From 8680a1c24975ea9d354775c251f31929e61c33e2 Mon Sep 17 00:00:00 2001 From: Robin Chalas Date: Tue, 23 Aug 2016 00:39:39 +0200 Subject: [PATCH] Introduce JWTExpiredEvent * Refactor encoder exceptions * Define encode/decode exceptions message from Encoder, Remove auto-renew behavior * Code quality tweeaks --- CHANGELOG.md | 2 + Encoder/DefaultEncoder.php | 14 ++++--- Encoder/JWTEncoderInterface.php | 6 ++- Event/JWTExpiredEvent.php | 12 ++++++ Events.php | 7 ++++ Exception/ExpiredTokenException.php | 23 +++++++++++ Exception/JWTDecodeFailureException.php | 5 ++- Exception/JWTEncodeFailureException.php | 4 +- Exception/JWTFailureException.php | 18 ++++++++- Resources/doc/2-data-customization.md | 40 ++++++++++++++++--- Security/Guard/JWTTokenAuthenticator.php | 20 +++++++++- Tests/Encoder/DefaultEncoderTest.php | 3 +- .../CompleteTokenAuthenticationTest.php | 6 ++- .../DefaultTokenAuthenticationTest.php | 2 +- .../SubscribedTokenAuthenticationTest.php | 7 ++-- .../Utils/CallableEventSubscriber.php | 2 + .../Guard/JWTTokenAuthenticatorTest.php | 26 +++++++++++- UPGRADE.md | 4 ++ composer.json | 6 --- 19 files changed, 175 insertions(+), 32 deletions(-) create mode 100644 Event/JWTExpiredEvent.php create mode 100644 Exception/ExpiredTokenException.php 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..fb2ad5f4 100644 --- a/Encoder/DefaultEncoder.php +++ b/Encoder/DefaultEncoder.php @@ -35,11 +35,11 @@ public function encode(array $payload) try { $jws = $this->jwsProvider->create($payload); } catch (InvalidArgumentException $e) { - throw new JWTEncodeFailureException('An error occurred while trying to encode the JWT token.', $e); + throw new JWTEncodeFailureException(JWTEncodeFailureException::INVALID_CONFIG, 'An error occured while trying to encode the JWT token. Please verify your configuration (private key/passphrase)', $e); } if (!$jws->isSigned()) { - throw new JWTEncodeFailureException('Unable to create a signed JWT from the given configuration.'); + throw new JWTEncodeFailureException(JWTEncodeFailureException::UNSIGNED_TOKEN, 'Unable to create a signed JWT from the given configuration.'); } return $jws->getToken(); @@ -53,17 +53,19 @@ public function decode($token) try { $jws = $this->jwsProvider->load($token); } catch (InvalidArgumentException $e) { - throw new JWTDecodeFailureException('Invalid JWT Token', $e); + throw new JWTDecodeFailureException(JWTDecodeFailureException::INVALID_TOKEN, 'Invalid JWT Token', $e); } + $payload = $jws->getPayload(); + if ($jws->isExpired()) { - throw new JWTDecodeFailureException('Expired JWT Token'); + throw new JWTDecodeFailureException(JWTDecodeFailureException::EXPIRED_TOKEN, 'Expired JWT Token'); } 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.'); + throw new JWTDecodeFailureException(JWTDecodeFailureException::UNVERIFIED_TOKEN, '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..a65b223f 100644 --- a/Encoder/JWTEncoderInterface.php +++ b/Encoder/JWTEncoderInterface.php @@ -17,7 +17,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 crypto key, invalid payload...) */ public function encode(array $data); @@ -26,7 +27,8 @@ public function encode(array $data); * * @return array * - * @throws JWTDecodeFailureException If an error occurred during the loading of the token (invalid signature, expired token...) + * @throws JWTDecodeFailureException If an error occurred while trying to load the token + * (invalid signature, invalid crypto key, expired token...) */ public function decode($token); } diff --git a/Event/JWTExpiredEvent.php b/Event/JWTExpiredEvent.php new file mode 100644 index 00000000..d0129bcb --- /dev/null +++ b/Event/JWTExpiredEvent.php @@ -0,0 +1,12 @@ + + */ +class JWTExpiredEvent extends AuthenticationFailureEvent implements JWTFailureEventInterface +{ +} diff --git a/Events.php b/Events.php index 1cfc077f..14b8f2bc 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. + */ + 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..c842ddb6 --- /dev/null +++ b/Exception/ExpiredTokenException.php @@ -0,0 +1,23 @@ + + */ +class ExpiredTokenException extends AuthenticationException +{ + /** + * {@inheritdoc} + */ + public function getMessageKey() + { + return 'Expired JWT Token'; + } +} diff --git a/Exception/JWTDecodeFailureException.php b/Exception/JWTDecodeFailureException.php index ebc5a367..0c81a958 100644 --- a/Exception/JWTDecodeFailureException.php +++ b/Exception/JWTDecodeFailureException.php @@ -3,10 +3,13 @@ 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 */ class JWTDecodeFailureException extends JWTFailureException { + const INVALID_TOKEN = 'invalid_token'; + const UNVERIFIED_TOKEN = 'unverified_token'; + const EXPIRED_TOKEN = 'expired_token'; } diff --git a/Exception/JWTEncodeFailureException.php b/Exception/JWTEncodeFailureException.php index 6475a1ca..65fad663 100644 --- a/Exception/JWTEncodeFailureException.php +++ b/Exception/JWTEncodeFailureException.php @@ -3,10 +3,12 @@ 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 */ class JWTEncodeFailureException extends JWTFailureException { + const INVALID_CONFIG = 'invalid_config'; + const UNSIGNED_TOKEN = 'unsigned_token'; } diff --git a/Exception/JWTFailureException.php b/Exception/JWTFailureException.php index 7aa84d64..27c53fbd 100644 --- a/Exception/JWTFailureException.php +++ b/Exception/JWTFailureException.php @@ -10,11 +10,27 @@ class JWTFailureException extends \Exception { /** + * @var string + */ + private $reason; + + /** + * @param string $reason * @param string $message * @param \Exception|null $previous */ - public function __construct($message, \Exception $previous = null) + public function __construct($reason, $message, \Exception $previous = null) { + $this->reason = $reason; + parent::__construct($message, 0, $previous); } + + /** + * @return string + */ + public function getReason() + { + return $this->reason; + } } diff --git a/Resources/doc/2-data-customization.md b/Resources/doc/2-data-customization.md index c51a1ec8..944304ca 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,38 @@ public function onJWTNotFound(JWTNotFoundEvent $event) } ``` -__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`). +#### Events::JWT_EXPIRED - customize the response message on expired token + +By default, if the token provided in the request is expired, the authentication listener will call the entry point returning an unauthorized (401) json response. +Thanks to this event, you can set a custom response or simply change the response message. + +``` yaml +# services.yml +services: + acme_api.event.jwt_expired_listener: + class: Acme\Bundle\ApiBundle\EventListener\JWTExpiredListener + tags: + - { name: kernel.event_listener, event: lexik_jwt_authentication.on_jwt_expired, method: onJWTExpired } +``` + +Example 8: customize the response in case of expired token + +``` php +// Acme\Bundle\ApiBundle\EventListener\JWTExpiredListener.php + +use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTExpiredEvent; + +/** + * @param JWTExpiredEvent $event + */ +public function onJWTExpired(JWTExpiredEvent $event) +{ + /** @var \Lexik\Bundle\JWTAuthenticationBundle\Response\JWTAuthenticationFailureResponse */ + $response = $event->getResponse(); + + $response->setMessage('Your token is expired, please renew it.'); +} +``` + +__Protip:__ You might want to use the same method for customizing the response on both `JWT_INVALID`, `JWT_NOT_FOUND` and/or `JWT_EXPIRED` events. +For that, use the `Event\JWTFailureEventInterface` interface to type-hint the event argument of your listener's method, rather the class corresponding to one of these specific events. diff --git a/Security/Guard/JWTTokenAuthenticator.php b/Security/Guard/JWTTokenAuthenticator.php index 721c33cd..1b512c77 100644 --- a/Security/Guard/JWTTokenAuthenticator.php +++ b/Security/Guard/JWTTokenAuthenticator.php @@ -3,9 +3,11 @@ 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\Response\JWTAuthenticationFailureResponse; @@ -86,6 +88,10 @@ public function getCredentials(Request $request) $preAuthToken->setPayload($payload); } catch (JWTDecodeFailureException $e) { + if (JWTDecodeFailureException::EXPIRED_TOKEN === $e->getReason()) { + throw new ExpiredTokenException(); + } + throw JWTAuthenticationException::invalidToken($e); } @@ -140,8 +146,18 @@ 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()) + ); + $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 fa1f1bea..ad98ee16 100644 --- a/Tests/Encoder/DefaultEncoderTest.php +++ b/Tests/Encoder/DefaultEncoderTest.php @@ -90,7 +90,8 @@ 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\JWTDecodeFailureException + * @expectedExceptionMessage Expired JWT Token */ public function testDecodeFromExpiredPayload() { diff --git a/Tests/Functional/CompleteTokenAuthenticationTest.php b/Tests/Functional/CompleteTokenAuthenticationTest.php index 2613f648..3a5f3c10 100644 --- a/Tests/Functional/CompleteTokenAuthenticationTest.php +++ b/Tests/Functional/CompleteTokenAuthenticationTest.php @@ -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 bdcf706a..7ecf304f 100644 --- a/Tests/Functional/SubscribedTokenAuthenticationTest.php +++ b/Tests/Functional/SubscribedTokenAuthenticationTest.php @@ -3,6 +3,7 @@ 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; @@ -75,9 +76,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) { $response = $e->getResponse(); if ($response instanceof JWTAuthenticationFailureResponse) { @@ -89,6 +90,6 @@ public function testAccessSecuredRouteWithExpiredToken() $this->assertSame('Custom JWT Expired Token message', $response['message']); - self::$subscriber->unsetListener(Events::JWT_INVALID); + 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 da8be322..5b16b1f8 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\JWTDecodeFailureException; use Lexik\Bundle\JWTAuthenticationBundle\Response\JWTAuthenticationFailureResponse; use Lexik\Bundle\JWTAuthenticationBundle\Security\Authentication\Token\JWTUserToken; use Lexik\Bundle\JWTAuthenticationBundle\Security\Authentication\Token\PreAuthenticationJWTUserToken; @@ -51,7 +53,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 JWTDecodeFailureException(JWTDecodeFailureException::EXPIRED_TOKEN, 'Expired JWT Token'))); + + 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(), diff --git a/UPGRADE.md b/UPGRADE.md index ec84553f..a2441e92 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -113,6 +113,10 @@ Events } ``` +* Introduced JWTExpiredEvent + In 1.x, trying to authenticate an user with an expired token was causing a JWTInvalidEvent to be dispatched, + as for several other mixed reasons. Now in 2.x, this failure reason has its own event on which you can listen on. + Encoder ------- diff --git a/composer.json b/composer.json index bbf06c69..dff64387 100644 --- a/composer.json +++ b/composer.json @@ -49,12 +49,6 @@ "symfony/browser-kit": "^2.8|^3.0", "friendsofphp/php-cs-fixer": "^1.1" }, - "require-dev": { - "friendsofphp/php-cs-fixer": "~1.1", - "phpunit/phpunit": "^4.1", - "symfony/phpunit-bridge": "~2.8|~3.0", - "symfony/browser-kit": "~2.8|~3.0" - }, "suggest": { "gesdinet/jwt-refresh-token-bundle": "Implements a refresh token system over Json Web Tokens in Symfony", "spomky-labs/lexik-jose-bridge": "JWT Token encoder with encryption support"