diff --git a/CHANGELOG.md b/CHANGELOG.md index a69d694f..be2dd17f 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 [\#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)) * feature [\#217](https://github.com/lexik/LexikJWTAuthenticationBundle/pull/217) Refactor TokenExtractors loading for easy overriding ([chalasr](https://github.com/chalasr)) @@ -25,7 +27,7 @@ For a diff between two versions https://github.com/lexik/LexikJWTAuthenticationB ## [1.7.0](https://github.com/lexik/LexikJWTAuthenticationBundle/tree/v1.7.0) (2016-08-06) -* feature [\#200](https://github.com/lexik/LexikJWTAuthenticationBundle/pull/200) Depreciate injection of Request instances ([chalasr](https://github.com/chalasr)) +* feature [\#200](https://github.com/lexik/LexikJWTAuthenticationBundle/pull/200) Deprecate injection of Request instances ([chalasr](https://github.com/chalasr)) ## [v1.6.0](https://github.com/lexik/LexikJWTAuthenticationBundle/tree/v1.6.0) (2016-07-07) diff --git a/Encoder/DefaultEncoder.php b/Encoder/DefaultEncoder.php index cb74718d..5ec7a9c3 100644 --- a/Encoder/DefaultEncoder.php +++ b/Encoder/DefaultEncoder.php @@ -57,7 +57,7 @@ public function decode($token) } if ($jws->isExpired()) { - throw new JWTDecodeFailureException('Expired JWT token'); + throw new JWTDecodeFailureException('Expired JWT Token'); } if (!$jws->isVerified()) { diff --git a/Encoder/JWTEncoderInterface.php b/Encoder/JWTEncoderInterface.php index 5a21b740..140cbf73 100644 --- a/Encoder/JWTEncoderInterface.php +++ b/Encoder/JWTEncoderInterface.php @@ -24,7 +24,7 @@ public function encode(array $data); /** * @param string $token * - * @return false|array + * @return array * * @throws JWTDecodeFailureException If an error occurred during the loading of the token (invalid signature, expired token...) */ diff --git a/Exception/JWTAuthenticationException.php b/Exception/JWTAuthenticationException.php new file mode 100644 index 00000000..9f6a376a --- /dev/null +++ b/Exception/JWTAuthenticationException.php @@ -0,0 +1,69 @@ + + */ +class JWTAuthenticationException extends AuthenticationException +{ + /** + * Returns an AuthenticationException in case of invalid token. + * + * To be used if the token cannot be properly decoded. + * + * @param JWTDecodeFailureException|null $previous + * + * @return JWTAuthenticationException + */ + public static function invalidToken(JWTDecodeFailureException $previous = null) + { + return new self($previous ? $previous->getMessage() : 'Invalid JWT Token', 0, $previous); + } + + /** + * Returns an AuthenticationException in case of token not found. + * + * @param string $message + * + * @return JWTAuthenticationException + */ + public static function tokenNotFound($message = 'JWT Token not found') + { + return new self($message); + } + + /** + * Returns an AuthenticationException in case of invalid user. + * + * To be used if no user can be loaded from the identity retrieved from + * the decoded token's payload. + * + * @param string $identity + * @param string $identityField + * + * @return JWTAuthenticationException + */ + public static function invalidUser($identity, $identityField) + { + return new self(sprintf('Unable to load a valid user with property "%s" = "%s". If the user identity has been changed, you must renew the token. Otherwise, verify that the "lexik_jwt_authentication.user_identity_field" config option is correctly set.', $identityField, $identity)); + } + + /** + * Returns an AuthenticationException in case of invalid payload. + * + * To be used if a key in missing in the payload or contains an unexpected value. + * + * @param string $message + * + * @return JWTAuthenticationException + */ + public static function invalidPayload($message = 'Invalid payload') + { + return new self($message); + } +} diff --git a/Resources/config/services.xml b/Resources/config/services.xml index 8a122071..06fbc641 100644 --- a/Resources/config/services.xml +++ b/Resources/config/services.xml @@ -18,6 +18,23 @@ %lexik_jwt_authentication.user_identity_field% + + + + %lexik_jwt_authentication.encoder.encryption_engine% + %lexik_jwt_authentication.encoder.signature_algorithm% + + + + + + + + + + + + @@ -28,6 +45,7 @@ + %lexik_jwt_authentication.private_key_path% @@ -40,7 +58,8 @@ %lexik_jwt_authentication.public_key_path% %lexik_jwt_authentication.pass_phrase% - + + @@ -48,8 +67,9 @@ %lexik_jwt_authentication.user_identity_field% + The "%service_id%" service is deprecated since LexikJWTAuthenticationBundle version 2.0 and will be removed in 3.0 - + @@ -57,7 +77,11 @@ + The "%service_id%" service is deprecated since LexikJWTAuthenticationBundle version 2.0 and will be removed in 3.0 + + + @@ -82,14 +106,6 @@ - - - - - - %lexik_jwt_authentication.encoder.encryption_engine% - %lexik_jwt_authentication.encoder.signature_algorithm% - diff --git a/Resources/doc/1-configuration-reference.md b/Resources/doc/1-configuration-reference.md index 4bcf52a5..e9cf953e 100644 --- a/Resources/doc/1-configuration-reference.md +++ b/Resources/doc/1-configuration-reference.md @@ -1,8 +1,10 @@ Configuration reference ======================= -Configuration reference ------------------------- +Bundle configuration +--------------------- + +### Minimal configuration ### Full default configuration @@ -11,9 +13,9 @@ Configuration reference # ... lexik_jwt_authentication: # ssh private key path - private_key_path: %kernel.root_dir%/var/jwt/private.pem + private_key_path: '%kernel.root_dir%/var/jwt/private.pem' # ssh public key path - public_key_path: %kernel.root_dir%/var/jwt/public.pem + public_key_path: '%kernel.root_dir%/var/jwt/public.pem' # ssh key pass phrase pass_phrase: '' # token ttl @@ -21,28 +23,42 @@ lexik_jwt_authentication: # key under which the user identity will be stored in the token payload user_identity_field: username + # token encoding/decoding settings encoder: # token encoder/decoder service - default implementation based on the namshi/jose library - service: lexik_jwt_authentication.encoder.default + service: lexik_jwt_authentication.encoder.default # encryption engine used by the encoder service - encryption_engine: openssl + encryption_engine: openssl # encryption algorithm used by the encoder service - signature_algorithm: RS256 + signature_algorithm: RS256 + + # token extraction settings + token_extractors: + authorization_header: # look for a token as Authorization Header + enabled: true + prefix: Bearer + name: Authorization + cookie: # check token in a cookie + enabled: false + name: BEARER + query_parameter: # check token in query string parameter + enabled: false + name: bearer ``` -### Encoder configuration +#### Encoder configuration -#### service +##### service Default based on the [Namshi/JOSE](https://github.com/namshi/jose) library. To create your own encoder service, see the [JWT encoder service customization chapter](5-encoder-service.md). -#### encryption_engine +##### encryption_engine One of `openssl` and `phpseclib`, the encryption engines supported by the default token encoder service. See the [OpenSSL](https://github.com/openssl/openssl) and [phpseclib](https://github.com/phpseclib/phpseclib) documentations for more information. -#### signature_algorithm +##### signature_algorithm One of the algorithms supported by the default encoder for the configured [encryption engine](#encryption_engine). @@ -54,44 +70,22 @@ __Supported algorithms for OpenSSL:__ __Supported algorithms for phpseclib:__ - RS256, RS384, RS512 (RSA) -Security reference -------------------- - -### Simplest configuration - -``` yaml -# app/config/security.yml -# ... -firewalls: - # ... - api: - # ... - lexik_jwt: ~ # check token in Authorization Header, with a value prefix of 'Bearer' -``` +Security configuration +----------------------- ### Full default configuration -``` yaml +```yaml # app/config/security.yml # ... firewalls: # ... api: # ... - # advanced configuration - lexik_jwt: - authorization_header: # check token in Authorization Header - enabled: true - prefix: Bearer - name: Authorization - cookie: # check token in a cookie - enabled: false - name: BEARER - query_parameter: # check token in query string parameter - enabled: false - name: bearer - throw_exceptions: false # When an authentication failure occurs, return a 401 response immediately - create_entry_point: true # When no authentication details are provided, create a default entry point that returns a 401 response - authentication_provider: lexik_jwt_authentication.security.authentication.provider - authentication_listener: lexik_jwt_authentication.security.authentication.listener + guard: + authenticators: + - lexik_jwt_authentication.jwt_token_authenticator ``` + +For more details about the `lexik_jwt_authentication.jwt_token_authenticator` service and how to +customize it, see ["Extending the Guard JWTTokenAuthenticator"](6-extending-jwt-authenticator.md) diff --git a/Resources/doc/6-extending-jwt-authenticator.md b/Resources/doc/6-extending-jwt-authenticator.md new file mode 100644 index 00000000..e69de29b diff --git a/Security/Authentication/Provider/JWTProvider.php b/Security/Authentication/Provider/JWTProvider.php index 7543d148..a77268d3 100644 --- a/Security/Authentication/Provider/JWTProvider.php +++ b/Security/Authentication/Provider/JWTProvider.php @@ -6,6 +6,7 @@ use Lexik\Bundle\JWTAuthenticationBundle\Events; use Lexik\Bundle\JWTAuthenticationBundle\Exception\JWTDecodeFailureException; use Lexik\Bundle\JWTAuthenticationBundle\Security\Authentication\Token\JWTUserToken; +use Lexik\Bundle\JWTAuthenticationBundle\Security\Guard\JWTTokenAuthenticator; use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTManagerInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Security\Core\Authentication\Provider\AuthenticationProviderInterface; @@ -17,6 +18,9 @@ * JWTProvider. * * @author Nicolas Cabot + * + * @deprecated since 2.0, will be removed in 3.0. See + * {@link JWTTokenAuthenticator} instead */ class JWTProvider implements AuthenticationProviderInterface { @@ -50,6 +54,8 @@ public function __construct( JWTManagerInterface $jwtManager, EventDispatcherInterface $dispatcher ) { + @trigger_error(sprintf('The "%s" class is deprecated since version 2.0 and will be removed in 3.0. See "%s" instead.', __CLASS__, JWTTokenAuthenticator::class), E_USER_DEPRECATED); + $this->userProvider = $userProvider; $this->jwtManager = $jwtManager; $this->dispatcher = $dispatcher; diff --git a/Security/Authentication/Token/PreAuthenticationJWTUserToken.php b/Security/Authentication/Token/PreAuthenticationJWTUserToken.php new file mode 100644 index 00000000..d5a492db --- /dev/null +++ b/Security/Authentication/Token/PreAuthenticationJWTUserToken.php @@ -0,0 +1,55 @@ + + */ +final class PreAuthenticationJWTUserToken extends PreAuthenticationGuardToken +{ + /** + * @var string + */ + private $rawToken; + + /** + * @var array + */ + private $payload; + + /** + * @param string $rawToken + */ + public function __construct($rawToken) + { + $this->rawToken = $rawToken; + } + + /** + * {@inheritdoc} + */ + public function getCredentials() + { + return $this->rawToken; + } + + /** + * {@inheritdoc} + */ + public function setPayload(array $payload) + { + $this->payload = $payload; + } + + /** + * {@inheritdoc} + */ + public function getPayload() + { + return $this->payload; + } +} diff --git a/Security/Firewall/JWTListener.php b/Security/Firewall/JWTListener.php index 725a0691..d3445189 100644 --- a/Security/Firewall/JWTListener.php +++ b/Security/Firewall/JWTListener.php @@ -7,6 +7,7 @@ use Lexik\Bundle\JWTAuthenticationBundle\Events; use Lexik\Bundle\JWTAuthenticationBundle\Response\JWTAuthenticationFailureResponse; use Lexik\Bundle\JWTAuthenticationBundle\Security\Authentication\Token\JWTUserToken; +use Lexik\Bundle\JWTAuthenticationBundle\Security\Guard\JWTTokenAuthenticator; use Lexik\Bundle\JWTAuthenticationBundle\TokenExtractor\TokenExtractorInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\Request; @@ -21,6 +22,9 @@ * * @author Nicolas Cabot * @author Robin Chalas + * + * @deprecated since 2.0, will be removed in 3.0. See + * {@link JWTTokenAuthenticator} instead */ class JWTListener implements ListenerInterface { @@ -59,6 +63,8 @@ public function __construct( AuthenticationManagerInterface $authenticationManager, array $config = [] ) { + @trigger_error(sprintf('The "%s" class is deprecated since version 2.0 and will be removed in 3.0. See "%s" instead.', __CLASS__, JWTTokenAuthenticator::class), E_USER_DEPRECATED); + $this->tokenStorage = $tokenStorage; $this->authenticationManager = $authenticationManager; $this->config = array_merge(['throw_exceptions' => false], $config); diff --git a/Security/Guard/JWTTokenAuthenticator.php b/Security/Guard/JWTTokenAuthenticator.php new file mode 100644 index 00000000..721c33cd --- /dev/null +++ b/Security/Guard/JWTTokenAuthenticator.php @@ -0,0 +1,187 @@ + + * @author Robin Chalas + */ +class JWTTokenAuthenticator extends AbstractGuardAuthenticator +{ + /** + * @var JWTTokenManagerInterface + */ + private $jwtManager; + + /** + * @var EventDispatcherInterface + */ + private $dispatcher; + + /** + * @var TokenExtractorInterface + */ + private $tokenExtractor; + + /** + * @param JWTTokenManagerInterface $jwtManager + * @param EventDispatcherInterface $dispatcher + * @param TokenExtractorInterface $tokenExtractor + */ + public function __construct( + JWTTokenManagerInterface $jwtManager, + EventDispatcherInterface $dispatcher, + TokenExtractorInterface $tokenExtractor + ) { + $this->jwtManager = $jwtManager; + $this->dispatcher = $dispatcher; + $this->tokenExtractor = $tokenExtractor; + } + + /** + * Returns a decoded JWT token extracted from a request. + * + * {@inheritdoc} + * + * @return PreAuthenticationJWTUserToken + * + * @throws JWTAuthenticationException If the request token cannot be decoded + */ + public function getCredentials(Request $request) + { + if (false === ($jsonWebToken = $this->tokenExtractor->extract($request))) { + return; + } + + $preAuthToken = new PreAuthenticationJWTUserToken($jsonWebToken); + + try { + if (!$payload = $this->jwtManager->decode($preAuthToken)) { + throw JWTAuthenticationException::invalidToken(); + } + + $preAuthToken->setPayload($payload); + } catch (JWTDecodeFailureException $e) { + throw JWTAuthenticationException::invalidToken($e); + } + + return $preAuthToken; + } + + /** + * Returns an user object loaded from a JWT token. + * + * {@inheritdoc} + * + * @param PreAuthenticationJWTUserToken Implementation of the (Security) TokenInterface + * + * @throws JWTAuthenticationException If no user can be loaded from the decoded token + */ + public function getUser($preAuthToken, UserProviderInterface $userProvider) + { + if (!$preAuthToken instanceof PreAuthenticationJWTUserToken) { + throw new \InvalidArgumentException( + sprintf('The first argument of the "%s()" method must be an instance of "%s".', __METHOD__, PreAuthenticationJWTUserToken::class) + ); + } + + $payload = $preAuthToken->getPayload(); + $identityField = $this->jwtManager->getUserIdentityField(); + + if (!isset($payload[$identityField])) { + throw JWTAuthenticationException::invalidPayload( + sprintf('Unable to find a key corresponding to the configured user_identity_field ("%s") in the token payload.', $identityField) + ); + } + + $identity = $payload[$identityField]; + + try { + $user = $userProvider->loadUserByUsername($identity); + } catch (UsernameNotFoundException $e) { + throw JWTAuthenticationException::invalidUser($identity, $identityField); + } + + $authToken = new JWTUserToken($user->getRoles()); + $authToken->setUser($user); + $authToken->setRawToken($preAuthToken->getCredentials()); + + $this->dispatcher->dispatch(Events::JWT_AUTHENTICATED, new JWTAuthenticatedEvent($payload, $authToken)); + + return $user; + } + + /** + * {@inheritdoc} + */ + public function onAuthenticationFailure(Request $request, AuthenticationException $authException) + { + $event = new JWTInvalidEvent($authException, new JWTAuthenticationFailureResponse($authException->getMessage())); + $this->dispatcher->dispatch(Events::JWT_INVALID, $event); + + return $event->getResponse(); + } + + /** + * {@inheritdoc} + */ + public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey) + { + return; + } + + /** + * {@inheritdoc} + * + * @return JWTAuthenticationFailureResponse + */ + public function start(Request $request, AuthenticationException $authException = null) + { + $authException = JWTAuthenticationException::tokenNotFound(); + $event = new JWTNotFoundEvent($authException, new JWTAuthenticationFailureResponse($authException->getMessage())); + + $this->dispatcher->dispatch(Events::JWT_NOT_FOUND, $event); + + return $event->getResponse(); + } + + /** + * {@inheritdoc} + */ + public function checkCredentials($credentials, UserInterface $user) + { + return true; + } + + /** + * {@inheritdoc} + */ + public function supportsRememberMe() + { + return false; + } +} diff --git a/Security/Http/EntryPoint/JWTEntryPoint.php b/Security/Http/EntryPoint/JWTEntryPoint.php index 9cc12c43..66563c90 100644 --- a/Security/Http/EntryPoint/JWTEntryPoint.php +++ b/Security/Http/EntryPoint/JWTEntryPoint.php @@ -3,6 +3,7 @@ namespace Lexik\Bundle\JWTAuthenticationBundle\Security\Http\EntryPoint; use Lexik\Bundle\JWTAuthenticationBundle\Response\JWTAuthenticationFailureResponse; +use Lexik\Bundle\JWTAuthenticationBundle\Security\Guard\JWTTokenAuthenticator; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface; @@ -11,9 +12,17 @@ * JWTEntryPoint starts throw a 401 when not authenticated. * * @author Jérémie Augustin + * + * @deprecated since 2.0, will be removed in 3.0. Use + * {@link JWTTokenAuthenticator} instead */ class JWTEntryPoint implements AuthenticationEntryPointInterface { + public function __construct() + { + @trigger_error(sprintf('The "%s" class is deprecated since version 2.0 and will be removed in 3.0. Use "%s" instead.', __CLASS__, JWTTokenAuthenticator::class), E_USER_DEPRECATED); + } + /** * {@inheritdoc} */ diff --git a/Services/JWTManager.php b/Services/JWTManager.php index 21e30b88..7aa53e69 100644 --- a/Services/JWTManager.php +++ b/Services/JWTManager.php @@ -18,7 +18,7 @@ * @author Nicolas Cabot * @author Robin Chalas */ -class JWTManager implements JWTManagerInterface +class JWTManager implements JWTManagerInterface, JWTTokenManagerInterface { /** * @var JWTEncoderInterface @@ -111,7 +111,7 @@ protected function addUserIdentityToPayload(UserInterface $user, array &$payload } /** - * @return string + * {@inheritdoc} */ public function getUserIdentityField() { @@ -119,7 +119,7 @@ public function getUserIdentityField() } /** - * @param string $userIdentityField + * {@inheritdoc} */ public function setUserIdentityField($userIdentityField) { diff --git a/Services/JWTManagerInterface.php b/Services/JWTManagerInterface.php index b0ca0dea..f48b7b00 100644 --- a/Services/JWTManagerInterface.php +++ b/Services/JWTManagerInterface.php @@ -8,6 +8,8 @@ /** * JWTManagerInterface. * + * @deprecated since 2.0, removed in 3.0. Use {@link JWTTokenManagerInterface} instead + * * @author Nicolas Cabot */ interface JWTManagerInterface @@ -15,14 +17,14 @@ interface JWTManagerInterface /** * @param UserInterface $user * - * @return string + * @return string The JWT token */ public function create(UserInterface $user); /** * @param TokenInterface $token * - * @return bool|array + * @return array|false The JWT token payload or false if an error occurs */ public function decode(TokenInterface $token); } diff --git a/Services/JWTTokenManagerInterface.php b/Services/JWTTokenManagerInterface.php new file mode 100644 index 00000000..2d6c64cc --- /dev/null +++ b/Services/JWTTokenManagerInterface.php @@ -0,0 +1,43 @@ + + */ +interface JWTTokenManagerInterface +{ + /** + * @param UserInterface $user + * + * @return string The JWT token + */ + public function create(UserInterface $user); + + /** + * @param TokenInterface $token + * + * @return array|false The JWT token payload or false if an error occurs + */ + public function decode(TokenInterface $token); + + /** + * Sets the field used as identifier to load an user from a JWT payload. + * + * @param string + */ + public function setUserIdentityField($field); + + /** + * Returns the field used as identifier to load an user from a JWT payload. + * + * @return string + */ + public function getUserIdentityField(); +} diff --git a/Tests/Encoder/DefaultEncoderTest.php b/Tests/Encoder/DefaultEncoderTest.php index 63743f69..fa1f1bea 100644 --- a/Tests/Encoder/DefaultEncoderTest.php +++ b/Tests/Encoder/DefaultEncoderTest.php @@ -19,7 +19,7 @@ class DefaultEncoderTest extends \PHPUnit_Framework_TestCase */ public function testDecodeFromValidJWS() { - $payload = [ + $payload = [ 'username' => 'chalasr', 'exp' => time() + 3600, ]; diff --git a/Tests/Functional/BootTest.php b/Tests/Functional/BootTest.php deleted file mode 100644 index a5e11c79..00000000 --- a/Tests/Functional/BootTest.php +++ /dev/null @@ -1,17 +0,0 @@ -createKernel(); - $kernel->boot(); - } -} diff --git a/Tests/Functional/Bundle/Bundle.php b/Tests/Functional/Bundle/Bundle.php new file mode 100644 index 00000000..dad4ba74 --- /dev/null +++ b/Tests/Functional/Bundle/Bundle.php @@ -0,0 +1,9 @@ + + */ +class CompleteTokenAuthenticationTest extends TestCase +{ + public static function setupBeforeClass() + { + static::bootKernel(); + } + + public function testAccessSecuredRoute() + { + static::$client = static::createAuthenticatedClient(); + static::$client->request('GET', '/api/secured'); + + $this->assertSuccessful(static::$client->getResponse()); + } + + public function testAccessSecuredRouteWithoutToken() + { + static::$client = static::createClient(); + static::$client->request('GET', '/api/secured'); + + $response = static::$client->getResponse(); + + $this->assertFailure($response); + + return json_decode($response->getContent(), true); + } + + public function testAccessSecuredRouteWithInvalidToken() + { + static::$client = static::createClient(); + static::$client->request('GET', '/api/secured', [], [], ['HTTP_AUTHORIZATION' => 'Bearer dummy']); + + $response = static::$client->getResponse(); + + $this->assertFailure($response); + + return json_decode($response->getContent(), true); + } + + /** + * @group time-sensitive + */ + public function testAccessSecuredRouteWithExpiredToken() + { + static::bootKernel(); + + $encoderWrapper = static::$kernel->getContainer()->get('lexik_jwt_authentication.test.exp_aware_jwt_encoder.wrapper'); + $expiredToken = $encoderWrapper->decreaseTokenExpirationTime(static::getAuthenticatedToken()); + + static::$client = static::createAuthenticatedClient($expiredToken); + static::$client->request('GET', '/api/secured'); + + $response = static::$client->getResponse(); + + $this->assertFailure($response); + + return json_decode($response->getContent(), true); + } + + protected function assertFailure(Response $response) + { + $this->assertFalse($response->isSuccessful()); + $this->assertSame(401, $response->getStatusCode()); + } + + protected function assertSuccessful(Response $response) + { + $this->assertTrue($response->isSuccessful()); + $this->assertSame(200, $response->getStatusCode()); + } +} diff --git a/Tests/Functional/DefaultTokenAuthenticationTest.php b/Tests/Functional/DefaultTokenAuthenticationTest.php new file mode 100644 index 00000000..4c7de35b --- /dev/null +++ b/Tests/Functional/DefaultTokenAuthenticationTest.php @@ -0,0 +1,35 @@ + + */ +class DefaultTokenAuthenticationTest extends CompleteTokenAuthenticationTest +{ + public function testAccessSecuredRouteWithoutToken() + { + $response = parent::testAccessSecuredRouteWithoutToken(); + + $this->assertEquals('JWT Token not found', $response['message']); + } + + public function testAccessSecuredRouteWithInvalidToken() + { + $response = parent::testAccessSecuredRouteWithInvalidToken(); + + $this->assertEquals('Invalid JWT Token', $response['message']); + } + + /** + * @group time-sensitive + */ + public function testAccessSecuredRouteWithExpiredToken() + { + $response = parent::testAccessSecuredRouteWithExpiredToken(); + + $this->assertSame('Expired JWT Token', $response['message']); + } +} diff --git a/Tests/Functional/DependencyInjection/LexikJWTAuthenticationExtensionTest.php b/Tests/Functional/DependencyInjection/LexikJWTAuthenticationExtensionTest.php index 5d9d948e..3c9d7b7e 100644 --- a/Tests/Functional/DependencyInjection/LexikJWTAuthenticationExtensionTest.php +++ b/Tests/Functional/DependencyInjection/LexikJWTAuthenticationExtensionTest.php @@ -16,17 +16,14 @@ class LexikJWTAuthenticationExtensionTest extends TestCase */ public function testEncoderConfiguration() { - /* @var \Symfony\Component\HttpKernel\KernelInterface */ - $kernel = $this->createKernel(); - $kernel->boot(); + static::bootKernel(); /* @var \Symfony\Component\DependencyInjection\ContainerInterface */ - $container = $kernel->getContainer(); + $container = static::$kernel->getContainer(); $encoderNamespace = 'lexik_jwt_authentication.encoder'; $encryptionEngine = $container->getParameter($encoderNamespace.'.encryption_engine'); $encryptionAlgorithm = $container->getParameter($encoderNamespace.'.signature_algorithm'); - /* @var PHPUnit_Framework_MockObject_MockObject */ $jwsProviderMock = $this ->getMockBuilder('Lexik\Bundle\JWTAuthenticationBundle\Services\JWSProvider') ->setConstructorArgs([ diff --git a/Tests/Functional/GetTokenTest.php b/Tests/Functional/GetTokenTest.php new file mode 100644 index 00000000..d0663534 --- /dev/null +++ b/Tests/Functional/GetTokenTest.php @@ -0,0 +1,66 @@ +request('POST', '/login_check', ['_username' => 'lexik', '_password' => 'dummy']); + + $response = static::$client->getResponse(); + + $this->assertInstanceOf(JWTAuthenticationSuccessResponse::class, $response); + $this->assertTrue($response->isSuccessful()); + + $body = json_decode($response->getContent(), true); + + $this->assertArrayHasKey('token', $body, 'The response should have a "token" key containing a JWT Token.'); + } + + public function testGetTokenWithCustomClaim() + { + static::bootKernel(); + + $subscriber = static::$kernel->getContainer()->get('lexik_jwt_authentication.test.jwt_event_subscriber'); + $subscriber->setListener(Events::JWT_CREATED, function (JWTCreatedEvent $e) { + $e->setData($e->getData() + ['custom' => 'dummy']); + }); + + static::$client->request('POST', '/login_check', ['_username' => 'lexik', '_password' => 'dummy']); + + $body = json_decode(static::$client->getResponse()->getContent(), true); + $decoder = static::$kernel->getContainer()->get('lexik_jwt_authentication.encoder.default'); + $payload = $decoder->decode($body['token']); + + $this->assertArrayHasKey('custom', $payload, 'The payload should contains a "custom" claim.'); + $this->assertSame('dummy', $payload['custom'], 'The "custom" claim should be equal to "dummy".'); + } + + public function testGetTokenFromInvalidCredentials() + { + static::$client->request('POST', '/login_check', ['_username' => 'lexik', '_password' => 'wrong']); + + $response = static::$client->getResponse(); + + $body = json_decode($response->getContent(), true); + + $this->assertFalse($response->isSuccessful()); + $this->assertSame(401, $response->getStatusCode()); + + $this->assertArrayHasKey('message', $body, 'The response should have a "message" key containing the failure reason.'); + $this->assertArrayHasKey('code', $body, 'The response should have a "code" key containing the response status code.'); + + $this->assertSame('Bad credentials', $body['message']); + $this->assertSame(401, $body['code']); + } +} diff --git a/Tests/Functional/SubscribedTokenAuthenticationTest.php b/Tests/Functional/SubscribedTokenAuthenticationTest.php new file mode 100644 index 00000000..bdcf706a --- /dev/null +++ b/Tests/Functional/SubscribedTokenAuthenticationTest.php @@ -0,0 +1,94 @@ + + */ +class SubscribedTokenAuthenticationTest extends CompleteTokenAuthenticationTest +{ + private static $subscriber; + + public static function setupBeforeClass() + { + parent::setupBeforeClass(); + + self::$subscriber = static::$kernel->getContainer()->get('lexik_jwt_authentication.test.jwt_event_subscriber'); + } + + public function testAccessSecuredRouteWithoutToken() + { + self::$subscriber->setListener(Events::JWT_NOT_FOUND, function (JWTNotFoundEvent $e) { + $response = $e->getResponse(); + + if ($response instanceof JWTAuthenticationFailureResponse) { + $response->setMessage('Custom JWT not found message'); + } + }); + + $response = parent::testAccessSecuredRouteWithoutToken(); + + $this->assertSame('Custom JWT not found message', $response['message']); + } + + public function testAccessSecuredRouteWithInvalidToken() + { + self::$subscriber->setListener(Events::JWT_INVALID, function (JWTInvalidEvent $e) { + $response = $e->getResponse(); + + if ($response instanceof JWTAuthenticationFailureResponse) { + $response->setMessage('Custom JWT invalid message'); + } + }); + + $response = parent::testAccessSecuredRouteWithInvalidToken(); + + self::$subscriber->unsetListener(Events::JWT_INVALID); + + $this->assertSame('Custom JWT invalid message', $response['message']); + } + + public function testAccessSecuredRouteWithInvalidJWTDecodedEvent() + { + self::$subscriber->setListener(Events::JWT_DECODED, function (JWTDecodedEvent $e) { + $e->markAsInvalid(); + }); + + static::$client = static::createAuthenticatedClient(); + static::$client->request('GET', '/api/secured'); + + $responseBody = json_decode(static::$client->getResponse()->getContent(), true); + + $this->assertSame('Invalid JWT Token', $responseBody['message']); + + self::$subscriber->unsetListener(Events::JWT_DECODED); + } + + /** + * @group time-sensitive + */ + public function testAccessSecuredRouteWithExpiredToken() + { + self::$subscriber->setListener(Events::JWT_INVALID, function (JWTInvalidEvent $e) { + $response = $e->getResponse(); + + if ($response instanceof JWTAuthenticationFailureResponse) { + $e->getResponse()->setMessage('Custom JWT Expired Token message'); + } + }); + + $response = parent::testAccessSecuredRouteWithExpiredToken(); + + $this->assertSame('Custom JWT Expired Token message', $response['message']); + + self::$subscriber->unsetListener(Events::JWT_INVALID); + } +} diff --git a/Tests/Functional/TestCase.php b/Tests/Functional/TestCase.php index c9430d4c..eed38781 100644 --- a/Tests/Functional/TestCase.php +++ b/Tests/Functional/TestCase.php @@ -2,6 +2,7 @@ namespace Lexik\Bundle\JWTAuthenticationBundle\Tests\Functional; +use Symfony\Bundle\FrameworkBundle\Client; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Component\Filesystem\Filesystem; @@ -10,14 +11,50 @@ */ abstract class TestCase extends WebTestCase { + protected static $client; + /** * {@inheritdoc} */ protected static function createKernel(array $options = []) { + require_once __DIR__.'/app/AppKernel.php'; + return new AppKernel('test', true); } + protected static function createAuthenticatedClient($token = null) + { + if (null === static::$kernel) { + static::bootKernel(); + } + + $client = static::$kernel->getContainer()->get('test.client'); + $token = null === $token ? self::getAuthenticatedToken() : $token; + + if (null === $token) { + throw new \LogicException('Unable to create an authenticated client from a null JWT token'); + } + + $client->setServerParameter('HTTP_AUTHORIZATION', sprintf('Bearer %s', $token)); + + return $client; + } + + protected static function getAuthenticatedToken() + { + $client = static::$client ?: static::$kernel->getContainer()->get('test.client'); + + $client->request('POST', '/login_check', ['_username' => 'lexik', '_password' => 'dummy']); + $responseBody = json_decode($client->getResponse()->getContent(), true); + + if (!isset($responseBody['token'])) { + throw new \LogicException('Unable to get a JWT Token through the "/login_check" route.'); + } + + return $responseBody['token']; + } + /** * {@inheritdoc} */ diff --git a/Tests/Functional/Utils/CallableEventSubscriber.php b/Tests/Functional/Utils/CallableEventSubscriber.php new file mode 100644 index 00000000..0695df1f --- /dev/null +++ b/Tests/Functional/Utils/CallableEventSubscriber.php @@ -0,0 +1,115 @@ + JWTCreatedEvent::class, + Events::JWT_DECODED => JWTDecodedEvent::class, + Events::JWT_INVALID => JWTInvalidEvent::class, + Events::JWT_NOT_FOUND => JWTNotFoundEvent::class, + Events::JWT_ENCODED => JWTEncodedEvent::class, + Events::JWT_AUTHENTICATED => JWTAuthenticatedEvent::class, + ]; + + public static function getSubscribedEvents() + { + $subscriberMap = []; + + foreach (self::$eventClassMap as $name => $className) { + if (self::hasListener($name)) { + $subscriberMap[$name] = 'handleEvent'; + } + } + + return $subscriberMap; + } + + /** + * Executes the good listener depending on the passed event. + * + * @param Event $event An instance of one of the events + * defined in {@link self::$eventClassMap} + */ + public function handleEvent(Event $event) + { + $eventName = array_search(get_class($event), self::$eventClassMap); + + if (!$eventName) { + return; + } + + $listener = self::getListener($eventName); + + if ($listener instanceof \Closure) { + return $listener($event); + } + + call_user_func($listener, $event); + } + + /** + * Checks whether a listener is registered for this event. + * + * @param string $eventName + * + * @return bool + */ + public static function hasListener($eventName) + { + return isset(self::$listeners[$eventName]); + } + + /** + * Gets the listener for this event. + * + * @param string $eventName The event for which to retrieve the listener + * + * @return callable + */ + public static function getListener($eventName) + { + if (!self::hasListener($eventName)) { + return; + } + + return self::$listeners[$eventName]; + } + + /** + * Set the listener to use for a given event. + * + * @param string $eventName The event to listen on + * @param callable $listener The callback to be executed for this event + */ + public static function setListener($eventName, callable $listener) + { + self::$listeners[$eventName] = $listener; + } + + /** + * Unset the listener for a given event. + * + * @param string $eventName The event for which to unset the listener + */ + public static function unsetListener($eventName) + { + if (!self::hasListener($eventName)) { + return; + } + + unset(self::$listeners[$eventName]); + } +} diff --git a/Tests/Functional/Utils/ExpAwareJWTEncoderWrapper.php b/Tests/Functional/Utils/ExpAwareJWTEncoderWrapper.php new file mode 100644 index 00000000..b5129aa6 --- /dev/null +++ b/Tests/Functional/Utils/ExpAwareJWTEncoderWrapper.php @@ -0,0 +1,23 @@ +wrappedEncoder = $encoder; + } + + public function decreaseTokenExpirationTime($token) + { + $payload = $this->wrappedEncoder->decode($token); + $payload['exp'] = time(); + + return $this->wrappedEncoder->encode($payload); + } +} diff --git a/Tests/Functional/AppKernel.php b/Tests/Functional/app/AppKernel.php similarity index 85% rename from Tests/Functional/AppKernel.php rename to Tests/Functional/app/AppKernel.php index 6e3ff6c3..e1325aff 100644 --- a/Tests/Functional/AppKernel.php +++ b/Tests/Functional/app/AppKernel.php @@ -19,15 +19,21 @@ public function registerBundles() new \Symfony\Bundle\FrameworkBundle\FrameworkBundle(), new \Symfony\Bundle\SecurityBundle\SecurityBundle(), new \Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle(), + new \Lexik\Bundle\JWTAuthenticationBundle\Tests\Functional\Bundle\Bundle(), ]; } + public function getRootDir() + { + return __DIR__; + } + /** * {@inheritdoc} */ public function getCacheDir() { - return sys_get_temp_dir().'/LexikJWTAuthenticationBundle/'; + return sys_get_temp_dir().'/LexikJWTAuthenticationBundle/cache'; } /** @@ -35,7 +41,7 @@ public function getCacheDir() */ public function getLogDir() { - return sys_get_temp_dir().'/LexikJWTAuthenticationBundle/'; + return sys_get_temp_dir().'/LexikJWTAuthenticationBundle/logs'; } /** diff --git a/Tests/Functional/app/config/config.yml b/Tests/Functional/app/config/config.yml new file mode 100644 index 00000000..e866f236 --- /dev/null +++ b/Tests/Functional/app/config/config.yml @@ -0,0 +1,57 @@ +framework: + secret: test + router: + resource: '%kernel.root_dir%/config/routing.yml' + test: ~ + session: + storage_id: session.storage.mock_file + +lexik_jwt_authentication: + private_key_path: '%kernel.root_dir%/../var/jwt/private.pem' + public_key_path: '%kernel.root_dir%/../var/jwt/public.pem' + pass_phrase: testing + +security: + encoders: + Symfony\Component\Security\Core\User\User: plaintext + + providers: + in_memory: + memory: + users: + lexik: + password: dummy + roles: ROLE_USER + + firewalls: + login: + pattern: ^/login + stateless: true + anonymous: true + form_login: + check_path: /login_check + require_previous_session: false + success_handler: lexik_jwt_authentication.handler.authentication_success + failure_handler: lexik_jwt_authentication.handler.authentication_failure + + api: + pattern: ^/api + stateless: true + anonymous: false + guard: + authenticators: + - lexik_jwt_authentication.jwt_token_authenticator + access_control: + - { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY } + - { path: ^/api, roles: IS_AUTHENTICATED_FULLY } + +services: + lexik_jwt_authentication.test.exp_aware_jwt_encoder.wrapper: + class: Lexik\Bundle\JWTAuthenticationBundle\Tests\Functional\Utils\ExpAwareJWTEncoderWrapper + arguments: [ '@lexik_jwt_authentication.encoder.default' ] + + lexik_jwt_authentication.test.jwt_event_subscriber: + class: Lexik\Bundle\JWTAuthenticationBundle\Tests\Functional\Utils\CallableEventSubscriber + shared: true + tags: + - { name: kernel.event_subscriber } diff --git a/Tests/Functional/app/config/routing.yml b/Tests/Functional/app/config/routing.yml new file mode 100644 index 00000000..80a16763 --- /dev/null +++ b/Tests/Functional/app/config/routing.yml @@ -0,0 +1,9 @@ +login_check: + path: /login_check + defaults: { _controller: Bundle:Test:loginCheck } + methods: [POST] + +secured: + path: /api/secured + defaults: { _controller: Bundle:Test:secured } + methods: [GET] diff --git a/Tests/Functional/config/config.yml b/Tests/Functional/config/config.yml deleted file mode 100644 index a6622270..00000000 --- a/Tests/Functional/config/config.yml +++ /dev/null @@ -1,34 +0,0 @@ -framework: - secret: test - router: - resource: '%kernel.root_dir%/config/routing.yml' - -lexik_jwt_authentication: - private_key_path: '%kernel.root_dir%/var/private.pem' - public_key_path: '%kernel.root_dir%/var/public.pem' - pass_phrase: testing - -security: - - providers: - in_memory: - memory: - - firewalls: - - login: - pattern: ^/api/login - stateless: true - anonymous: true - form_login: - check_path: /api/login_check - require_previous_session: false - username_parameter: username - password_parameter: password - success_handler: lexik_jwt_authentication.handler.authentication_success - failure_handler: lexik_jwt_authentication.handler.authentication_failure - - api: - pattern: ^/api - stateless: true - lexik_jwt: ~ diff --git a/Tests/Functional/var/jwt/private.pem b/Tests/Functional/var/jwt/private.pem new file mode 100644 index 00000000..a690dfe6 --- /dev/null +++ b/Tests/Functional/var/jwt/private.pem @@ -0,0 +1,54 @@ +-----BEGIN RSA PRIVATE KEY----- +Proc-Type: 4,ENCRYPTED +DEK-Info: AES-256-CBC,DF05065941BEE3F036EC824515EC1FA9 + +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 +-----END RSA PRIVATE KEY----- diff --git a/Tests/Functional/var/jwt/public.pem b/Tests/Functional/var/jwt/public.pem new file mode 100644 index 00000000..d6231bb5 --- /dev/null +++ b/Tests/Functional/var/jwt/public.pem @@ -0,0 +1,14 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA3CxMb//3E/X9zeRiBF/+ +ysxTpleCLrhkK0/e18R8jITARQz3c7v0SxiuMwwgrQGci2rFIAcorz+il+aoBWo1 +V5mB/dBtJOA0AOT4meesU68plDvtQ8X38eOPZ+WeC+gtwtZLVh8IqUhtzoEVTtxj +d23XMkaTYSJIjE7wnF24260jGrxZg/AfwErYEnemXOtrIRc1Yyyha9LfM7cKsrzN +AVoxTRftZ0zB6ri+n0cHBST+Or1klDgq68K2SIuFVV2QWjWczZRemJwW5hVfnLhu +2e+JUQOzuH/HN4BwDmPu85W+Mz0g1ssLWsvPIEJ9fz2UPqqqEiy/LjU3PzmsSEoU +Xkc+G/m4Umgq61ns//6gbgJ2ukRbeUdESsBDd5O59RqHsSVTREgP6R+up4MP4SIF +2xK7dSJOHlFQyt4XF+aXH3B24mf4hilyJiFWVxzkGCAUBUG6yW6v0bU2H8CUMDc+ ++pxk7E9en9UTxpIY+1aeDmc/1ILQVlPmJzowP3AzNqdLLc74UNKSUoDGQ+QOgPNo +EIHTZKvjQZ7K5DrN6vamJO0XndJyhzzjXIJ0Rr8LLCXhVyST1jU5nH7p/6HHinpS +6Fr25tOcHgcxiRSdtpOi6cwzoDQn5a9/bufZT6lOzPJNJjiMcvJZ9bk+Uqg843vQ +C5dZb6v9JeadiXYnNNN20nkCAwEAAQ== +-----END PUBLIC KEY----- diff --git a/Tests/Functional/var/private.pem b/Tests/Functional/var/private.pem deleted file mode 100644 index 32eaef77..00000000 --- a/Tests/Functional/var/private.pem +++ /dev/null @@ -1,54 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -Proc-Type: 4,ENCRYPTED -DEK-Info: AES-256-CBC,C224538048780FE86193DCB9F3C8636A - -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 ------END RSA PRIVATE KEY----- diff --git a/Tests/Functional/var/public.pem b/Tests/Functional/var/public.pem deleted file mode 100644 index 1463e4ce..00000000 --- a/Tests/Functional/var/public.pem +++ /dev/null @@ -1,14 +0,0 @@ ------BEGIN PUBLIC KEY----- -MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAwyr62h/f2CSbYaKBWWOQ -Rg0Ehkw7z9gR1Tv4nrO+OTDwzPIey5P4SsnWbGw5lgi1tBKq7SEToIGBJqtmNO3K -kyywATBh8bl8l/PKAPL3d4l8KUboDhOHWWVrmth+lCD2Qcq4fv2ud65aY4PUetrL -G03EOBSqdCxNyM17cycSBlnwFaxeSoXD3Kvq5qlIna9QrkP6OlNREx1HuEiAryzu -gSEukajuVKcuYzvJh1WGrufJAuYkhxY5+Yfsej8qhoBcb3aHFMbXwav8aix/Zd4E -OzDcMqPIYaiHkH+UE/R4DhdsL/6zGQRQlIspNHp/WO5vbycUkhGjknb2qfX93k3S -ijn2DiatdTuIkvp3+csL79dOk4CVK3cFhLshL1QXSCpsbNX33jnfhvtqfm66up5o -zCX80SPtsRJ8TpSMYd1q3St/jnU7qU14/OA/W9dpPHGIU4fAIaWelIaJm0pCmxg+ -Vto+aSIGnIYsVMvbiM/cxDf4FMyGCehrO5HPdQIAgI/Tes2z0jyjKS7XxKrC3gyS -jQ//1Dn+wnf9wcegRw8FY5J4oZSvAfGn7h4gw5GfXekyd4Y0+fDr2xrl8F7JDeC9 -BPCpClaxNkDQ7PXm/5pcb3KBOPb2VgtzXjkgsFk0tLNg7GVMpYKn0cKxwehF7p8e -7jar7Q9t0Wjeg0D2a13Tfq8CAwEAAQ== ------END PUBLIC KEY----- diff --git a/Tests/Security/Authentication/Firewall/JWTListenerTest.php b/Tests/Security/Authentication/Firewall/JWTListenerTest.php index 1aac29b4..81fc0193 100644 --- a/Tests/Security/Authentication/Firewall/JWTListenerTest.php +++ b/Tests/Security/Authentication/Firewall/JWTListenerTest.php @@ -9,6 +9,8 @@ /** * JWTListenerTest. * + * @group legacy + * * @author Nicolas Cabot * @author Robin Chalas */ diff --git a/Tests/Security/Authentication/Provider/JWTProviderTest.php b/Tests/Security/Authentication/Provider/JWTProviderTest.php index 53ac4535..e59579da 100644 --- a/Tests/Security/Authentication/Provider/JWTProviderTest.php +++ b/Tests/Security/Authentication/Provider/JWTProviderTest.php @@ -9,6 +9,8 @@ /** * JWTProviderTest. * + * @group legacy + * * @author Nicolas Cabot */ class JWTProviderTest extends \PHPUnit_Framework_TestCase diff --git a/Tests/Security/Guard/JWTTokenAuthenticatorTest.php b/Tests/Security/Guard/JWTTokenAuthenticatorTest.php new file mode 100644 index 00000000..da8be322 --- /dev/null +++ b/Tests/Security/Guard/JWTTokenAuthenticatorTest.php @@ -0,0 +1,262 @@ +getJWTManagerMock(); + $jwtManager + ->expects($this->once()) + ->method('decode') + ->willReturn(['username' => 'lexik']); + + $authenticator = new JWTTokenAuthenticator( + $jwtManager, + $this->getEventDispatcherMock(), + $this->getTokenExtractorMock('token') + ); + + $this->assertInstanceOf(PreAuthenticationJWTUserToken::class, $authenticator->getCredentials($this->getRequestMock())); + } + + /** + * @expectedException \Lexik\Bundle\JWTAuthenticationBundle\Exception\JWTAuthenticationException + * @expectedExceptionMessage Invalid JWT Token + */ + public function testGetCredentialsWithInvalidToken() + { + (new JWTTokenAuthenticator( + $this->getJWTManagerMock(), + $this->getEventDispatcherMock(), + $this->getTokenExtractorMock('token') + ))->getCredentials($this->getRequestMock()); + } + + public function testGetCredentialsWithoutToken() + { + $authenticator = new JWTTokenAuthenticator( + $this->getJWTManagerMock(), + $this->getEventDispatcherMock(), + $this->getTokenExtractorMock(false) + ); + + $this->assertNull($authenticator->getCredentials($this->getRequestMock())); + } + + public function testGetUser() + { + $userIdentityField = 'username'; + $payload = [$userIdentityField => 'lexik']; + $rawToken = 'token'; + $userRoles = ['ROLE_USER']; + + $dispatcher = $this->getEventDispatcherMock(); + $userStub = new AdvancedUserStub('lexik', 'password', 'user@gmail.com', $userRoles); + + $jwtUserToken = new JWTUserToken($userRoles); + $jwtUserToken->setUser($userStub); + $jwtUserToken->setRawToken($rawToken); + + $dispatcher + ->expects($this->once()) + ->method('dispatch') + ->with(Events::JWT_AUTHENTICATED, new JWTAuthenticatedEvent($payload, $jwtUserToken)); + + $decodedToken = new PreAuthenticationJWTUserToken($rawToken); + $decodedToken->setPayload($payload); + + $userProvider = $this->getUserProviderMock(); + $userProvider + ->expects($this->once()) + ->method('loadUserByUsername') + ->with($payload[$userIdentityField]) + ->willReturn($userStub); + + $authenticator = new JWTTokenAuthenticator( + $this->getJWTManagerMock('username'), + $dispatcher, + $this->getTokenExtractorMock() + ); + + $this->assertSame($userStub, $authenticator->getUser($decodedToken, $userProvider)); + } + + /** + * @expectedException \Lexik\Bundle\JWTAuthenticationBundle\Exception\JWTAuthenticationException + * @expectedExceptionMessage Unable to find a key corresponding to the configured user_identity_field ("username") + */ + public function testGetUserWithInvalidPayload() + { + $decodedToken = new PreAuthenticationJWTUserToken('rawToken'); + $decodedToken->setPayload([]); // Empty payload + + (new JWTTokenAuthenticator( + $this->getJWTManagerMock('username'), + $this->getEventDispatcherMock(), + $this->getTokenExtractorMock() + ))->getUser($decodedToken, $this->getUserProviderMock()); + } + + /** + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage must be an instance of "Lexik\Bundle\JWTAuthenticationBundle\Security\Authentication\Token\PreAuthenticationJWTUserToken". + */ + public function testGetUserWithInvalidFirstArg() + { + (new JWTTokenAuthenticator( + $this->getJWTManagerMock(), + $this->getEventDispatcherMock(), + $this->getTokenExtractorMock() + ))->getUser(new \stdClass(), $this->getUserProviderMock()); + } + + /** + * @expectedException \Lexik\Bundle\JWTAuthenticationBundle\Exception\JWTAuthenticationException + * @expectedExceptionMessage Unable to load a valid user with property "username" = "lexik" + */ + public function testGetUserWithInvalidUser() + { + $userIdentityField = 'username'; + $payload = [$userIdentityField => 'lexik']; + + $decodedToken = new PreAuthenticationJWTUserToken('rawToken'); + $decodedToken->setPayload($payload); + + $userProvider = $this->getUserProviderMock(); + $userProvider + ->expects($this->once()) + ->method('loadUserByUsername') + ->with($payload[$userIdentityField]) + ->will($this->throwException(new UsernameNotFoundException())); + + (new JWTTokenAuthenticator( + $this->getJWTManagerMock('username'), + $this->getEventDispatcherMock(), + $this->getTokenExtractorMock() + ))->getUser($decodedToken, $userProvider); + } + + public function testOnAuthenticationFailureWithInvalidToken() + { + $authException = new JWTAuthenticationException('Invalid JWT Token'); + $expectedResponse = new JWTAuthenticationFailureResponse('Invalid JWT Token'); + + $dispatcher = $this->getEventDispatcherMock(); + $dispatcher + ->expects($this->once()) + ->method('dispatch') + ->with( + Events::JWT_INVALID, + new JWTInvalidEvent($authException, $expectedResponse) + ); + + $authenticator = new JWTTokenAuthenticator( + $this->getJWTManagerMock(), + $dispatcher, + $this->getTokenExtractorMock() + ); + + $response = $authenticator->onAuthenticationFailure($this->getRequestMock(), $authException); + + $this->assertEquals($expectedResponse, $response); + $this->assertSame($expectedResponse->getMessage(), $response->getMessage()); + } + + public function testStart() + { + $authException = JWTAuthenticationException::tokenNotFound(); + $failureResponse = new JWTAuthenticationFailureResponse($authException->getMessage()); + + $dispatcher = $this->getEventDispatcherMock(); + $dispatcher + ->expects($this->once()) + ->method('dispatch') + ->with( + Events::JWT_NOT_FOUND, + new JWTNotFoundEvent($authException, $failureResponse) + ); + + $authenticator = new JWTTokenAuthenticator( + $this->getJWTManagerMock(), + $dispatcher, + $this->getTokenExtractorMock() + ); + + $response = $authenticator->start($this->getRequestMock(), $authException); + + $this->assertEquals($failureResponse, $response); + $this->assertSame($failureResponse->getMessage(), $response->getMessage()); + } + + private function getJWTManagerMock($userIdentityField = null) + { + $jwtManager = $this->getMockBuilder(JWTTokenManagerInterface::class) + ->disableOriginalConstructor() + ->getMock(); + + if (null !== $userIdentityField) { + $jwtManager + ->expects($this->once()) + ->method('getUserIdentityField') + ->willReturn($userIdentityField); + } + + return $jwtManager; + } + + private function getEventDispatcherMock() + { + return $this->getMockBuilder(EventDispatcherInterface::class) + ->disableOriginalConstructor() + ->getMock(); + } + + private function getTokenExtractorMock($returnValue = null) + { + $extractor = $this->getMockBuilder(TokenExtractorInterface::class) + ->disableOriginalConstructor() + ->getMock(); + + if (null !== $returnValue) { + $extractor + ->expects($this->once()) + ->method('extract') + ->willReturn($returnValue); + } + + return $extractor; + } + + private function getRequestMock() + { + return $this->getMockBuilder(Request::class) + ->disableOriginalConstructor() + ->getMock(); + } + + private function getUserProviderMock() + { + return $this->getMockBuilder(UserProviderInterface::class) + ->disableOriginalConstructor() + ->getMock(); + } +} diff --git a/Tests/Security/Http/EntryPoint/JWTEntryPointTest.php b/Tests/Security/Http/EntryPoint/JWTEntryPointTest.php index 2d12f817..a76abae6 100644 --- a/Tests/Security/Http/EntryPoint/JWTEntryPointTest.php +++ b/Tests/Security/Http/EntryPoint/JWTEntryPointTest.php @@ -7,6 +7,8 @@ /** * JWTEntryPointTest. * + * @group legacy + * * @author Jérémie Augustin */ class JWTEntryPointTest extends \PHPUnit_Framework_TestCase diff --git a/UPGRADE.md b/UPGRADE.md index 6b77811b..ec84553f 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,6 +1,56 @@ UPGRADE FROM 1.x to 2.0 ======================= +Configuration +------------- + +* The JWT authentication system has been deprecated in favor of a Guard authenticator + called `JWTTokenAuthenticator`. + By the way, the security configuration has been simplified. Most of the options that was + set from the JWT-secured firewall configuration have been moved to the bundle configuration, + keeping the same names and default values. + + __Removed options__ + - `create_entry_point`: The new authenticator being an entry point after all, this option doesn't bring any value anymore. + If a firewall allows anonymous, the entry point will not be called at all, letting the request continue. + If it doesn't, the entry point will dispatch a `on_jwt_not_found` event that can be subscribed to customize the default failure response that will be returned by the entry point. + - `throw_exceptions`: This option doesn't make sense anymore as the exceptions thrown during the authentication process are needed, involving call of the good method in the good time, dispatching the good events, so a custom response can be easily set, as its content no more depends on the exception thrown. + - `authentication_provider` and `authentication_listener`: It's now part of the authenticator role, simplifiying a lot the corresponding code that can now be found/overrided from one place. + + __Before__ + + ```yaml + # app/config/security.yml + firewalls: + api: + lexik_jwt: + authorization_header: ~ + cookie: ~ + query_parameter: ~ + throw_exceptions: false + create_entry_point: true + authentication_provider: lexik_jwt_authentication.security.authentication.provider + authentication_listener: lexik_jwt_authentication.security.authentication.listener + ``` + + __After__ + + ```yaml + # app/config/security.yml + firewalls: + api: + guard: + authenticators: + - lexik_jwt_authentication.jwt_token_authenticator + + # app/config/config.yml + lexik_jwt_authentication: + # ... + token_extractors: + authorization_header: ~ + cookie: ~ + query_parameter: ~ + ``` Events ------- @@ -95,3 +145,11 @@ Command * The `lexik:jwt:check-open-ssl` command has been renamed to `lexik:jwt:check-config` as the bundle now supports several encryption engines. + +Security +-------- + +* The `JWTManagerInterface` has been deprecated in favor of a new `JWTTokenManagerInterface` + implementing two new methods: `setUserIdentityField` and `getUserIdentityField`. + These methods were already implemented by the JWTManager class in 1.x but not guaranteed + by the old interface. diff --git a/composer.json b/composer.json index 47d80d24..bbf06c69 100644 --- a/composer.json +++ b/composer.json @@ -45,9 +45,16 @@ }, "require-dev": { "phpunit/phpunit": "^4.1|^5.0", - "symfony/phpunit-bridge": "^2.7|^3.0", + "symfony/phpunit-bridge": "^2.8|^3.0", + "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"