diff --git a/.gitignore b/.gitignore index 04f7b493..2d4ac93a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ build vendor composer.lock .phpunit.result.cache +.idea diff --git a/DependencyInjection/Security/Factory/JWTAuthenticatorFactory.php b/DependencyInjection/Security/Factory/JWTAuthenticatorFactory.php new file mode 100644 index 00000000..cb77685a --- /dev/null +++ b/DependencyInjection/Security/Factory/JWTAuthenticatorFactory.php @@ -0,0 +1,60 @@ + + */ +class JWTAuthenticatorFactory implements SecurityFactoryInterface, AuthenticatorFactoryInterface +{ + /** + * {@inheritdoc} + */ + public function create(ContainerBuilder $container, $id, $config, $userProvider, $defaultEntryPoint) + { + return []; + } + + /** + * {@inheritdoc} + */ + public function getPosition() + { + return 'pre_auth'; + } + + /** + * {@inheritdoc} + */ + public function getKey() + { + return 'jwt'; + } + + /** + * {@inheritdoc} + */ + public function addConfiguration(NodeDefinition $node) + { + // no-op - no config here for now + } + + public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId) + { + $authenticatorId = 'security.authenticator.jwt.'.$firewallName; + $container + ->setDefinition($authenticatorId, new ChildDefinition('lexik_jwt_authentication.security.jwt_authenticator')) + ->replaceArgument(3, new Reference($userProviderId)); + + return $authenticatorId; + } +} diff --git a/DependencyInjection/Security/Factory/JWTFactory.php b/DependencyInjection/Security/Factory/JWTFactory.php index c1714895..8e926d7d 100644 --- a/DependencyInjection/Security/Factory/JWTFactory.php +++ b/DependencyInjection/Security/Factory/JWTFactory.php @@ -19,6 +19,13 @@ */ class JWTFactory implements SecurityFactoryInterface { + public function __construct($triggerDeprecation = true) + { + if ($triggerDeprecation) { + trigger_deprecation('lexik/jwt-authentication-bundle', '2.0', 'Class "%s" is deprecated, use "%s" instead.', self::class, JWTAuthenticatorFactory::class); + } + } + /** * {@inheritdoc} */ diff --git a/LexikJWTAuthenticationBundle.php b/LexikJWTAuthenticationBundle.php index 323ea6df..e8348d66 100644 --- a/LexikJWTAuthenticationBundle.php +++ b/LexikJWTAuthenticationBundle.php @@ -3,8 +3,10 @@ namespace Lexik\Bundle\JWTAuthenticationBundle; use Lexik\Bundle\JWTAuthenticationBundle\DependencyInjection\Compiler\WireGenerateTokenCommandPass; +use Lexik\Bundle\JWTAuthenticationBundle\DependencyInjection\Security\Factory\JWTAuthenticatorFactory; use Lexik\Bundle\JWTAuthenticationBundle\DependencyInjection\Security\Factory\JWTFactory; use Lexik\Bundle\JWTAuthenticationBundle\DependencyInjection\Security\Factory\JWTUserFactory; +use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\AuthenticatorFactoryInterface; use Symfony\Bundle\SecurityBundle\DependencyInjection\SecurityExtension; use Symfony\Component\Console\Application; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -30,7 +32,10 @@ public function build(ContainerBuilder $container) $extension = $container->getExtension('security'); $extension->addUserProviderFactory(new JWTUserFactory()); - $extension->addSecurityListenerFactory(new JWTFactory()); // BC 1.x, to be removed in 3.0 + $extension->addSecurityListenerFactory(new JWTFactory(false)); // BC 1.x, to be removed in 3.0 + if (interface_exists(AuthenticatorFactoryInterface::class)) { + $extension->addSecurityListenerFactory(new JWTAuthenticatorFactory()); + } } /** diff --git a/Resources/config/token_authenticator.xml b/Resources/config/token_authenticator.xml index df9b90b3..9b03a35d 100644 --- a/Resources/config/token_authenticator.xml +++ b/Resources/config/token_authenticator.xml @@ -15,5 +15,13 @@ + + + + + + + + diff --git a/Security/Authenticator/JWTAuthenticator.php b/Security/Authenticator/JWTAuthenticator.php new file mode 100644 index 00000000..514a7a36 --- /dev/null +++ b/Security/Authenticator/JWTAuthenticator.php @@ -0,0 +1,222 @@ +tokenExtractor = $tokenExtractor; + $this->jwtManager = $jwtManager; + $this->eventDispatcher = $eventDispatcher; + $this->userProvider = $userProvider; + } + + /** + * {@inheritdoc} + */ + public function start(Request $request, AuthenticationException $authException = null): JWTAuthenticationFailureResponse + { + $exception = new MissingTokenException('JWT Token not found', 0, $authException); + $event = new JWTNotFoundEvent($exception, new JWTAuthenticationFailureResponse($exception->getMessageKey())); + + $this->eventDispatcher->dispatch($event, Events::JWT_NOT_FOUND); + + return $event->getResponse(); + } + + public function supports(Request $request): ?bool + { + return false !== $this->getTokenExtractor()->extract($request); + } + + public function authenticate(Request $request): PassportInterface + { + $token = $this->getTokenExtractor()->extract($request); + + try { + if (!$payload = $this->jwtManager->decodeFromJsonWebToken($token)) { + throw new InvalidTokenException('Invalid JWT Token'); + } + } catch (JWTDecodeFailureException $e) { + if (JWTDecodeFailureException::EXPIRED_TOKEN === $e->getReason()) { + throw new ExpiredTokenException(); + } + + throw new InvalidTokenException('Invalid JWT Token', 0, $e); + } + + $idClaim = $this->jwtManager->getUserIdClaim(); + if (!isset($payload[$idClaim])) { + throw new InvalidPayloadException($idClaim); + } + + $passport = new SelfValidatingPassport( + new UserBadge($payload[$idClaim], + function ($userIdentifier) use($payload) { + return $this->loadUser($payload, $userIdentifier); + }) + ); + + $passport->setAttribute('payload', $payload); + + return $passport; + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return null; + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + $errorMessage = strtr($exception->getMessageKey(), $exception->getMessageData()); + $response = new JWTAuthenticationFailureResponse($errorMessage); + + if ($exception instanceof ExpiredTokenException) { + $event = new JWTExpiredEvent($exception, $response); + $eventName = Events::JWT_EXPIRED; + } else { + $event = new JWTInvalidEvent($exception, $response); + $eventName = Events::JWT_INVALID; + } + + $this->eventDispatcher->dispatch($event, $eventName); + + return $event->getResponse(); + } + + /** + * Gets the token extractor to be used for retrieving a JWT token in the + * current request. + * + * Override this method for adding/removing extractors to the chain one or + * returning a different {@link TokenExtractorInterface} implementation. + */ + protected function getTokenExtractor(): TokenExtractorInterface + { + return $this->tokenExtractor; + } + + /** + * Loads the user to authenticate. + * + * @param array $payload The token payload + * @param string $identity The key from which to retrieve the user "identifier" + */ + protected function loadUser(array $payload, string $identity): UserInterface + { + if ($this->userProvider instanceof PayloadAwareUserProviderInterface) { + if (method_exists($this->userProvider, 'loadUserByIdentifierAndPayload')) { + return $this->userProvider->loadUserByIdentifierAndPayload($identity, $payload); + } else { + return $this->userProvider->loadUserByUsernameAndPayload($identity, $payload); + } + } + + if ($this->userProvider instanceof ChainUserProvider) { + foreach ($this->userProvider->getProviders() as $provider) { + try { + if ($provider instanceof PayloadAwareUserProviderInterface) { + if (method_exists(PayloadAwareUserProviderInterface::class, 'loadUserByIdentifierAndPayload')) { + return $this->userProvider->loadUserByIdentifierAndPayload($identity, $payload); + } else { + return $this->userProvider->loadUserByUsernameAndPayload($identity, $payload); + } + } + + return $provider->loadUserByIdentifier($identity); + // More generic call to catch both UsernameNotFoundException for SF<5.3 and new UserNotFoundException + } catch (AuthenticationException $e) { + // try next one + } + } + + if(!class_exists(UsernameNotFoundException::class)) { + $ex = new UsernameNotFoundException(sprintf('There is no user with username "%s".', $identity)); + $ex->setUsername($identity); + } else { + $ex = new UserNotFoundException(sprintf('There is no user with identifier "%s".', $identity)); + $ex->setUserIdentifier($identity); + } + + throw $ex; + } + + if (method_exists($this->userProvider, 'loadUserByIdentifier')) { + return $this->userProvider->loadUserByIdentifier($identity); + } else { + return $this->userProvider->loadUserByUsername($identity); + } + } + + public function createAuthenticatedToken(PassportInterface $passport, string $firewallName): TokenInterface + { + $token = parent::createAuthenticatedToken($passport, $firewallName); + + if (!$passport instanceof SelfValidatingPassport) { + throw new \LogicException(sprintf('Expected "%s" but got "%s".', SelfValidatingPassport::class, get_debug_type($passport))); + } + + $this->eventDispatcher->dispatch(new JWTAuthenticatedEvent($passport->getAttribute('payload'), $token), Events::JWT_AUTHENTICATED); + + return $token; + } +} diff --git a/Security/Guard/JWTTokenAuthenticator.php b/Security/Guard/JWTTokenAuthenticator.php index f6860675..2e2a077d 100644 --- a/Security/Guard/JWTTokenAuthenticator.php +++ b/Security/Guard/JWTTokenAuthenticator.php @@ -30,6 +30,7 @@ use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserProviderInterface; use Symfony\Component\Security\Guard\AbstractGuardAuthenticator; +use Symfony\Component\Security\Guard\AuthenticatorInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; /** @@ -40,7 +41,7 @@ * @author Nicolas Cabot * @author Robin Chalas */ -class JWTTokenAuthenticator extends AbstractGuardAuthenticator +class JWTTokenAuthenticator implements AuthenticatorInterface { /** * @var JWTTokenManagerInterface diff --git a/Security/User/JWTUser.php b/Security/User/JWTUser.php index 32e85db1..f2102449 100644 --- a/Security/User/JWTUser.php +++ b/Security/User/JWTUser.php @@ -11,15 +11,15 @@ */ class JWTUser implements JWTUserInterface { - private $username; + private $userIdentifier; private $roles; /** * @final */ - public function __construct(string $username, array $roles = []) + public function __construct(string $userIdentifier, array $roles = []) { - $this->username = $username; + $this->userIdentifier = $userIdentifier; $this->roles = $roles; } @@ -38,15 +38,20 @@ public static function createFromPayload($username, array $payload) /** * {@inheritdoc} */ - public function getUsername() + public function getUsername(): string { - return $this->username; + return $this->getUserIdentifier(); + } + + public function getUserIdentifier(): string + { + return $this->userIdentifier; } /** * {@inheritdoc} */ - public function getRoles() + public function getRoles(): array { return $this->roles; } @@ -54,7 +59,7 @@ public function getRoles() /** * {@inheritdoc} */ - public function getPassword() + public function getPassword(): ?string { return null; } @@ -62,7 +67,7 @@ public function getPassword() /** * {@inheritdoc} */ - public function getSalt() + public function getSalt(): ?string { return null; } diff --git a/Security/User/JWTUserProvider.php b/Security/User/JWTUserProvider.php index 4a97c268..78f1ea44 100644 --- a/Security/User/JWTUserProvider.php +++ b/Security/User/JWTUserProvider.php @@ -27,18 +27,26 @@ public function __construct($class) * {@inheritdoc} * * @param array $payload The JWT payload from which to create an instance - * - * @return JWTUserInterface */ public function loadUserByUsername($username, array $payload = []) { return $this->loadUserByUsernameAndPayload($username, $payload); } + /** + * {@inheritdoc} + * + * @param array $payload The JWT payload from which to create an instance + */ + public function loadUserByIdentifier(string $identifier, array $payload = []): UserInterface + { + return $this->loadUserByIdentifierAndPayload($identifier, $payload); + } + /** * {@inheritdoc} */ - public function loadUserByUsernameAndPayload($username, array $payload) + public function loadUserByUsernameAndPayload(string $username, array $payload): UserInterface { $class = $this->class; @@ -49,6 +57,17 @@ public function loadUserByUsernameAndPayload($username, array $payload) return $this->cache[$username] = $class::createFromPayload($username, $payload); } + public function loadUserByIdentifierAndPayload(string $userIdentifier, array $payload): UserInterface + { + $class = $this->class; + + if (isset($this->cache[$userIdentifier])) { + return $this->cache[$userIdentifier]; + } + + return $this->cache[$userIdentifier] = $class::createFromPayload($userIdentifier, $payload); + } + /** * {@inheritdoc} */ @@ -57,7 +76,7 @@ public function supportsClass($class) return $class === $this->class || (new \ReflectionClass($class))->implementsInterface(JWTUserInterface::class); } - public function refreshUser(UserInterface $user) + public function refreshUser(UserInterface $user): UserInterface { return $user; // noop } diff --git a/Security/User/PayloadAwareUserProviderInterface.php b/Security/User/PayloadAwareUserProviderInterface.php index 4627fb27..dbf29b43 100644 --- a/Security/User/PayloadAwareUserProviderInterface.php +++ b/Security/User/PayloadAwareUserProviderInterface.php @@ -2,20 +2,22 @@ namespace Lexik\Bundle\JWTAuthenticationBundle\Security\User; +use Lexik\Bundle\JWTAuthenticationBundle\Exception\UserNotFoundException; use Symfony\Component\Security\Core\Exception\UsernameNotFoundException; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserProviderInterface; +/** + * @method UserInterface loadUserByIdentifierAndPayload(string $identifier) Loads a user from an identifier and JWT token payload. + */ interface PayloadAwareUserProviderInterface extends UserProviderInterface { /** * Load a user by its username, including the JWT token payload. * - * @param string $username + * @throws UsernameNotFoundException|UserNotFoundException if the user is not found * - * @throws UsernameNotFoundException if the user is not found - * - * @return UserInterface + * @deprecated since 2.12, implement loadUserByIdentifierAndPayload() instead. */ - public function loadUserByUsernameAndPayload($username, array $payload); + public function loadUserByUsernameAndPayload(string $username, array $payload)/*: UserInterface*/; } diff --git a/Services/JWTManager.php b/Services/JWTManager.php index a755ff4a..66e7d425 100644 --- a/Services/JWTManager.php +++ b/Services/JWTManager.php @@ -8,6 +8,7 @@ use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTDecodedEvent; use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTEncodedEvent; use Lexik\Bundle\JWTAuthenticationBundle\Events; +use Lexik\Bundle\JWTAuthenticationBundle\Exception\JWTDecodeFailureException; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\User\UserInterface; @@ -55,7 +56,7 @@ public function __construct(JWTEncoderInterface $encoder, EventDispatcherInterfa /** * @return string The JWT token */ - public function create(UserInterface $user) + public function create(UserInterface $user): string { $payload = ['roles' => $user->getRoles()]; $this->addUserIdentityToPayload($user, $payload); @@ -66,7 +67,7 @@ public function create(UserInterface $user) /** * @return string The JWT token */ - public function createFromPayload(UserInterface $user, array $payload) + public function createFromPayload(UserInterface $user, array $payload): string { $payload = array_merge(['roles' => $user->getRoles()], $payload); $this->addUserIdentityToPayload($user, $payload); @@ -77,7 +78,7 @@ public function createFromPayload(UserInterface $user, array $payload) /** * @return string The JWT token */ - private function generateJwtStringAndDispatchEvents(UserInterface $user, array $payload) + private function generateJwtStringAndDispatchEvents(UserInterface $user, array $payload): string { $jwtCreatedEvent = new JWTCreatedEvent($payload, $user); $this->dispatcher->dispatch($jwtCreatedEvent, Events::JWT_CREATED); @@ -97,6 +98,7 @@ private function generateJwtStringAndDispatchEvents(UserInterface $user, array $ /** * {@inheritdoc} + * @throws JWTDecodeFailureException */ public function decode(TokenInterface $token) { @@ -114,6 +116,26 @@ public function decode(TokenInterface $token) return $event->getPayload(); } + /** + * @inheritDoc + * @throws JWTDecodeFailureException + */ + public function decodeFromJsonWebToken(string $jwtToken) + { + if (!($payload = $this->jwtEncoder->decode($jwtToken))) { + return false; + } + + $event = new JWTDecodedEvent($payload); + $this->dispatcher->dispatch($event, Events::JWT_DECODED); + + if (!$event->isValid()) { + return false; + } + + return $event->getPayload(); + } + /** * Add user identity to payload, username by default. * Override this if you need to identify it by another property. @@ -129,7 +151,7 @@ protected function addUserIdentityToPayload(UserInterface $user, array &$payload /** * {@inheritdoc} */ - public function getUserIdentityField() + public function getUserIdentityField(): string { return $this->userIdentityField; } @@ -137,15 +159,15 @@ public function getUserIdentityField() /** * {@inheritdoc} */ - public function setUserIdentityField($userIdentityField) + public function setUserIdentityField($field) { - $this->userIdentityField = $userIdentityField; + $this->userIdentityField = $field; } /** * @return string */ - public function getUserIdClaim() + public function getUserIdClaim(): ?string { return $this->userIdClaim; } diff --git a/Services/JWTTokenManagerInterface.php b/Services/JWTTokenManagerInterface.php index 1c35227b..1a6e0c55 100644 --- a/Services/JWTTokenManagerInterface.php +++ b/Services/JWTTokenManagerInterface.php @@ -2,6 +2,7 @@ namespace Lexik\Bundle\JWTAuthenticationBundle\Services; +use Lexik\Bundle\JWTAuthenticationBundle\Exception\JWTDecodeFailureException; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\User\UserInterface; @@ -23,9 +24,16 @@ public function create(UserInterface $user); /** * @return array|false The JWT token payload or false if an error occurs + * @throws JWTDecodeFailureException */ public function decode(TokenInterface $token); + /** + * @return array|false The JWT token payload or false if an error occurs + * @throws JWTDecodeFailureException + */ + public function decodeFromJsonWebToken(string $jwtToken); + /** * Sets the field used as identifier to load an user from a JWT payload. * diff --git a/Tests/Functional/Command/GenerateTokenCommandTest.php b/Tests/Functional/Command/GenerateTokenCommandTest.php index 4fe5eb8a..8b11f3ac 100644 --- a/Tests/Functional/Command/GenerateTokenCommandTest.php +++ b/Tests/Functional/Command/GenerateTokenCommandTest.php @@ -5,6 +5,7 @@ use Lexik\Bundle\JWTAuthenticationBundle\Tests\Functional\TestCase; use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Security\Core\User\InMemoryUser; use Symfony\Component\Security\Core\User\User; class GenerateTokenCommandTest extends TestCase @@ -13,7 +14,7 @@ public function testRun() { $tester = new CommandTester((new Application($this->bootKernel(['test_case' => 'GenerateTokenCommand'])))->get('lexik:jwt:generate-token')); - $this->assertSame(0, $tester->execute(['username' => 'lexik', '--user-class' => User::class])); + $this->assertSame(0, $tester->execute(['username' => 'lexik', '--user-class' => class_exists(InMemoryUser::class) ? InMemoryUser::class : User::class])); } public function testRunWithoutSpecifiedProviderAndMoreThanOneConfigured() diff --git a/Tests/Functional/app/AppKernel.php b/Tests/Functional/app/AppKernel.php index caaa1280..b02c4057 100644 --- a/Tests/Functional/app/AppKernel.php +++ b/Tests/Functional/app/AppKernel.php @@ -7,6 +7,7 @@ use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Kernel; +use Symfony\Component\Security\Core\Exception\UserNotFoundException; /** * AppKernel. @@ -70,18 +71,37 @@ public function getLogDir() */ public function registerContainerConfiguration(LoaderInterface $loader) { - if (\method_exists(TreeBuilder::class, 'getRootNode')) { - // Symfony 4.2+ - $loader->load(__DIR__.'/config/config_router_utf8.yml'); + $loader->load(__DIR__.'/config/config_router_utf8.yml'); + + // 5.3+ session config + if (class_exists(UserNotFoundException::class)) { + $sessionConfig = [ + 'storage_factory_id' => 'session.storage.factory.mock_file', + ]; + } else { + $sessionConfig = [ + 'handler_id' => null, + 'cookie_secure' => 'auto', + 'cookie_samesite' => 'lax', + 'storage_id' => 'session.storage.mock_file', + ]; } + $loader->load(function (ContainerBuilder $container) use ($sessionConfig) { + $container->prependExtensionConfig('framework', [ + 'router' => [ + 'resource' => '%kernel.root_dir%/config/routing.yml', + 'utf8' => true, + ], + 'session' => $sessionConfig + ]); + }); + if ($this->testCase && file_exists(__DIR__.'/config/'.$this->testCase.'/config.yml')) { $loader->load(__DIR__.'/config/'.$this->testCase.'/config.yml'); - - return; } - $loader->load(__DIR__.sprintf('/config/security_%s.yml', $this->userProvider)); + $loader->load(__DIR__.sprintf('/config/security_%s.yml', $this->userProvider . (class_exists(UserNotFoundException::class) ? '' : '_legacy'))); if ($this->signatureAlgorithm && file_exists($file = __DIR__.sprintf('/config/config_%s_%s.yml', $this->encoder, strtolower($this->signatureAlgorithm)))) { $loader->load($file); diff --git a/Tests/Functional/app/config/GenerateTokenCommand/config.yml b/Tests/Functional/app/config/GenerateTokenCommand/config.yml index 458212a8..cabc1940 100644 --- a/Tests/Functional/app/config/GenerateTokenCommand/config.yml +++ b/Tests/Functional/app/config/GenerateTokenCommand/config.yml @@ -1,6 +1,5 @@ imports: - { resource: '../base_config.yml' } - - { resource: '../security_in_memory.yml' } lexik_jwt_authentication: secret_key: testing diff --git a/Tests/Functional/app/config/base_config.yml b/Tests/Functional/app/config/base_config.yml index ba9ff6c0..baa06876 100644 --- a/Tests/Functional/app/config/base_config.yml +++ b/Tests/Functional/app/config/base_config.yml @@ -1,10 +1,6 @@ framework: secret: test - router: - resource: '%kernel.root_dir%/config/routing.yml' test: ~ - session: - storage_id: session.storage.mock_file services: lexik_jwt_authentication.test.jwt_event_subscriber: @@ -19,7 +15,6 @@ services: arguments: ['@lexik_jwt_authentication.jws_provider.lcobucci'] public: true - Lexik\Bundle\JWTAuthenticationBundle\Tests\Functional\Bundle\Controller\TestController: arguments: ['@security.token_storage'] public: true diff --git a/Tests/Functional/app/config/base_security.yml b/Tests/Functional/app/config/base_security.yml index 3c893d56..0d6fdd05 100644 --- a/Tests/Functional/app/config/base_security.yml +++ b/Tests/Functional/app/config/base_security.yml @@ -1,7 +1,4 @@ security: - encoders: - Symfony\Component\Security\Core\User\User: plaintext - providers: in_memory: memory: diff --git a/Tests/Functional/app/config/security_in_memory.yml b/Tests/Functional/app/config/security_in_memory.yml index 79acc319..c3bb6422 100644 --- a/Tests/Functional/app/config/security_in_memory.yml +++ b/Tests/Functional/app/config/security_in_memory.yml @@ -2,11 +2,15 @@ imports: - { resource: base_security.yml } security: + enable_authenticator_manager: true + + password_hashers: + Symfony\Component\Security\Core\User\UserInterface: plaintext + firewalls: login: pattern: ^/login stateless: true - anonymous: true provider: in_memory form_login: check_path: /login_check @@ -19,6 +23,4 @@ security: stateless: true anonymous: false provider: in_memory - guard: - authenticators: - - lexik_jwt_authentication.jwt_token_authenticator + jwt: ~ diff --git a/Tests/Functional/app/config/security_in_memory_legacy.yml b/Tests/Functional/app/config/security_in_memory_legacy.yml new file mode 100644 index 00000000..55e5f0cc --- /dev/null +++ b/Tests/Functional/app/config/security_in_memory_legacy.yml @@ -0,0 +1,27 @@ +imports: + - { resource: base_security.yml } + +security: + encoders: + Symfony\Component\Security\Core\User\UserInterface: plaintext + + firewalls: + login: + pattern: ^/login + stateless: true + anonymous: true + provider: in_memory + 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 + provider: in_memory + guard: + authenticators: + - lexik_jwt_authentication.jwt_token_authenticator diff --git a/Tests/Functional/app/config/security_lexik_jwt.yml b/Tests/Functional/app/config/security_lexik_jwt.yml index c7a09718..7bc50160 100644 --- a/Tests/Functional/app/config/security_lexik_jwt.yml +++ b/Tests/Functional/app/config/security_lexik_jwt.yml @@ -2,11 +2,15 @@ imports: - { resource: base_security.yml } security: + enable_authenticator_manager: true + + password_hashers: + Symfony\Component\Security\Core\User\UserInterface: plaintext + firewalls: login: pattern: ^/login stateless: true - anonymous: true provider: in_memory form_login: check_path: /login_check @@ -17,8 +21,5 @@ security: api: pattern: ^/api stateless: true - anonymous: false provider: jwt - guard: - authenticators: - - lexik_jwt_authentication.jwt_token_authenticator + jwt: ~ diff --git a/Tests/Functional/app/config/security_lexik_jwt_legacy.yml b/Tests/Functional/app/config/security_lexik_jwt_legacy.yml new file mode 100644 index 00000000..5aaf731e --- /dev/null +++ b/Tests/Functional/app/config/security_lexik_jwt_legacy.yml @@ -0,0 +1,27 @@ +imports: + - { resource: base_security.yml } + +security: + encoders: + Symfony\Component\Security\Core\User\UserInterface: plaintext + + firewalls: + login: + pattern: ^/login + stateless: true + anonymous: true + provider: in_memory + 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 + provider: jwt + guard: + authenticators: + - lexik_jwt_authentication.jwt_token_authenticator diff --git a/Tests/Security/Authenticator/JWTAuthenticatorTest.php b/Tests/Security/Authenticator/JWTAuthenticatorTest.php new file mode 100644 index 00000000..009847ac --- /dev/null +++ b/Tests/Security/Authenticator/JWTAuthenticatorTest.php @@ -0,0 +1,276 @@ += 7.2 */ +class JWTAuthenticatorTest extends TestCase +{ + public function setUp(): void + { + parent::setUp(); + + if (!class_exists(UserNotFoundException::class)) { + $this->markTestSkipped('This test suite only concerns the new Symfony 5.3 authentication system.'); + } + } + + public function testAuthenticate() { + $userIdClaim = 'sub'; + $payload = [$userIdClaim => 'lexik']; + $rawToken = 'token'; + $userRoles = ['ROLE_USER']; + + $userStub = new AdvancedUserStub('lexik', 'password', 'user@gmail.com', $userRoles); + + $jwtManager = $this->getJWTManagerMock(null, $userIdClaim); + $jwtManager + ->method('decodeFromJsonWebToken') + ->willReturn(['sub' => 'lexik']); + + $userProvider = $this->getUserProviderMock(); + $userProvider + ->method('loadUserByIdentifierAndPayload') + ->with($payload['sub'], $payload) + ->willReturn($userStub); + + $authenticator = new JWTAuthenticator( + $jwtManager, + $this->getEventDispatcherMock(), + $this->getTokenExtractorMock($rawToken), + $userProvider + ); + + $this->assertSame($userStub, ($authenticator->authenticate($this->getRequestMock()))->getUser()); + } + + public function testAuthenticateWithExpiredTokenThrowsException() { + $jwtManager = $this->getJWTManagerMock(); + $jwtManager->method('decodeFromJsonWebToken') + ->will($this->throwException(new JWTDecodeFailureException(JWTDecodeFailureException::EXPIRED_TOKEN, 'Expired JWT Token'))); + + $this->expectException(ExpiredTokenException::class); + + $authenticator = new JWTAuthenticator( + $jwtManager, + $this->getEventDispatcherMock(), + $this->getTokenExtractorMock('token'), + $this->getUserProviderMock() + ); + + $authenticator->authenticate($this->getRequestMock()); + } + + public function testAuthenticateWithInvalidTokenThrowsException() { + $jwtManager = $this->getJWTManagerMock(); + $jwtManager->method('decodeFromJsonWebToken') + ->willThrowException(new JWTDecodeFailureException( + JWTDecodeFailureException::INVALID_TOKEN, + 'Invalid JWT Token') + ); + $authenticator = new JWTAuthenticator( + $jwtManager, + $this->getEventDispatcherMock(), + $this->getTokenExtractorMock('token'), + $this->getUserProviderMock() + ); + + $this->expectException(InvalidTokenException::class); + + $authenticator->authenticate($this->getRequestMock()); + } + + public function testAuthenticateWithUndecodableTokenThrowsException() { + $jwtManager = $this->getJWTManagerMock(); + $jwtManager->method('decodeFromJsonWebToken') + ->willReturn(null); + $authenticator = new JWTAuthenticator( + $jwtManager, + $this->getEventDispatcherMock(), + $this->getTokenExtractorMock('token'), + $this->getUserProviderMock() + ); + + $this->expectException(InvalidTokenException::class); + + $authenticator->authenticate($this->getRequestMock()); + } + + public function testAuthenticationWithInvalidPayloadThrowsException() { + $jwtManager = $this->getJWTManagerMock(); + $jwtManager->method('decodeFromJsonWebToken') + ->willReturn(['foo' => 'bar']); + $jwtManager->method('getUserIdClaim') + ->willReturn('identifier'); + $authenticator = new JWTAuthenticator( + $jwtManager, + $this->getEventDispatcherMock(), + $this->getTokenExtractorMock('token'), + $this->getUserProviderMock() + ); + + $this->expectException(InvalidPayloadException::class); + + $authenticator->authenticate($this->getRequestMock()); + } + + public function testAuthenticateWithInvalidUserThrowsException() { + $jwtManager = $this->getJWTManagerMock(); + $jwtManager->method('decodeFromJsonWebToken') + ->willReturn(['identifier' => 'bar']); + $jwtManager->method('getUserIdClaim') + ->willReturn('identifier'); + + $userProvider = $this->getUserProviderMock(); + $userProvider->method('loadUserByIdentifierAndPayload') + ->willThrowException(new UserNotFoundException()); + + $authenticator = new JWTAuthenticator( + $jwtManager, + $this->getEventDispatcherMock(), + $this->getTokenExtractorMock('token'), + $userProvider + ); + + $this->expectException(UserNotFoundException::class); + + $authenticator->authenticate($this->getRequestMock())->getUser(); + } + + public function testOnAuthenticationFailureWithInvalidToken() { + $authException = new InvalidTokenException(); + $expectedResponse = new JWTAuthenticationFailureResponse('Invalid JWT Token'); + + $dispatcher = $this->getEventDispatcherMock(); + $this->expectEvent(Events::JWT_INVALID, new JWTInvalidEvent($authException, $expectedResponse), $dispatcher); + + $authenticator = new JWTAuthenticator( + $this->getJWTManagerMock(), + $dispatcher, + $this->getTokenExtractorMock(), + $this->getUserProviderMock() + ); + + $response = $authenticator->onAuthenticationFailure($this->getRequestMock(), $authException); + + $this->assertEquals($expectedResponse, $response); + $this->assertSame($expectedResponse->getMessage(), $response->getMessage()); + } + + public function testStart() + { + $authException = new MissingTokenException('JWT Token not found'); + $failureResponse = new JWTAuthenticationFailureResponse($authException->getMessageKey()); + + $dispatcher = $this->getEventDispatcherMock(); + $this->expectEvent(Events::JWT_NOT_FOUND, new JWTNotFoundEvent($authException, $failureResponse), $dispatcher); + + $authenticator = new JWTAuthenticator( + $this->getJWTManagerMock(), + $dispatcher, + $this->getTokenExtractorMock(), + $this->getUserProviderMock() + ); + + $response = $authenticator->start($this->getRequestMock()); + + $this->assertEquals($failureResponse, $response); + $this->assertSame($failureResponse->getMessage(), $response->getMessage()); + } + + private function getJWTManagerMock($userIdentityField = null, $userIdClaim = null) + { + $jwtManager = $this->getMockBuilder(JWTTokenManagerInterface::class) + ->disableOriginalConstructor() + ->getMock(); + + if (null !== $userIdentityField) { + $jwtManager + ->expects($this->once()) + ->method('getUserIdentityField') + ->willReturn($userIdentityField); + } + if (null !== $userIdClaim) { + $jwtManager + ->expects($this->once()) + ->method('getUserIdClaim') + ->willReturn($userIdClaim); + } + + 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(DummyUserProvider::class) + ->disableOriginalConstructor() + ->getMock(); + } + + private function expectEvent($eventName, $event, $dispatcher) + { + $dispatcher->expects($this->once())->method('dispatch')->with($event, $eventName); + } +} + +abstract class DummyUserProvider implements UserProviderInterface, PayloadAwareUserProviderInterface +{ + public function loadUserByIdentifier(string $identifier): UserInterface + { + } + + public function loadUserByIdentifierAndPayload(string $identifier): UserInterface + { + } +} diff --git a/Tests/Security/Guard/JWTTokenAuthenticatorTest.php b/Tests/Security/Guard/JWTTokenAuthenticatorTest.php index 9b44347d..a766ced9 100644 --- a/Tests/Security/Guard/JWTTokenAuthenticatorTest.php +++ b/Tests/Security/Guard/JWTTokenAuthenticatorTest.php @@ -24,9 +24,14 @@ use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Exception\UserNotFoundException as SecurityUserNotFoundException; use Symfony\Component\Security\Core\Exception\UsernameNotFoundException; +use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserProviderInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +/** + * + * @group legacy + */ class JWTTokenAuthenticatorTest extends TestCase { public function testGetCredentials() @@ -113,7 +118,7 @@ public function testGetUser() $userProvider = $this->getUserProviderMock(); $userProvider ->expects($this->once()) - ->method('loadUserByUsername') + ->method('loadUserByIdentifier') ->with($payload[$userIdClaim]) ->willReturn($userStub); @@ -177,7 +182,7 @@ public function testGetUserWithInvalidUserThrowsException() $userProvider = $this->getUserProviderMock(); $userProvider ->expects($this->once()) - ->method('loadUserByUsername') + ->method('loadUserByIdentifier') ->with($payload[$userIdClaim]) ->will($this->throwException($exception)); @@ -231,7 +236,7 @@ public function testCreateAuthenticatedToken() $userProvider = $this->getUserProviderMock(); $userProvider ->expects($this->once()) - ->method('loadUserByUsername') + ->method('loadUserByIdentifier') ->with($payload['sub']) ->willReturn($userStub); @@ -382,9 +387,7 @@ private function getRequestMock() private function getUserProviderMock() { - return $this->getMockBuilder(UserProviderInterface::class) - ->disableOriginalConstructor() - ->getMock(); + return $this->getMockBuilder(DummyUserProvider::class)->getMock(); } /** @@ -402,3 +405,10 @@ private function expectEvent($eventName, $event, $dispatcher) $dispatcher->expects($this->once())->method('dispatch')->with($event, $eventName); } } + +abstract class DummyUserProvider implements UserProviderInterface +{ + public function loadUserByIdentifier(string $identifier): UserInterface + { + } +} diff --git a/Tests/Stubs/User.php b/Tests/Stubs/User.php index eaa2f49e..33222521 100644 --- a/Tests/Stubs/User.php +++ b/Tests/Stubs/User.php @@ -22,18 +22,18 @@ */ final class User implements UserInterface { - private $username; + private $userIdentifier; private $password; private $roles; private $email; - public function __construct($username, $password, $email = '', array $roles = []) + public function __construct($userIdentifier, $password, $email = '', array $roles = []) { - if (empty($username)) { + if (empty($userIdentifier)) { throw new \InvalidArgumentException('The username cannot be empty.'); } - $this->username = $username; + $this->userIdentifier = $userIdentifier; $this->password = $password; $this->roles = $roles; $this->email = $email; @@ -67,15 +67,12 @@ public function getSalt() */ public function getUsername() { - return $this->username; + return $this->getUserIdentifier(); } - /** - * {@inheritdoc} - */ public function getUserIdentifier(): string { - return $this->username; + return $this->userIdentifier; } /** diff --git a/composer.json b/composer.json index ff42db90..a5e6bf23 100644 --- a/composer.json +++ b/composer.json @@ -42,7 +42,8 @@ "ext-openssl": "*", "lcobucci/jwt": "^3.4|^4.0", "symfony/framework-bundle": "^4.4|^5.1", - "symfony/security-bundle": "^4.4|^5.1" + "symfony/security-bundle": "^4.4|^5.1", + "symfony/deprecation-contracts": "^2.4" }, "require-dev": { "symfony/browser-kit": "^4.4|^5.1",