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

Ignore expired tokens and let user continue unauthenticated #298

Closed
ruudk opened this issue Dec 30, 2016 · 17 comments
Closed

Ignore expired tokens and let user continue unauthenticated #298

ruudk opened this issue Dec 30, 2016 · 17 comments

Comments

@ruudk
Copy link
Contributor

ruudk commented Dec 30, 2016

When the JWT token of a user is expired, a JWTAuthenticationFailureResponse is shown. Is there a way to not do that? And just stop authentication and let the user continue anonymous?

Currently, there is no way to do this by subscribing to the JWTExpiredEvent event. Since the event has a default JWTAuthenticationFailureResponse response that can only be overwritten (not set to null).

I'm willing to create a PR for this use case, but first want to discuss the best approach. Any thoughts?

@ruudk
Copy link
Contributor Author

ruudk commented Dec 30, 2016

I was using a cookie to store the JWT. I think I already solved this issue, because I had to make sure the cookie expiry time was the same as the JWT expiry time. Then, the scenario above will almost never occur.

@chalasr
Copy link
Collaborator

chalasr commented Jan 1, 2017

Hello @ruudk,

What you are asking is legit to me. Though, the need is quite specific, the expected behavior stays to throw exceptions if bad credentials are given (which is the case) and don't throw them if no credentials are given, so I'm not sure that it should be solved in the bundle.

A way to solve that could be to inject the Symfony\Bundle\SecurityBundle\Security\FirewallMap (security.firewall_map service) in the authenticator, catch all AuthenticationException in getCredentials() and rethrow them only if the firewall doesn't allow anonymous.

Here is what I end up with:

namespace AppBundle\Security;

use Lexik\Bundle\JWTAuthenticationBundle\TokenExtractor\TokenExtractorInterface;
use Lexik\Bundle\JWTAuthenticationBundle\Security\Guard\JWTTokenAuthenticator;
use Symfony\Bundle\SecurityBundle\Security\FirewallMap;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;

final class JWTAuthenticator extends JWTTokenAuthenticator
{
    private $firewallMap;

    public function __construct(
        JWTTokenManagerInterface $jwtManager,
        EventDispatcherInterface $dispatcher,
        TokenExtractorInterface $tokenExtractor,
        FirewallMap $firewallMap
    ) {
        parent::__construct($jwtManager, $dispatcher, $tokenExtractor);

        $this->firewallMap = $firewallMap;
    }

    public function getCredentials(Request $request)
    {
        try {
            return parent::getCredentials($request);
        } catch (AuthenticationException $e) {
            $firewall = $this->firewallMap->getFirewallConfig($request);

            if ($firewall->allowsAnonymous()) {
                return;
            }

            throw $e;
        }
    }
}
services:
    app.jwt_authenticator:
        parent: lexik_jwt_authentication.security.guard.jwt_token_authenticator
        class: AppBundle\Security\JWTAuthenticator
        arguments: ['@security.firewall.map']

Note that this requires symfony 3.2+

@ruudk
Copy link
Contributor Author

ruudk commented Jan 4, 2017

It works! Awesome. Thank you :)

@ruudk
Copy link
Contributor Author

ruudk commented Jan 4, 2017

Would be nice to add as a doc :)

@chalasr
Copy link
Collaborator

chalasr commented Jan 4, 2017

Would be nice to add as a doc :)

Indeed 👍 Please let this open, I'll do it asap

@abdallahmaali
Copy link

Hello @chalasr,
I have a problem with your solution, my Symfony version is 3.3.6, but it keeps return 401 error when the token is expired but when I send valid one it's working fine and I can get the user information
could you help me, please?

@chalasr
Copy link
Collaborator

chalasr commented Jun 25, 2018

@abdallahmaali the best would be to create a sample app and share it here so I can give it a look.

@ruudk ruudk closed this as completed Oct 8, 2018
@vladdoga
Copy link

Hello, @chalasr,
I have a problem with symfony 4 using this trick. I have setup the service exactly as you proposed and then set it up as guard on the firewall:

app_api:
            pattern:    ^/
            stateless:  true
            anonymous:  true
            guard:
                authenticators:
                    - app.jwt_authenticator

On the expired token case, when getCredentials() returns null I get an exception from symfony's GuardAuthenticationListener:

The return value of "App\Security\JWTAuthenticator::getCredentials()" must not be null. Return false from "App\Security\JWTAuthenticator::supports()" instead.

I guess I should make supports() to return false in the case of the expired token. What would be a correct implementation for this?

Thank you very much for your help!

@fidraj
Copy link

fidraj commented Apr 17, 2019

You have to implement the supports() method in a way that it checks if the token is expired and return false when it is. Unfortunately you have to actually decode the token which makes it being called twice.

I'm wondering if there's another solution to bypass the expired token.

@SDenman
Copy link

SDenman commented Mar 24, 2020

Hi there,
I'm running into this problem too. I'm running Symfony v4 (and API-Platform) and I am storing the JWT token as a HTTP Only cookie.
I am refreshing that cookie with an event subscriber onAuthenticatedResponse and this all works fine unless the token expires between accesses.
When this happens I need to delete the cookie on the client browser before the client can log back in.
Now I have an api logout endpoint on my backend that deletes the cookie, but although its route is set under Access_Control as
- { path: ^/api_logout, roles: IS_AUTHENTICATED_ANONYMOUSLY }
it only works when the user has a current session.

Now, I tried the above fix by creating a JWTAuthenticator, but Symfony 4 complains that:

Attribute "autowire" on service "app.jwt_authenticator" cannot be inherited from "_defaults" when a "parent" is set. Move your child definitions to a separate file or define this attribute explicitly in /srv/api/config/services.yaml (which is loaded in resource "/srv/api/config/services.yaml").<

Any ideas?

@itsmitul
Copy link

itsmitul commented Jul 5, 2020

@SDenman

app.jwt_authenticator:
        parent: lexik_jwt_authentication.security.guard.jwt_token_authenticator
        class: App\Security\JWTAuthenticator
        arguments: ['@security.firewall.map']
        autowire: true
        autoconfigure: false

@itsmitul
Copy link

itsmitul commented Jul 5, 2020

This seems to be the only way for now:

<?php

namespace App\Security\Guard;

use Lexik\Bundle\JWTAuthenticationBundle\TokenExtractor\TokenExtractorInterface;
use Lexik\Bundle\JWTAuthenticationBundle\Security\Guard\JWTTokenAuthenticator;
use Symfony\Bundle\SecurityBundle\Security\FirewallMap;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;

class JWTAuthenticator extends JWTTokenAuthenticator
{
    private $firewallMap;

    public function __construct(
        JWTTokenManagerInterface $jwtManager,
        EventDispatcherInterface $dispatcher,
        TokenExtractorInterface $tokenExtractor,
        FirewallMap $firewallMap
    )
    {
        parent::__construct($jwtManager, $dispatcher, $tokenExtractor);

        $this->firewallMap = $firewallMap;
    }

    public function supports(Request $request)
    {
        if($this->getTokenExtractor()->extract($request)===false) {
            return false;
        }
        try {
            parent::getCredentials($request);
            return true;
        } catch (AuthenticationException $e) {
            $firewall = $this->firewallMap->getFirewallConfig($request);

            if ($firewall->allowsAnonymous()) {
                return false;
            }

            throw $e;
        }
    }
}
app.jwt_token_authenticator:
        class: App\Security\Guard\JWTAuthenticator
        parent: lexik_jwt_authentication.security.guard.jwt_token_authenticator
        arguments: ['@security.firewall.map']
        autowire: true
        autoconfigure: false
main:
            stateless: true
            anonymous: true
            guard:
                authenticators:
                    - app.jwt_token_authenticator

@deepmikoto
Copy link

For me, using Symfony 4.4, the above example did not work, it was complaining about the bind property not being inherited from _defaults.

Another similar way that worked for me was to use a decorator:

<?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\Security\Core\Exception\AuthenticationException;

class JWTAuthenticator extends JWTTokenAuthenticator
{
    /** @var FirewallMap */
    private $firewallMap;

    /** @var JWTTokenAuthenticator */
    private $decorated;

    /**
     * @param JWTTokenAuthenticator $decorated
     * @param JWTTokenManagerInterface $jwtManager
     * @param EventDispatcherInterface $dispatcher
     * @param TokenExtractorInterface $tokenExtractor
     */
    public function __construct(
        JWTTokenAuthenticator $decorated,
        JWTTokenManagerInterface $jwtManager,
        EventDispatcherInterface $dispatcher,
        TokenExtractorInterface $tokenExtractor
    ) {
        $this->decorated = $decorated;

        parent::__construct($jwtManager, $dispatcher, $tokenExtractor);
    }

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

    /**
     * @param Request $request
     *
     * @return bool
     */
    public function supports(Request $request): bool
    {
        try {
            return $this->decorated->supports($request) && $this->decorated->getCredentials($request);
        } catch (AuthenticationException $e) {
            if ($this->firewallMap->getFirewallConfig($request)->allowsAnonymous()) {
                return false;
            }

            throw $e;
        }
    }
}

And the services.yaml definition:

    App\Security\JWTAuthenticator:
        decorates: lexik_jwt_authentication.security.guard.jwt_token_authenticator
        calls:
            - ['setFirewallMap', ['@security.firewall.map']]

Maybe it helps somebody.

@david-vde
Copy link

Hi

None of the above solution works for me on symfony 5.2.

I Always get an allowsAnonymous = false

even if I have this in the security.yml

access_control:
    - { path: ^/, roles: PUBLIC_ACCESS }

I'm still having this exception generated if the cookie is expired...

Any clue?

Thanks

@alexsegura
Copy link

alexsegura commented Mar 24, 2021

A slightly different implementation than @deepmikoto, to avoid PHPStan complaining about Right side of && is always true.
Also, it does not re-throw the exception, so that the onAuthenticationFailure method is invoked, and returns the nice JSON response.

public function supports(Request $request): bool
{
    if (!$this->decorated->supports($request)) {
        return false;
    }

    try {
        $credentials = $this->decorated->getCredentials($request);
    } catch (ExpiredTokenException $e) {
        if ($this->firewallMap->getFirewallConfig($request)->allowsAnonymous()) {
            return false;
        }
    }

    return true;
}

@chalasr
Copy link
Collaborator

chalasr commented Mar 24, 2021

Thanks everyone for sharing your up-to-date solutions. I will try to make this behavior easier to implement in a next version.

@abeal-hottomali
Copy link

For anyone finding this later, I added another approach if the above do not work for your situation: #862

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

No branches or pull requests