diff --git a/CHANGELOG.md b/CHANGELOG.md index be2dd17f..b15dbeaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ For a diff between two versions https://github.com/lexik/LexikJWTAuthenticationB ## [2.0](https://github.com/lexik/LexikJWTAuthenticationBundle/tree/2.0) +* feature [\#230](https://github.com/lexik/LexikJWTAuthenticationBundle/pull/230) Introduce JWTExpiredEvent ([chalasr](https://github.com/chalasr)) + * feature [\#184](https://github.com/lexik/LexikJWTAuthenticationBundle/pull/184) [Security] Deprecate current system in favor of a JWTTokenAuthenticator (Guard) ([chalasr](https://github.com/chalasr)) * feature [\#218](https://github.com/lexik/LexikJWTAuthenticationBundle/pull/218) Add more flexibility in token extractors configuration ([chalasr](https://github.com/chalasr)) diff --git a/Encoder/DefaultEncoder.php b/Encoder/DefaultEncoder.php index 5ec7a9c3..9130b0f7 100644 --- a/Encoder/DefaultEncoder.php +++ b/Encoder/DefaultEncoder.php @@ -5,6 +5,7 @@ use InvalidArgumentException; use Lexik\Bundle\JWTAuthenticationBundle\Exception\JWTDecodeFailureException; use Lexik\Bundle\JWTAuthenticationBundle\Exception\JWTEncodeFailureException; +use Lexik\Bundle\JWTAuthenticationBundle\Exception\JWTExpirationTimeReachedException; use Lexik\Bundle\JWTAuthenticationBundle\Services\JWSProviderInterface; /** @@ -56,14 +57,16 @@ public function decode($token) throw new JWTDecodeFailureException('Invalid JWT Token', $e); } + $payload = $jws->getPayload(); + if ($jws->isExpired()) { - throw new JWTDecodeFailureException('Expired JWT Token'); + throw new JWTExpirationTimeReachedException($payload); } if (!$jws->isVerified()) { throw new JWTDecodeFailureException('Unable to verify the given JWT through the given configuration. If the "lexik_jwt_authentication.encoder" encryption options have been changed since your last authentication, please renew the token. If the problem persists, verify that the configured keys/passphrase are valid.'); } - return $jws->getPayload(); + return $payload; } } diff --git a/Encoder/JWTEncoderInterface.php b/Encoder/JWTEncoderInterface.php index 140cbf73..60655990 100644 --- a/Encoder/JWTEncoderInterface.php +++ b/Encoder/JWTEncoderInterface.php @@ -4,6 +4,7 @@ use Lexik\Bundle\JWTAuthenticationBundle\Exception\JWTDecodeFailureException; use Lexik\Bundle\JWTAuthenticationBundle\Exception\JWTEncodeFailureException; +use Lexik\Bundle\JWTAuthenticationBundle\Exception\JWTExpirationTimeReachedException; /** * JWTEncoderInterface. @@ -17,7 +18,8 @@ interface JWTEncoderInterface * * @return string the encoded token string * - * @throws JWTEncodeFailureException If an error occurred during the creation of the token (invalid configuration...) + * @throws JWTEncodeFailureException If an error occurred while trying to create + * the token (invalid configuration...) */ public function encode(array $data); @@ -26,7 +28,9 @@ public function encode(array $data); * * @return array * - * @throws JWTDecodeFailureException If an error occurred during the loading of the token (invalid signature, expired token...) + * @throws JWTExpirationTimeReachedException If the token is expired + * @throws JWTDecodeFailureException If an error occurred while trying to load + * the token (invalid signature, invalid config...) */ public function decode($token); } diff --git a/Event/JWTExpiredEvent.php b/Event/JWTExpiredEvent.php new file mode 100644 index 00000000..7dd02670 --- /dev/null +++ b/Event/JWTExpiredEvent.php @@ -0,0 +1,28 @@ + + */ +class JWTExpiredEvent extends AuthenticationFailureEvent implements JWTFailureEventInterface +{ + /** + * @param ExpiredTokenException $exception + * @param Response $response + */ + public function __construct(ExpiredTokenException $exception, Response $response) + { + parent::__construct($exception, $response); + } + + public function getInvalidPayload() + { + return $this->exception->getPayload(); + } +} diff --git a/Events.php b/Events.php index 1cfc077f..7ac92bc5 100644 --- a/Events.php +++ b/Events.php @@ -56,4 +56,11 @@ final class Events * Hook into this event to set a custom response. */ const JWT_NOT_FOUND = 'lexik_jwt_authentication.on_jwt_not_found'; + + /** + * Dispatched when the token is expired. + * The expired token's payload can be retrieved by hooking into this event, so you can set a different + * response, containing a new token for instance. + */ + const JWT_EXPIRED = 'lexik_jwt_authentication.on_jwt_expired'; } diff --git a/Exception/ExpiredTokenException.php b/Exception/ExpiredTokenException.php new file mode 100644 index 00000000..b85df7b5 --- /dev/null +++ b/Exception/ExpiredTokenException.php @@ -0,0 +1,46 @@ + + */ +class ExpiredTokenException extends AuthenticationException +{ + /** + * @var array + */ + private $payload; + + /** + * @param array $payload The invalidated payload + */ + public function __construct(array $payload) + { + parent::__construct(); + + $this->payload = $payload; + } + + /** + * @return array + */ + public function getPayload() + { + return $this->payload; + } + + /** + * {@inheritdoc} + */ + public function getMessageKey() + { + return 'Expired JWT Token'; + } +} diff --git a/Exception/JWTDecodeFailureException.php b/Exception/JWTDecodeFailureException.php index ebc5a367..59950e43 100644 --- a/Exception/JWTDecodeFailureException.php +++ b/Exception/JWTDecodeFailureException.php @@ -3,7 +3,7 @@ namespace Lexik\Bundle\JWTAuthenticationBundle\Exception; /** - * Base class for exceptions thrown during JWTEncoderInterface::decode(). + * JWTDecodeFailureException is thrown if an error occurs in the token decoding process. * * @author Robin Chalas */ diff --git a/Exception/JWTEncodeFailureException.php b/Exception/JWTEncodeFailureException.php index 6475a1ca..939fab65 100644 --- a/Exception/JWTEncodeFailureException.php +++ b/Exception/JWTEncodeFailureException.php @@ -3,7 +3,7 @@ namespace Lexik\Bundle\JWTAuthenticationBundle\Exception; /** - * Base class for exceptions thrown during JWTEncoderInterface::encode(). + * JWTEncodeFailureException is thrown if an error occurs in the token encoding process. * * @author Robin Chalas */ diff --git a/Exception/JWTExpirationTimeReachedException.php b/Exception/JWTExpirationTimeReachedException.php new file mode 100644 index 00000000..424e7c6b --- /dev/null +++ b/Exception/JWTExpirationTimeReachedException.php @@ -0,0 +1,34 @@ + + */ +class JWTExpirationTimeReachedException extends JWTDecodeFailureException +{ + /** + * @param array $invalidPayload The payload in which the exp time is reached + */ + public function __construct(array $invalidPayload) + { + $this->invalidPayload = $invalidPayload; + + parent::__construct('Expired JWT Token'); + } + + /** + * Gets the payload of the expired token. This can be useful + * to reuse the user informations, for automatically providing + * a new token for instance. + * + * @return array + */ + public function getInvalidPayload() + { + return $this->invalidPayload; + } +} diff --git a/Resources/doc/2-data-customization.md b/Resources/doc/2-data-customization.md index c51a1ec8..a8e89534 100644 --- a/Resources/doc/2-data-customization.md +++ b/Resources/doc/2-data-customization.md @@ -287,8 +287,6 @@ public function onJWTInvalid(JWTInvalidEvent $event) } ``` -__Note:__ This feature is not available if the `throw_exceptions` firewall option is set to `true`. - #### Events::JWT_NOT_FOUND - customize the response on token not found By default, if no token is found in a request, the authentication listener will either call the entry point that returns a unauthorized (401) json response, or (if the firewall allows anonymous requests), just let the request continue. @@ -326,6 +324,40 @@ public function onJWTNotFound(JWTNotFoundEvent $event) } ``` +#### Events::JWT_EXPIRED - customize the response on expired token + +By default, if no token is found in a request, the authentication listener will either call the entry point that returns a unauthorized (401) json response, or (if the firewall allows anonymous requests), just let the request continue. +Thanks to this event, you can set a custom response. + +``` yaml +# services.yml +services: + acme_api.event.jwt_invalid_listener: + class: Acme\Bundle\ApiBundle\EventListener\JWTInvalidListener + arguments: + - '@lexik_jwt_authentication.jwt_manager' + tags: + - { name: kernel.event_listener, event: lexik_jwt_authentication.on_jwt_not_found, method: attachNewToken } +``` + +Example 8: attach a new token to the response in case of expired token + +``` php +// Acme\Bundle\ApiBundle\EventListener\JWTAutoRefresher.php + +use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTExpiredEvent; + +/** + * @param JWTExpiredEvent $event + */ +public function onJWTExpired(JWTNotFoundEvent $event) +{ + $response = new JsonResponse($data, 403); + + $event->setResponse($response); +} +``` + __Protip:__ You might want to use the same method for customizing the response on both `JWT_INVALID` and `JWT_NOT_FOUND` events. For that, use the `Event\JWTFailureEventInterface` interface to typehint the event argument of your listener's method, rather than a specific event class (i.e. `JWTNotFoundEvent` or `JWTInvalidEvent`). diff --git a/Security/Guard/JWTTokenAuthenticator.php b/Security/Guard/JWTTokenAuthenticator.php index 721c33cd..23f4f0ee 100644 --- a/Security/Guard/JWTTokenAuthenticator.php +++ b/Security/Guard/JWTTokenAuthenticator.php @@ -3,11 +3,14 @@ namespace Lexik\Bundle\JWTAuthenticationBundle\Security\Guard; use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTAuthenticatedEvent; +use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTExpiredEvent; use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTInvalidEvent; use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTNotFoundEvent; use Lexik\Bundle\JWTAuthenticationBundle\Events; +use Lexik\Bundle\JWTAuthenticationBundle\Exception\ExpiredTokenException; use Lexik\Bundle\JWTAuthenticationBundle\Exception\JWTAuthenticationException; use Lexik\Bundle\JWTAuthenticationBundle\Exception\JWTDecodeFailureException; +use Lexik\Bundle\JWTAuthenticationBundle\Exception\JWTExpirationTimeReachedException; use Lexik\Bundle\JWTAuthenticationBundle\Response\JWTAuthenticationFailureResponse; use Lexik\Bundle\JWTAuthenticationBundle\Security\Authentication\Token\JWTUserToken; use Lexik\Bundle\JWTAuthenticationBundle\Security\Authentication\Token\PreAuthenticationJWTUserToken; @@ -85,6 +88,8 @@ public function getCredentials(Request $request) } $preAuthToken->setPayload($payload); + } catch (JWTExpirationTimeReachedException $e) { + throw new ExpiredTokenException($e->getInvalidPayload()); } catch (JWTDecodeFailureException $e) { throw JWTAuthenticationException::invalidToken($e); } @@ -140,8 +145,19 @@ public function getUser($preAuthToken, UserProviderInterface $userProvider) */ public function onAuthenticationFailure(Request $request, AuthenticationException $authException) { - $event = new JWTInvalidEvent($authException, new JWTAuthenticationFailureResponse($authException->getMessage())); - $this->dispatcher->dispatch(Events::JWT_INVALID, $event); + if ($authException instanceof ExpiredTokenException) { + $event = new JWTExpiredEvent( + $authException, + // After adding other AuthException classes, assign $response + // before the check and use it for both events, using getMessageKey() + new JWTAuthenticationFailureResponse($authException->getMessageKey()), + $authException->getPayload() + ); + $this->dispatcher->dispatch(Events::JWT_EXPIRED, $event); + } else { + $event = new JWTInvalidEvent($authException, new JWTAuthenticationFailureResponse($authException->getMessage())); + $this->dispatcher->dispatch(Events::JWT_INVALID, $event); + } return $event->getResponse(); } diff --git a/Tests/Encoder/DefaultEncoderTest.php b/Tests/Encoder/DefaultEncoderTest.php index 66e814f1..12149680 100644 --- a/Tests/Encoder/DefaultEncoderTest.php +++ b/Tests/Encoder/DefaultEncoderTest.php @@ -90,7 +90,7 @@ public function testDecodeFromUnverifiedJWS() /** * Tests that calling DefaultEncoder::decode() with an expired payload correctly fails. * - * @expectedException \Lexik\Bundle\JWTAuthenticationBundle\Exception\JWTDecodeFailureException + * @expectedException \Lexik\Bundle\JWTAuthenticationBundle\Exception\JWTExpirationTimeReachedException */ public function testDecodeFromExpiredPayload() { diff --git a/Tests/Functional/CompleteTokenAuthenticationTest.php b/Tests/Functional/CompleteTokenAuthenticationTest.php index 2613f648..81c3e319 100644 --- a/Tests/Functional/CompleteTokenAuthenticationTest.php +++ b/Tests/Functional/CompleteTokenAuthenticationTest.php @@ -17,7 +17,7 @@ public static function setupBeforeClass() static::bootKernel(); } - public function testAccessSecuredRoute() + public function testAccessSecuredRoute($token = null) { static::$client = static::createAuthenticatedClient(); static::$client->request('GET', '/api/secured'); @@ -52,7 +52,7 @@ public function testAccessSecuredRouteWithInvalidToken() /** * @group time-sensitive */ - public function testAccessSecuredRouteWithExpiredToken() + public function testAccessSecuredRouteWithExpiredToken($fail = true) { static::bootKernel(); @@ -64,7 +64,9 @@ public function testAccessSecuredRouteWithExpiredToken() $response = static::$client->getResponse(); - $this->assertFailure($response); + if (true === $fail) { + $this->assertFailure($response); + } return json_decode($response->getContent(), true); } diff --git a/Tests/Functional/DefaultTokenAuthenticationTest.php b/Tests/Functional/DefaultTokenAuthenticationTest.php index 4c7de35b..5c71d777 100644 --- a/Tests/Functional/DefaultTokenAuthenticationTest.php +++ b/Tests/Functional/DefaultTokenAuthenticationTest.php @@ -26,7 +26,7 @@ public function testAccessSecuredRouteWithInvalidToken() /** * @group time-sensitive */ - public function testAccessSecuredRouteWithExpiredToken() + public function testAccessSecuredRouteWithExpiredToken($fail = true) { $response = parent::testAccessSecuredRouteWithExpiredToken(); diff --git a/Tests/Functional/SubscribedTokenAuthenticationTest.php b/Tests/Functional/SubscribedTokenAuthenticationTest.php index b9d4fbc3..49e5e269 100644 --- a/Tests/Functional/SubscribedTokenAuthenticationTest.php +++ b/Tests/Functional/SubscribedTokenAuthenticationTest.php @@ -3,9 +3,12 @@ namespace Lexik\Bundle\JWTAuthenticationBundle\Tests\Functional; use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTDecodedEvent; +use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTExpiredEvent; use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTInvalidEvent; use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTNotFoundEvent; use Lexik\Bundle\JWTAuthenticationBundle\Events; +use Lexik\Bundle\JWTAuthenticationBundle\Response\JWTAuthenticationSuccessResponse; +use Lexik\Bundle\JWTAuthenticationBundle\Tests\Stubs\User; /** * Tests the overriding authentication response mechanism. @@ -66,9 +69,9 @@ public function testAccessSecuredRouteWithInvalidJWTDecodedEvent() /** * @group time-sensitive */ - public function testAccessSecuredRouteWithExpiredToken() + public function testAccessSecuredRouteWithExpiredToken($fail = true) { - self::$subscriber->setListener(Events::JWT_INVALID, function (JWTInvalidEvent $e) { + self::$subscriber->setListener(Events::JWT_EXPIRED, function (JWTExpiredEvent $e) { $e->getResponse()->setMessage('Custom JWT Expired Token message'); }); @@ -76,6 +79,28 @@ public function testAccessSecuredRouteWithExpiredToken() $this->assertSame('Custom JWT Expired Token message', $response['message']); - self::$subscriber->unsetListener(Events::JWT_INVALID); + self::$subscriber->unsetListener(Events::JWT_EXPIRED); + } + + /** + * @group time-sensitive + */ + public function testRenewTokenOnJWTExpired() + { + static::bootKernel(); + $jwtManager = static::$kernel->getContainer()->get('lexik_jwt_authentication.jwt_manager'); + + self::$subscriber->setListener(Events::JWT_EXPIRED, function (JWTExpiredEvent $e) use ($jwtManager) { + $user = new User($e->getInvalidPayload(), ''); + $e->setResponse(new JWTAuthenticationSuccessResponse($jwtManager->create($user))); + }); + + $response = parent::testAccessSecuredRouteWithExpiredToken(false); + + $this->assertArrayHasKey('token', $response); + + parent::testAccessSecuredRoute($response['token']); + + self::$subscriber->unsetListener(Events::JWT_EXPIRED); } } diff --git a/Tests/Functional/Utils/CallableEventSubscriber.php b/Tests/Functional/Utils/CallableEventSubscriber.php index 0695df1f..164268cb 100644 --- a/Tests/Functional/Utils/CallableEventSubscriber.php +++ b/Tests/Functional/Utils/CallableEventSubscriber.php @@ -6,6 +6,7 @@ use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTCreatedEvent; use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTDecodedEvent; use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTEncodedEvent; +use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTExpiredEvent; use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTInvalidEvent; use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTNotFoundEvent; use Lexik\Bundle\JWTAuthenticationBundle\Events; @@ -22,6 +23,7 @@ class CallableEventSubscriber implements EventSubscriberInterface Events::JWT_NOT_FOUND => JWTNotFoundEvent::class, Events::JWT_ENCODED => JWTEncodedEvent::class, Events::JWT_AUTHENTICATED => JWTAuthenticatedEvent::class, + Events::JWT_EXPIRED => JWTExpiredEvent::class, ]; public static function getSubscribedEvents() diff --git a/Tests/Security/Guard/JWTTokenAuthenticatorTest.php b/Tests/Security/Guard/JWTTokenAuthenticatorTest.php index 4bb4d75c..2e3d7a45 100644 --- a/Tests/Security/Guard/JWTTokenAuthenticatorTest.php +++ b/Tests/Security/Guard/JWTTokenAuthenticatorTest.php @@ -6,7 +6,9 @@ use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTInvalidEvent; use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTNotFoundEvent; use Lexik\Bundle\JWTAuthenticationBundle\Events; +use Lexik\Bundle\JWTAuthenticationBundle\Exception\ExpiredTokenException; use Lexik\Bundle\JWTAuthenticationBundle\Exception\JWTAuthenticationException; +use Lexik\Bundle\JWTAuthenticationBundle\Exception\JWTExpirationTimeReachedException; use Lexik\Bundle\JWTAuthenticationBundle\Response\JWTAuthenticationFailureResponse; use Lexik\Bundle\JWTAuthenticationBundle\Security\Authentication\Token\JWTUserToken; use Lexik\Bundle\JWTAuthenticationBundle\Security\Authentication\Token\PreAuthenticationJWTUserToken; @@ -55,7 +57,29 @@ public function testGetCredentialsWithInvalidToken() ))->getCredentials($this->getRequestMock()); } - public function testGetCredentialsWithoutToken() + public function testGetCredentialsWithExpiredToken() + { + $jwtManager = $this->getJWTManagerMock(); + $jwtManager + ->expects($this->once()) + ->method('decode') + ->with(new PreAuthenticationJWTUserToken('token')) + ->will($this->throwException(new JWTExpirationTimeReachedException([]))); + + try { + (new JWTTokenAuthenticator( + $jwtManager, + $this->getEventDispatcherMock(), + $this->getTokenExtractorMock('token') + ))->getCredentials($this->getRequestMock()); + + $this->fail(sprintf('Expected exception of type "%s" to be thrown.', ExpiredTokenException::class)); + } catch (ExpiredTokenException $e) { + $this->assertSame('Expired JWT Token', $e->getMessageKey()); + } + } + + public function testGetCredentialsReturnsNullWithoutToken() { $authenticator = new JWTTokenAuthenticator( $this->getJWTManagerMock(),