From e5873e8bcab7a2625bd2a8f917b5f09836f067c5 Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Tue, 9 Aug 2022 19:03:44 +0200 Subject: [PATCH] [Security] Access Token Authenticator --- .../Bundle/SecurityBundle/CHANGELOG.md | 1 + .../Security/Factory/AccessTokenFactory.php | 121 +++++++ .../DependencyInjection/SecurityExtension.php | 1 + .../security_authenticator_access_token.php | 44 +++ .../Bundle/SecurityBundle/SecurityBundle.php | 2 + .../Factory/AccessTokenFactoryTest.php | 93 +++++ .../Tests/Functional/AccessTokenTest.php | 318 ++++++++++++++++++ .../AccessTokenBundle/AccessTokenBundle.php | 18 + .../Controller/BarController.php | 22 ++ .../Controller/FooController.php | 23 ++ .../Security/Handler/AccessTokenHandler.php | 32 ++ .../Http/JsonAuthenticationFailureHandler.php | 26 ++ .../Http/JsonAuthenticationSuccessHandler.php | 26 ++ .../Functional/app/AccessToken/bundles.php | 17 + .../app/AccessToken/config_anonymous.yml | 33 ++ .../app/AccessToken/config_body_custom.yml | 37 ++ .../app/AccessToken/config_body_default.yml | 32 ++ .../app/AccessToken/config_header_custom.yml | 38 +++ .../app/AccessToken/config_header_default.yml | 32 ++ .../config_multiple_extractors.yml | 34 ++ .../app/AccessToken/config_no_extractors.yml | 27 ++ .../app/AccessToken/config_no_handler.yml | 29 ++ .../app/AccessToken/config_query_custom.yml | 37 ++ .../app/AccessToken/config_query_default.yml | 32 ++ .../Functional/app/AccessToken/routing.yml | 6 + .../Bundle/SecurityBundle/composer.json | 2 +- .../AccessTokenExtractorInterface.php | 24 ++ .../AccessTokenHandlerInterface.php | 28 ++ .../AccessToken/ChainAccessTokenExtractor.php | 41 +++ .../AccessToken/FormEncodedBodyExtractor.php | 47 +++ .../HeaderAccessTokenExtractor.php | 49 +++ .../AccessToken/QueryAccessTokenExtractor.php | 45 +++ .../AccessTokenAuthenticator.php | 122 +++++++ .../Component/Security/Http/CHANGELOG.md | 1 + .../ChainedAccessTokenExtractorsTest.php | 112 ++++++ ...ncodedBodyAccessTokenAuthenticatorTest.php | 127 +++++++ .../HeaderAccessTokenAuthenticatorTest.php | 151 +++++++++ .../QueryAccessTokenAuthenticatorTest.php | 119 +++++++ .../InMemoryAccessTokenHandler.php | 46 +++ 39 files changed, 1994 insertions(+), 1 deletion(-) create mode 100644 src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AccessTokenFactory.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator_access_token.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/AccessTokenFactoryTest.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/AccessTokenTest.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/AccessTokenBundle/AccessTokenBundle.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/AccessTokenBundle/Controller/BarController.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/AccessTokenBundle/Controller/FooController.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/AccessTokenBundle/Security/Handler/AccessTokenHandler.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/AccessTokenBundle/Security/Http/JsonAuthenticationFailureHandler.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/AccessTokenBundle/Security/Http/JsonAuthenticationSuccessHandler.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/bundles.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_anonymous.yml create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_body_custom.yml create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_body_default.yml create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_header_custom.yml create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_header_default.yml create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_multiple_extractors.yml create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_no_extractors.yml create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_no_handler.yml create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_query_custom.yml create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_query_default.yml create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/routing.yml create mode 100644 src/Symfony/Component/Security/Http/AccessToken/AccessTokenExtractorInterface.php create mode 100644 src/Symfony/Component/Security/Http/AccessToken/AccessTokenHandlerInterface.php create mode 100644 src/Symfony/Component/Security/Http/AccessToken/ChainAccessTokenExtractor.php create mode 100644 src/Symfony/Component/Security/Http/AccessToken/FormEncodedBodyExtractor.php create mode 100644 src/Symfony/Component/Security/Http/AccessToken/HeaderAccessTokenExtractor.php create mode 100644 src/Symfony/Component/Security/Http/AccessToken/QueryAccessTokenExtractor.php create mode 100644 src/Symfony/Component/Security/Http/Authenticator/AccessTokenAuthenticator.php create mode 100644 src/Symfony/Component/Security/Http/Tests/Authenticator/AccessToken/ChainedAccessTokenExtractorsTest.php create mode 100644 src/Symfony/Component/Security/Http/Tests/Authenticator/AccessToken/FormEncodedBodyAccessTokenAuthenticatorTest.php create mode 100644 src/Symfony/Component/Security/Http/Tests/Authenticator/AccessToken/HeaderAccessTokenAuthenticatorTest.php create mode 100644 src/Symfony/Component/Security/Http/Tests/Authenticator/AccessToken/QueryAccessTokenAuthenticatorTest.php create mode 100644 src/Symfony/Component/Security/Http/Tests/Authenticator/InMemoryAccessTokenHandler.php diff --git a/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md b/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md index 2c63dc89d16ad..7a96e86cdbe9e 100644 --- a/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md @@ -10,6 +10,7 @@ CHANGELOG * Add `Security::login()` to login programmatically * Add `Security::logout()` to logout programmatically * Add `security.firewalls.logout.enable_csrf` to enable CSRF protection using the default CSRF token generator + * Add RFC6750 Access Token support to allow token-based authentication 6.1 --- diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AccessTokenFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AccessTokenFactory.php new file mode 100644 index 0000000000000..7034e780cc1d2 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AccessTokenFactory.php @@ -0,0 +1,121 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory; + +use Symfony\Component\Config\Definition\Builder\NodeDefinition; +use Symfony\Component\DependencyInjection\ChildDefinition; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; + +/** + * AccessTokenFactory creates services for Access Token authentication. + * + * @author Florent Morselli + * + * @internal + */ +final class AccessTokenFactory extends AbstractFactory +{ + private const PRIORITY = -40; + + public function __construct() + { + $this->options = []; + $this->defaultFailureHandlerOptions = []; + $this->defaultSuccessHandlerOptions = []; + } + + public function addConfiguration(NodeDefinition $node): void + { + $builder = $node->children(); + + $builder + ->scalarNode('token_handler')->isRequired()->end() + ->scalarNode('user_provider')->defaultNull()->end() + ->scalarNode('realm')->defaultNull()->end() + ->scalarNode('success_handler')->defaultNull()->end() + ->scalarNode('failure_handler')->defaultNull()->end() + ->arrayNode('token_extractors') + ->fixXmlConfig('token_extractors') + ->beforeNormalization() + ->ifString() + ->then(static function (string $v): array { return [$v]; }) + ->end() + ->cannotBeEmpty() + ->defaultValue([ + 'security.access_token_extractor.header', + ]) + ->scalarPrototype()->end() + ->end() + ; + } + + public function getPriority(): int + { + return self::PRIORITY; + } + + /** + * {@inheritdoc} + */ + public function getKey(): string + { + return 'access_token'; + } + + public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId): string + { + $userProvider = new Reference($config['user_provider'] ?? $userProviderId); + $successHandler = isset($config['success_handler']) ? new Reference($this->createAuthenticationSuccessHandler($container, $firewallName, $config)) : null; + $failureHandler = isset($config['failure_handler']) ? new Reference($this->createAuthenticationFailureHandler($container, $firewallName, $config)) : null; + $authenticatorId = sprintf('security.authenticator.access_token.%s', $firewallName); + $extractorId = $this->createExtractor($container, $firewallName, $config['token_extractors']); + + $container + ->setDefinition($authenticatorId, new ChildDefinition('security.authenticator.access_token')) + ->replaceArgument(0, $userProvider) + ->replaceArgument(1, new Reference($config['token_handler'])) + ->replaceArgument(2, new Reference($extractorId)) + ->replaceArgument(3, $successHandler) + ->replaceArgument(4, $failureHandler) + ->replaceArgument(5, $config['realm']) + ; + + return $authenticatorId; + } + + /** + * @param array $extractors + */ + private function createExtractor(ContainerBuilder $container, string $firewallName, array $extractors): string + { + $aliases = [ + 'query_string' => 'security.access_token_extractor.query_string', + 'request_body' => 'security.access_token_extractor.request_body', + 'header' => 'security.access_token_extractor.header', + ]; + $extractors = array_map(static function (string $extractor) use ($aliases): string { + return $aliases[$extractor] ?? $extractor; + }, $extractors); + + if (1 === \count($extractors)) { + return current($extractors); + } + $extractorId = sprintf('security.authenticator.access_token.chain_extractor.%s', $firewallName); + $container + ->setDefinition($extractorId, new ChildDefinition('security.authenticator.access_token.chain_extractor')) + ->replaceArgument(0, array_map(function (string $extractorId): Reference {return new Reference($extractorId); }, $extractors)) + ; + + return $extractorId; + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php index 9f46f23bab0c0..b5e7a619fdc1f 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php @@ -98,6 +98,7 @@ public function load(array $configs, ContainerBuilder $container) } $loader->load('security_authenticator.php'); + $loader->load('security_authenticator_access_token.php'); if ($container::willBeAvailable('symfony/twig-bridge', LogoutUrlExtension::class, ['symfony/security-bundle'])) { $loader->load('templating_twig.php'); diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator_access_token.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator_access_token.php new file mode 100644 index 0000000000000..17bc916c8d314 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator_access_token.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\Component\Security\Http\AccessToken\ChainAccessTokenExtractor; +use Symfony\Component\Security\Http\AccessToken\FormEncodedBodyExtractor; +use Symfony\Component\Security\Http\AccessToken\HeaderAccessTokenExtractor; +use Symfony\Component\Security\Http\AccessToken\QueryAccessTokenExtractor; +use Symfony\Component\Security\Http\Authenticator\AccessTokenAuthenticator; + +return static function (ContainerConfigurator $container) { + $container->services() + ->set('security.access_token_extractor.header', HeaderAccessTokenExtractor::class) + ->set('security.access_token_extractor.query_string', QueryAccessTokenExtractor::class) + ->set('security.access_token_extractor.request_body', FormEncodedBodyExtractor::class) + + ->set('security.authenticator.access_token', AccessTokenAuthenticator::class) + ->abstract() + ->args([ + abstract_arg('user provider'), + abstract_arg('access token handler'), + abstract_arg('access token extractor'), + null, + null, + null, + ]) + ->call('setTranslator', [service('translator')->ignoreOnInvalid()]) + + ->set('security.authenticator.access_token.chain_extractor', ChainAccessTokenExtractor::class) + ->abstract() + ->args([ + abstract_arg('access token extractors'), + ]) + ; +}; diff --git a/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php b/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php index 01eeb74c3a094..3d90b1690a589 100644 --- a/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php +++ b/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php @@ -22,6 +22,7 @@ use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterTokenUsageTrackingPass; use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\ReplaceDecoratedRememberMeHandlerPass; use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\SortFirewallListenersPass; +use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\AccessTokenFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\CustomAuthenticatorFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\FormLoginFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\FormLoginLdapFactory; @@ -69,6 +70,7 @@ public function build(ContainerBuilder $container) $extension->addAuthenticatorFactory(new CustomAuthenticatorFactory()); $extension->addAuthenticatorFactory(new LoginThrottlingFactory()); $extension->addAuthenticatorFactory(new LoginLinkFactory()); + $extension->addAuthenticatorFactory(new AccessTokenFactory()); $extension->addUserProviderFactory(new InMemoryFactory()); $extension->addUserProviderFactory(new LdapFactory()); diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/AccessTokenFactoryTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/AccessTokenFactoryTest.php new file mode 100644 index 0000000000000..f31798113b75c --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/AccessTokenFactoryTest.php @@ -0,0 +1,93 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Tests\DependencyInjection\Security\Factory; + +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\AccessTokenFactory; +use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; +use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +class AccessTokenFactoryTest extends TestCase +{ + public function testBasicServiceConfiguration() + { + $container = new ContainerBuilder(); + $config = [ + 'token_handler' => 'in_memory_token_handler_service_id', + 'success_handler' => 'success_handler_service_id', + 'failure_handler' => 'failure_handler_service_id', + 'token_extractors' => ['BAR', 'FOO'], + ]; + + $factory = new AccessTokenFactory(); + $finalizedConfig = $this->processConfig($config, $factory); + + $factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider'); + + $this->assertTrue($container->hasDefinition('security.authenticator.access_token.firewall1')); + } + + public function testDefaultServiceConfiguration() + { + $container = new ContainerBuilder(); + $config = [ + 'token_handler' => 'in_memory_token_handler_service_id', + ]; + + $factory = new AccessTokenFactory(); + $finalizedConfig = $this->processConfig($config, $factory); + + $factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider'); + + $this->assertTrue($container->hasDefinition('security.authenticator.access_token.firewall1')); + } + + public function testNoExtractorsDefined() + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('The path "access_token.token_extractors" should have at least 1 element(s) defined.'); + $config = [ + 'token_handler' => 'in_memory_token_handler_service_id', + 'success_handler' => 'success_handler_service_id', + 'failure_handler' => 'failure_handler_service_id', + 'token_extractors' => [], + ]; + + $factory = new AccessTokenFactory(); + $this->processConfig($config, $factory); + } + + public function testNoHandlerDefined() + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('The child config "token_handler" under "access_token" must be configured.'); + $config = [ + 'success_handler' => 'success_handler_service_id', + 'failure_handler' => 'failure_handler_service_id', + ]; + + $factory = new AccessTokenFactory(); + $this->processConfig($config, $factory); + } + + private function processConfig(array $config, AccessTokenFactory $factory) + { + $nodeDefinition = new ArrayNodeDefinition('access_token'); + $factory->addConfiguration($nodeDefinition); + + $node = $nodeDefinition->getNode(); + $normalizedConfig = $node->normalize($config); + + return $node->finalize($normalizedConfig); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/AccessTokenTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/AccessTokenTest.php new file mode 100644 index 0000000000000..01b205737dd59 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/AccessTokenTest.php @@ -0,0 +1,318 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Tests\Functional; + +use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; +use Symfony\Component\HttpFoundation\Response; + +class AccessTokenTest extends AbstractWebTestCase +{ + public function testNoTokenHandlerConfiguredShouldFail() + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('The child config "token_handler" under "security.firewalls.main.access_token" must be configured.'); + $this->createClient(['test_case' => 'AccessToken', 'root_config' => 'config_no_handler.yml']); + } + + public function testNoTokenExtractorsConfiguredShouldFail() + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('The path "security.firewalls.main.access_token.token_extractors" should have at least 1 element(s) defined.'); + $this->createClient(['test_case' => 'AccessToken', 'root_config' => 'config_no_extractors.yml']); + } + + public function testAnonymousAccessIsGranted() + { + $client = $this->createClient(['test_case' => 'AccessToken', 'root_config' => 'config_anonymous.yml']); + $client->request('GET', '/bar'); + $response = $client->getResponse(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(['message' => 'Welcome anonymous!'], json_decode($response->getContent(), true)); + } + + public function testDefaultFormEncodedBodySuccess() + { + $client = $this->createClient(['test_case' => 'AccessToken', 'root_config' => 'config_body_default.yml']); + $client->request('POST', '/foo', ['access_token' => 'VALID_ACCESS_TOKEN'], [], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']); + $response = $client->getResponse(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(['message' => 'Welcome @dunglas!'], json_decode($response->getContent(), true)); + } + + /** + * @dataProvider defaultFormEncodedBodyFailureData + */ + public function testDefaultFormEncodedBodyFailure(array $parameters, array $headers) + { + $client = $this->createClient(['test_case' => 'AccessToken', 'root_config' => 'config_body_default.yml']); + $client->request('POST', '/foo', $parameters, [], $headers); + $response = $client->getResponse(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(401, $response->getStatusCode()); + $this->assertSame('', $response->getContent()); + $this->assertSame('Bearer realm="My API",error="invalid_token",error_description="Invalid credentials."', $response->headers->get('WWW-Authenticate')); + } + + public function testDefaultMissingFormEncodedBodyFail() + { + $client = $this->createClient(['test_case' => 'AccessToken', 'root_config' => 'config_body_default.yml']); + $client->request('GET', '/foo'); + $response = $client->getResponse(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(401, $response->getStatusCode()); + } + + public function testCustomFormEncodedBodySuccess() + { + $client = $this->createClient(['test_case' => 'AccessToken', 'root_config' => 'config_body_custom.yml']); + $client->request('POST', '/foo', ['secured_token' => 'VALID_ACCESS_TOKEN'], [], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']); + $response = $client->getResponse(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(['message' => 'Good game @dunglas!'], json_decode($response->getContent(), true)); + } + + /** + * @dataProvider customFormEncodedBodyFailure + */ + public function testCustomFormEncodedBodyFailure(array $parameters, array $headers) + { + $client = $this->createClient(['test_case' => 'AccessToken', 'root_config' => 'config_body_custom.yml']); + $client->request('POST', '/foo', $parameters, [], $headers); + $response = $client->getResponse(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(500, $response->getStatusCode()); + $this->assertSame(['message' => 'Something went wrong'], json_decode($response->getContent(), true)); + $this->assertFalse($response->headers->has('WWW-Authenticate')); + } + + public function testCustomMissingFormEncodedBodyShouldFail() + { + $client = $this->createClient(['test_case' => 'AccessToken', 'root_config' => 'config_body_custom.yml']); + $client->request('POST', '/foo'); + $response = $client->getResponse(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(401, $response->getStatusCode()); + } + + public function defaultFormEncodedBodyFailureData(): iterable + { + yield [['access_token' => 'INVALID_ACCESS_TOKEN'], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']]; + } + + public function customFormEncodedBodyFailure(): iterable + { + yield [['secured_token' => 'INVALID_ACCESS_TOKEN'], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']]; + } + + public function testDefaultHeaderAccessTokenSuccess() + { + $client = $this->createClient(['test_case' => 'AccessToken', 'root_config' => 'config_header_default.yml']); + $client->request('GET', '/foo', [], [], ['HTTP_AUTHORIZATION' => 'Bearer VALID_ACCESS_TOKEN']); + $response = $client->getResponse(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(['message' => 'Welcome @dunglas!'], json_decode($response->getContent(), true)); + } + + public function testMultipleAccessTokenExtractorSuccess() + { + $client = $this->createClient(['test_case' => 'AccessToken', 'root_config' => 'config_multiple_extractors.yml']); + $client->request('GET', '/foo', [], [], ['HTTP_AUTHORIZATION' => 'Bearer VALID_ACCESS_TOKEN']); + $response = $client->getResponse(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(['message' => 'Welcome @dunglas!'], json_decode($response->getContent(), true)); + } + + /** + * @dataProvider defaultHeaderAccessTokenFailureData + */ + public function testDefaultHeaderAccessTokenFailure(array $headers) + { + $client = $this->createClient(['test_case' => 'AccessToken', 'root_config' => 'config_header_default.yml']); + $client->request('GET', '/foo', [], [], $headers); + $response = $client->getResponse(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(401, $response->getStatusCode()); + $this->assertSame('', $response->getContent()); + $this->assertSame('Bearer realm="My API",error="invalid_token",error_description="Invalid credentials."', $response->headers->get('WWW-Authenticate')); + } + + /** + * @dataProvider defaultMissingHeaderAccessTokenFailData + */ + public function testDefaultMissingHeaderAccessTokenFail(array $headers) + { + $client = $this->createClient(['test_case' => 'AccessToken', 'root_config' => 'config_header_default.yml']); + $client->request('GET', '/foo', [], [], $headers); + $response = $client->getResponse(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(401, $response->getStatusCode()); + } + + public function testCustomHeaderAccessTokenSuccess() + { + $client = $this->createClient(['test_case' => 'AccessToken', 'root_config' => 'config_header_custom.yml']); + $client->request('GET', '/foo', [], [], ['HTTP_X_AUTH_TOKEN' => 'VALID_ACCESS_TOKEN']); + $response = $client->getResponse(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(['message' => 'Good game @dunglas!'], json_decode($response->getContent(), true)); + } + + /** + * @dataProvider customHeaderAccessTokenFailure + */ + public function testCustomHeaderAccessTokenFailure(array $headers, int $errorCode) + { + $client = $this->createClient(['test_case' => 'AccessToken', 'root_config' => 'config_header_custom.yml']); + $client->request('GET', '/foo', [], [], $headers); + $response = $client->getResponse(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($errorCode, $response->getStatusCode()); + $this->assertFalse($response->headers->has('WWW-Authenticate')); + } + + /** + * @dataProvider customMissingHeaderAccessTokenShouldFail + */ + public function testCustomMissingHeaderAccessTokenShouldFail(array $headers) + { + $client = $this->createClient(['test_case' => 'AccessToken', 'root_config' => 'config_header_custom.yml']); + $client->request('GET', '/foo', [], [], $headers); + $response = $client->getResponse(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(401, $response->getStatusCode()); + } + + public function defaultHeaderAccessTokenFailureData(): iterable + { + yield [['HTTP_AUTHORIZATION' => 'Bearer INVALID_ACCESS_TOKEN']]; + } + + public function defaultMissingHeaderAccessTokenFailData(): iterable + { + yield [['HTTP_AUTHORIZATION' => 'JWT INVALID_TOKEN_TYPE']]; + yield [['HTTP_X_FOO' => 'Missing-Header']]; + yield [['HTTP_X_AUTH_TOKEN' => 'this is not a token']]; + } + + public function customHeaderAccessTokenFailure(): iterable + { + yield [['HTTP_X_AUTH_TOKEN' => 'INVALID_ACCESS_TOKEN'], 500]; + } + + public function customMissingHeaderAccessTokenShouldFail(): iterable + { + yield [[]]; + yield [['HTTP_AUTHORIZATION' => 'Bearer this is not a token']]; + } + + public function testDefaultQueryAccessTokenSuccess() + { + $client = $this->createClient(['test_case' => 'AccessToken', 'root_config' => 'config_query_default.yml']); + $client->request('GET', '/foo?access_token=VALID_ACCESS_TOKEN'); + $response = $client->getResponse(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(['message' => 'Welcome @dunglas!'], json_decode($response->getContent(), true)); + } + + /** + * @dataProvider defaultQueryAccessTokenFailureData + */ + public function testDefaultQueryAccessTokenFailure(string $query) + { + $client = $this->createClient(['test_case' => 'AccessToken', 'root_config' => 'config_query_default.yml']); + $client->request('GET', $query); + $response = $client->getResponse(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(401, $response->getStatusCode()); + $this->assertSame('', $response->getContent()); + $this->assertSame('Bearer realm="My API",error="invalid_token",error_description="Invalid credentials."', $response->headers->get('WWW-Authenticate')); + } + + public function testDefaultMissingQueryAccessTokenFail() + { + $client = $this->createClient(['test_case' => 'AccessToken', 'root_config' => 'config_query_default.yml']); + $client->request('GET', '/foo'); + $response = $client->getResponse(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(401, $response->getStatusCode()); + } + + public function testCustomQueryAccessTokenSuccess() + { + $client = $this->createClient(['test_case' => 'AccessToken', 'root_config' => 'config_query_custom.yml']); + $client->request('GET', '/foo?protection_token=VALID_ACCESS_TOKEN'); + $response = $client->getResponse(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(['message' => 'Good game @dunglas!'], json_decode($response->getContent(), true)); + } + + /** + * @dataProvider customQueryAccessTokenFailure + */ + public function testCustomQueryAccessTokenFailure(string $query) + { + $client = $this->createClient(['test_case' => 'AccessToken', 'root_config' => 'config_query_custom.yml']); + $client->request('GET', $query); + $response = $client->getResponse(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(500, $response->getStatusCode()); + $this->assertSame(['message' => 'Something went wrong'], json_decode($response->getContent(), true)); + $this->assertFalse($response->headers->has('WWW-Authenticate')); + } + + public function testCustomMissingQueryAccessTokenShouldFail() + { + $client = $this->createClient(['test_case' => 'AccessToken', 'root_config' => 'config_query_custom.yml']); + $client->request('GET', '/foo'); + $response = $client->getResponse(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(401, $response->getStatusCode()); + } + + public function defaultQueryAccessTokenFailureData(): iterable + { + yield ['/foo?access_token=INVALID_ACCESS_TOKEN']; + } + + public function customQueryAccessTokenFailure(): iterable + { + yield ['/foo?protection_token=INVALID_ACCESS_TOKEN']; + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/AccessTokenBundle/AccessTokenBundle.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/AccessTokenBundle/AccessTokenBundle.php new file mode 100644 index 0000000000000..a59c7cd47feef --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/AccessTokenBundle/AccessTokenBundle.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AccessTokenBundle; + +use Symfony\Component\HttpKernel\Bundle\Bundle; + +class AccessTokenBundle extends Bundle +{ +} diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/AccessTokenBundle/Controller/BarController.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/AccessTokenBundle/Controller/BarController.php new file mode 100644 index 0000000000000..f1cc399926e65 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/AccessTokenBundle/Controller/BarController.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AccessTokenBundle\Controller; + +use Symfony\Component\HttpFoundation\JsonResponse; + +class BarController +{ + public function __invoke(): JsonResponse + { + return new JsonResponse(['message' => 'Welcome anonymous!']); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/AccessTokenBundle/Controller/FooController.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/AccessTokenBundle/Controller/FooController.php new file mode 100644 index 0000000000000..7bc8e73502b78 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/AccessTokenBundle/Controller/FooController.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AccessTokenBundle\Controller; + +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\Security\Core\User\UserInterface; + +class FooController +{ + public function __invoke(UserInterface $user): JsonResponse + { + return new JsonResponse(['message' => sprintf('Welcome @%s!', $user->getUserIdentifier())]); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/AccessTokenBundle/Security/Handler/AccessTokenHandler.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/AccessTokenBundle/Security/Handler/AccessTokenHandler.php new file mode 100644 index 0000000000000..38bd28b4d3a96 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/AccessTokenBundle/Security/Handler/AccessTokenHandler.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AccessTokenBundle\Security\Handler; + +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface; + +class AccessTokenHandler implements AccessTokenHandlerInterface +{ + public function __construct() + { + } + + public function getUserIdentifierFrom(string $accessToken): string + { + switch ($accessToken) { + case 'VALID_ACCESS_TOKEN': + return 'dunglas'; + default: + throw new BadCredentialsException('Invalid credentials.'); + } + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/AccessTokenBundle/Security/Http/JsonAuthenticationFailureHandler.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/AccessTokenBundle/Security/Http/JsonAuthenticationFailureHandler.php new file mode 100644 index 0000000000000..ea6e608238721 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/AccessTokenBundle/Security/Http/JsonAuthenticationFailureHandler.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AccessTokenBundle\Security\Http; + +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface; + +class JsonAuthenticationFailureHandler implements AuthenticationFailureHandlerInterface +{ + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response + { + return new JsonResponse(['message' => 'Something went wrong'], 500); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/AccessTokenBundle/Security/Http/JsonAuthenticationSuccessHandler.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/AccessTokenBundle/Security/Http/JsonAuthenticationSuccessHandler.php new file mode 100644 index 0000000000000..fd08a4adc96ad --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/AccessTokenBundle/Security/Http/JsonAuthenticationSuccessHandler.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AccessTokenBundle\Security\Http; + +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface; + +class JsonAuthenticationSuccessHandler implements AuthenticationSuccessHandlerInterface +{ + public function onAuthenticationSuccess(Request $request, TokenInterface $token): Response + { + return new JsonResponse(['message' => sprintf('Good game @%s!', $token->getUserIdentifier())]); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/bundles.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/bundles.php new file mode 100644 index 0000000000000..ea92c9159102d --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/bundles.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +return [ + new Symfony\Bundle\SecurityBundle\SecurityBundle(), + new Symfony\Bundle\FrameworkBundle\FrameworkBundle(), + new Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AccessTokenBundle\AccessTokenBundle(), + new Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\TestBundle(), +]; diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_anonymous.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_anonymous.yml new file mode 100644 index 0000000000000..ccf7c5d052bbc --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_anonymous.yml @@ -0,0 +1,33 @@ +imports: + - { resource: ./../config/framework.yml } + +framework: + http_method_override: false + serializer: ~ + +security: + enable_authenticator_manager: true + password_hashers: + Symfony\Component\Security\Core\User\InMemoryUser: plaintext + + providers: + in_memory: + memory: + users: + dunglas: { password: foo, roles: [ROLE_USER] } + + firewalls: + main: + pattern: ^/ + lazy: true + access_token: + token_handler: access_token.access_token_handler + token_extractors: 'security.access_token_extractor.header' + + access_control: + - { path: ^/foo, roles: ROLE_USER } + - { path: ^/bar, roles: PUBLIC_ACCESS } + +services: + access_token.access_token_handler: + class: Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AccessTokenBundle\Security\Handler\AccessTokenHandler diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_body_custom.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_body_custom.yml new file mode 100644 index 0000000000000..88ab6295c22b8 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_body_custom.yml @@ -0,0 +1,37 @@ +imports: + - { resource: ./../config/framework.yml } + +security: + enable_authenticator_manager: true + password_hashers: + Symfony\Component\Security\Core\User\InMemoryUser: plaintext + + providers: + in_memory: + memory: + users: + dunglas: { password: foo, roles: [ROLE_USER] } + + firewalls: + main: + pattern: ^/ + access_token: + token_handler: access_token.access_token_handler + success_handler: access_token.success_handler + failure_handler: access_token.failure_handler + token_extractors: 'custom_extractor' + + access_control: + - { path: ^/foo, roles: ROLE_USER } + +services: + custom_extractor: + class: Symfony\Component\Security\Http\AccessToken\FormEncodedBodyExtractor + arguments: + - 'secured_token' + access_token.access_token_handler: + class: Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AccessTokenBundle\Security\Handler\AccessTokenHandler + access_token.success_handler: + class: Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AccessTokenBundle\Security\Http\JsonAuthenticationSuccessHandler + access_token.failure_handler: + class: Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AccessTokenBundle\Security\Http\JsonAuthenticationFailureHandler diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_body_default.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_body_default.yml new file mode 100644 index 0000000000000..da42a05d33939 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_body_default.yml @@ -0,0 +1,32 @@ +imports: + - { resource: ./../config/framework.yml } + +framework: + http_method_override: false + serializer: ~ + +security: + enable_authenticator_manager: true + password_hashers: + Symfony\Component\Security\Core\User\InMemoryUser: plaintext + + providers: + in_memory: + memory: + users: + dunglas: { password: foo, roles: [ROLE_USER] } + + firewalls: + main: + pattern: ^/ + access_token: + token_handler: access_token.access_token_handler + token_extractors: 'request_body' + realm: 'My API' + + access_control: + - { path: ^/foo, roles: ROLE_USER } + +services: + access_token.access_token_handler: + class: Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AccessTokenBundle\Security\Handler\AccessTokenHandler diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_header_custom.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_header_custom.yml new file mode 100644 index 0000000000000..2062596b1c156 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_header_custom.yml @@ -0,0 +1,38 @@ +imports: + - { resource: ./../config/framework.yml } + +security: + enable_authenticator_manager: true + password_hashers: + Symfony\Component\Security\Core\User\InMemoryUser: plaintext + + providers: + in_memory: + memory: + users: + dunglas: { password: foo, roles: [ROLE_USER] } + + firewalls: + main: + pattern: ^/ + access_token: + token_handler: access_token.access_token_handler + success_handler: access_token.success_handler + failure_handler: access_token.failure_handler + token_extractors: 'custom_extractor' + + access_control: + - { path: ^/foo, roles: ROLE_USER } + +services: + custom_extractor: + class: Symfony\Component\Security\Http\AccessToken\HeaderAccessTokenExtractor + arguments: + - 'X-AUTH-TOKEN' + - '' + access_token.access_token_handler: + class: Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AccessTokenBundle\Security\Handler\AccessTokenHandler + access_token.success_handler: + class: Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AccessTokenBundle\Security\Http\JsonAuthenticationSuccessHandler + access_token.failure_handler: + class: Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AccessTokenBundle\Security\Http\JsonAuthenticationFailureHandler diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_header_default.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_header_default.yml new file mode 100644 index 0000000000000..eec1658c43867 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_header_default.yml @@ -0,0 +1,32 @@ +imports: + - { resource: ./../config/framework.yml } + +framework: + http_method_override: false + serializer: ~ + +security: + enable_authenticator_manager: true + password_hashers: + Symfony\Component\Security\Core\User\InMemoryUser: plaintext + + providers: + in_memory: + memory: + users: + dunglas: { password: foo, roles: [ROLE_USER] } + + firewalls: + main: + pattern: ^/ + access_token: + token_handler: access_token.access_token_handler + token_extractors: 'header' + realm: 'My API' + + access_control: + - { path: ^/foo, roles: ROLE_USER } + +services: + access_token.access_token_handler: + class: Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AccessTokenBundle\Security\Handler\AccessTokenHandler diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_multiple_extractors.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_multiple_extractors.yml new file mode 100644 index 0000000000000..c4bc2977b0aec --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_multiple_extractors.yml @@ -0,0 +1,34 @@ +imports: + - { resource: ./../config/framework.yml } + +framework: + http_method_override: false + serializer: ~ + +security: + enable_authenticator_manager: true + password_hashers: + Symfony\Component\Security\Core\User\InMemoryUser: plaintext + + providers: + in_memory: + memory: + users: + dunglas: { password: foo, roles: [ROLE_USER] } + + firewalls: + main: + pattern: ^/ + access_token: + token_handler: access_token.access_token_handler + token_extractors: + - 'security.access_token_extractor.query_string' + - 'security.access_token_extractor.request_body' + - 'security.access_token_extractor.header' + + access_control: + - { path: ^/foo, roles: ROLE_USER } + +services: + access_token.access_token_handler: + class: Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AccessTokenBundle\Security\Handler\AccessTokenHandler diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_no_extractors.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_no_extractors.yml new file mode 100644 index 0000000000000..d02b379e55676 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_no_extractors.yml @@ -0,0 +1,27 @@ +imports: + - { resource: ./../config/framework.yml } + +security: + enable_authenticator_manager: true + password_hashers: + Symfony\Component\Security\Core\User\InMemoryUser: plaintext + + providers: + in_memory: + memory: + users: + dunglas: { password: foo, roles: [ROLE_USER] } + + firewalls: + main: + pattern: ^/ + access_token: + token_handler: access_token.access_token_handler + token_extractors: [] + + access_control: + - { path: ^/foo, roles: ROLE_USER } + +services: + access_token.access_token_handler: + class: Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AccessTokenBundle\Security\Handler\AccessTokenHandler diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_no_handler.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_no_handler.yml new file mode 100644 index 0000000000000..a1c26742ca673 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_no_handler.yml @@ -0,0 +1,29 @@ +imports: + - { resource: ./../config/framework.yml } + +security: + enable_authenticator_manager: true + password_hashers: + Symfony\Component\Security\Core\User\InMemoryUser: plaintext + + providers: + in_memory: + memory: + users: + dunglas: { password: foo, roles: [ROLE_USER] } + + firewalls: + main: + pattern: ^/ + access_token: + success_handler: access_token.success_handler + failure_handler: access_token.failure_handler + + access_control: + - { path: ^/foo, roles: ROLE_USER } + +services: + access_token.success_handler: + class: Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AccessTokenBundle\Security\Http\JsonAuthenticationSuccessHandler + access_token.failure_handler: + class: Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AccessTokenBundle\Security\Http\JsonAuthenticationFailureHandler diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_query_custom.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_query_custom.yml new file mode 100644 index 0000000000000..9f9bca322e5be --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_query_custom.yml @@ -0,0 +1,37 @@ +imports: + - { resource: ./../config/framework.yml } + +security: + enable_authenticator_manager: true + password_hashers: + Symfony\Component\Security\Core\User\InMemoryUser: plaintext + + providers: + in_memory: + memory: + users: + dunglas: { password: foo, roles: [ROLE_USER] } + + firewalls: + main: + pattern: ^/ + access_token: + token_handler: access_token.access_token_handler + success_handler: access_token.success_handler + failure_handler: access_token.failure_handler + token_extractors: 'custom_extractor' + + access_control: + - { path: ^/foo, roles: ROLE_USER } + +services: + custom_extractor: + class: Symfony\Component\Security\Http\AccessToken\QueryAccessTokenExtractor + arguments: + - 'protection_token' + access_token.access_token_handler: + class: Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AccessTokenBundle\Security\Handler\AccessTokenHandler + access_token.success_handler: + class: Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AccessTokenBundle\Security\Http\JsonAuthenticationSuccessHandler + access_token.failure_handler: + class: Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AccessTokenBundle\Security\Http\JsonAuthenticationFailureHandler diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_query_default.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_query_default.yml new file mode 100644 index 0000000000000..a4a95fdba4185 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_query_default.yml @@ -0,0 +1,32 @@ +imports: + - { resource: ./../config/framework.yml } + +framework: + http_method_override: false + serializer: ~ + +security: + enable_authenticator_manager: true + password_hashers: + Symfony\Component\Security\Core\User\InMemoryUser: plaintext + + providers: + in_memory: + memory: + users: + dunglas: { password: foo, roles: [ROLE_USER] } + + firewalls: + main: + pattern: ^/ + access_token: + token_handler: access_token.access_token_handler + token_extractors: 'query_string' + realm: 'My API' + + access_control: + - { path: ^/foo, roles: ROLE_USER } + +services: + access_token.access_token_handler: + class: Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AccessTokenBundle\Security\Handler\AccessTokenHandler diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/routing.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/routing.yml new file mode 100644 index 0000000000000..cbf300db53ba2 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/routing.yml @@ -0,0 +1,6 @@ +foo_route: + path: /foo + defaults: { _controller: Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AccessTokenBundle\Controller\FooController::__invoke } +bar_route: + path: /bar + defaults: { _controller: Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AccessTokenBundle\Controller\BarController::__invoke } diff --git a/src/Symfony/Bundle/SecurityBundle/composer.json b/src/Symfony/Bundle/SecurityBundle/composer.json index 1788b1685573a..bd52ad5bee75f 100644 --- a/src/Symfony/Bundle/SecurityBundle/composer.json +++ b/src/Symfony/Bundle/SecurityBundle/composer.json @@ -25,7 +25,7 @@ "symfony/http-kernel": "^6.2", "symfony/http-foundation": "^5.4|^6.0", "symfony/password-hasher": "^5.4|^6.0", - "symfony/security-core": "^5.4|^6.0", + "symfony/security-core": "^6.2", "symfony/security-csrf": "^5.4|^6.0", "symfony/security-http": "^6.2" }, diff --git a/src/Symfony/Component/Security/Http/AccessToken/AccessTokenExtractorInterface.php b/src/Symfony/Component/Security/Http/AccessToken/AccessTokenExtractorInterface.php new file mode 100644 index 0000000000000..dcd48a3c14e7b --- /dev/null +++ b/src/Symfony/Component/Security/Http/AccessToken/AccessTokenExtractorInterface.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\AccessToken; + +use Symfony\Component\HttpFoundation\Request; + +/** + * The token extractor retrieves the token from a request. + * + * @author Florent Morselli + */ +interface AccessTokenExtractorInterface +{ + public function extractAccessToken(Request $request): ?string; +} diff --git a/src/Symfony/Component/Security/Http/AccessToken/AccessTokenHandlerInterface.php b/src/Symfony/Component/Security/Http/AccessToken/AccessTokenHandlerInterface.php new file mode 100644 index 0000000000000..33a0690d15cc5 --- /dev/null +++ b/src/Symfony/Component/Security/Http/AccessToken/AccessTokenHandlerInterface.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\AccessToken; + +use Symfony\Component\Security\Core\Exception\AuthenticationException; + +/** + * The token handler retrieves the user identifier from the token. + * In order to get the user identifier, implementations may need to load and validate the token (e.g. revocation, expiration time, digital signature...). + * + * @author Florent Morselli + */ +interface AccessTokenHandlerInterface +{ + /** + * @throws AuthenticationException + */ + public function getUserIdentifierFrom(string $accessToken): string; +} diff --git a/src/Symfony/Component/Security/Http/AccessToken/ChainAccessTokenExtractor.php b/src/Symfony/Component/Security/Http/AccessToken/ChainAccessTokenExtractor.php new file mode 100644 index 0000000000000..ff16b91182ea3 --- /dev/null +++ b/src/Symfony/Component/Security/Http/AccessToken/ChainAccessTokenExtractor.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\AccessToken; + +use Symfony\Component\HttpFoundation\Request; + +/** + * The token extractor retrieves the token from a request. + * + * @author Florent Morselli + */ +final class ChainAccessTokenExtractor implements AccessTokenExtractorInterface +{ + /** + * @param AccessTokenExtractorInterface[] $accessTokenExtractors + */ + public function __construct( + private readonly iterable $accessTokenExtractors, + ) { + } + + public function extractAccessToken(Request $request): ?string + { + foreach ($this->accessTokenExtractors as $extractor) { + if ($accessToken = $extractor->extractAccessToken($request)) { + return $accessToken; + } + } + + return null; + } +} diff --git a/src/Symfony/Component/Security/Http/AccessToken/FormEncodedBodyExtractor.php b/src/Symfony/Component/Security/Http/AccessToken/FormEncodedBodyExtractor.php new file mode 100644 index 0000000000000..866f3fbe6ad13 --- /dev/null +++ b/src/Symfony/Component/Security/Http/AccessToken/FormEncodedBodyExtractor.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\AccessToken; + +use Symfony\Component\HttpFoundation\Request; + +/** + * Extracts a token from the body request. + * + * WARNING! + * Because of the security weaknesses associated with this method, + * the request body method SHOULD NOT be used except in application contexts + * where participating browsers do not have access to the "Authorization" request header field. + * + * @author Florent Morselli + * + * @see https://datatracker.ietf.org/doc/html/rfc6750#section-2.2 + */ +final class FormEncodedBodyExtractor implements AccessTokenExtractorInterface +{ + public function __construct( + private readonly string $parameter = 'access_token' + ) { + } + + public function extractAccessToken(Request $request): ?string + { + if ( + Request::METHOD_POST !== $request->getMethod() + || !str_starts_with($request->headers->get('CONTENT_TYPE', ''), 'application/x-www-form-urlencoded') + ) { + return null; + } + $parameter = $request->request->get($this->parameter); + + return \is_string($parameter) ? $parameter : null; + } +} diff --git a/src/Symfony/Component/Security/Http/AccessToken/HeaderAccessTokenExtractor.php b/src/Symfony/Component/Security/Http/AccessToken/HeaderAccessTokenExtractor.php new file mode 100644 index 0000000000000..487b87c24633d --- /dev/null +++ b/src/Symfony/Component/Security/Http/AccessToken/HeaderAccessTokenExtractor.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\AccessToken; + +use Symfony\Component\HttpFoundation\Request; + +/** + * Extracts a token from the request header. + * + * @author Florent Morselli + * + * @see https://datatracker.ietf.org/doc/html/rfc6750#section-2.1 + */ +final class HeaderAccessTokenExtractor implements AccessTokenExtractorInterface +{ + private string $regex; + + public function __construct( + private readonly string $headerParameter = 'Authorization', + private readonly string $tokenType = 'Bearer' + ) { + $this->regex = sprintf( + '/^%s([a-zA-Z0-9\-_\+~\/\.]+)$/', + '' === $this->tokenType ? '' : preg_quote($this->tokenType).'\s+' + ); + } + + public function extractAccessToken(Request $request): ?string + { + if (!$request->headers->has($this->headerParameter) || !\is_string($header = $request->headers->get($this->headerParameter))) { + return null; + } + + if (preg_match($this->regex, $header, $matches)) { + return $matches[1]; + } + + return null; + } +} diff --git a/src/Symfony/Component/Security/Http/AccessToken/QueryAccessTokenExtractor.php b/src/Symfony/Component/Security/Http/AccessToken/QueryAccessTokenExtractor.php new file mode 100644 index 0000000000000..4e5b14aa735b7 --- /dev/null +++ b/src/Symfony/Component/Security/Http/AccessToken/QueryAccessTokenExtractor.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\AccessToken; + +use Symfony\Component\HttpFoundation\Request; + +/** + * Extracts a token from a query string parameter. + * + * WARNING! + * Because of the security weaknesses associated with the URI method, + * including the high likelihood that the URL containing the access token will be logged, + * it SHOULD NOT be used unless it is impossible to transport the access token in the + * request header field. + * + * @author Florent Morselli + * + * @see https://datatracker.ietf.org/doc/html/rfc6750#section-2.3 + */ +final class QueryAccessTokenExtractor implements AccessTokenExtractorInterface +{ + public const PARAMETER = 'access_token'; + + public function __construct( + private readonly string $parameter = self::PARAMETER, + ) + { + } + + public function extractAccessToken(Request $request): ?string + { + $parameter = $request->query->get($this->parameter); + + return \is_string($parameter) ? $parameter : null; + } +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/AccessTokenAuthenticator.php b/src/Symfony/Component/Security/Http/Authenticator/AccessTokenAuthenticator.php new file mode 100644 index 0000000000000..ae8f0a4ea3da2 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/AccessTokenAuthenticator.php @@ -0,0 +1,122 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Authenticator; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\User\UserProviderInterface; +use Symfony\Component\Security\Http\AccessToken\AccessTokenExtractorInterface; +use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface; +use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface; +use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\Passport; +use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; +use Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * Provides an implementation of the RFC6750 of an authentication via + * an access token. + * + * @author Florent Morselli + */ +class AccessTokenAuthenticator implements AuthenticatorInterface +{ + private ?TranslatorInterface $translator = null; + + public function __construct( + private readonly UserProviderInterface $userProvider, + private readonly AccessTokenHandlerInterface $accessTokenHandler, + private readonly AccessTokenExtractorInterface $accessTokenExtractor, + private readonly ?AuthenticationSuccessHandlerInterface $successHandler = null, + private readonly ?AuthenticationFailureHandlerInterface $failureHandler = null, + private readonly ?string $realm = null, + ) { + } + + public function supports(Request $request): ?bool + { + return null === $this->accessTokenExtractor->extractAccessToken($request) ? false : null; + } + + public function authenticate(Request $request): Passport + { + $accessToken = $this->accessTokenExtractor->extractAccessToken($request); + if (!$accessToken) { + throw new BadCredentialsException('Invalid credentials.'); + } + $userIdentifier = $this->accessTokenHandler->getUserIdentifierFrom($accessToken); + + return new SelfValidatingPassport( + new UserBadge($userIdentifier, $this->userProvider->loadUserByIdentifier(...)) + ); + } + + public function createToken(Passport $passport, string $firewallName): TokenInterface + { + return new PostAuthenticationToken($passport->getUser(), $firewallName, $passport->getUser()->getRoles()); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return $this->successHandler?->onAuthenticationSuccess($request, $token); + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response + { + if (null !== $this->failureHandler) { + return $this->failureHandler->onAuthenticationFailure($request, $exception); + } + + if (null !== $this->translator) { + $errorMessage = $this->translator->trans($exception->getMessageKey(), $exception->getMessageData(), 'security'); + } else { + $errorMessage = strtr($exception->getMessageKey(), $exception->getMessageData()); + } + + return new Response( + null, + Response::HTTP_UNAUTHORIZED, + ['WWW-Authenticate' => $this->getAuthenticateHeader($errorMessage)] + ); + } + + public function setTranslator(?TranslatorInterface $translator) + { + $this->translator = $translator; + } + + /** + * @see https://datatracker.ietf.org/doc/html/rfc6750#section-3 + */ + private function getAuthenticateHeader(string $errorDescription = null): string + { + $data = [ + 'realm' => $this->realm, + 'error' => 'invalid_token', + 'error_description' => $errorDescription, + ]; + $values = []; + foreach ($data as $k => $v) { + if (null === $v || '' === $v) { + continue; + } + $values[] = sprintf('%s="%s"', $k, $v); + } + + return sprintf('Bearer %s', implode(',', $values)); + } +} diff --git a/src/Symfony/Component/Security/Http/CHANGELOG.md b/src/Symfony/Component/Security/Http/CHANGELOG.md index 268baf2446bc6..7c590bd999702 100644 --- a/src/Symfony/Component/Security/Http/CHANGELOG.md +++ b/src/Symfony/Component/Security/Http/CHANGELOG.md @@ -9,6 +9,7 @@ CHANGELOG * Deprecate empty username or password when using when using `JsonLoginAuthenticator` * Set custom lifetime for login link * Add `$lifetime` parameter to `LoginLinkHandlerInterface::createLoginLink()` + * Add RFC6750 Access Token support to allow token-based authentication * Allow using expressions as `#[IsGranted()]` attribute and subject 6.0 diff --git a/src/Symfony/Component/Security/Http/Tests/Authenticator/AccessToken/ChainedAccessTokenExtractorsTest.php b/src/Symfony/Component/Security/Http/Tests/Authenticator/AccessToken/ChainedAccessTokenExtractorsTest.php new file mode 100644 index 0000000000000..e59ce918b0651 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Authenticator/AccessToken/ChainedAccessTokenExtractorsTest.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Authenticator\AccessToken; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\User\InMemoryUserProvider; +use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface; +use Symfony\Component\Security\Http\AccessToken\ChainAccessTokenExtractor; +use Symfony\Component\Security\Http\AccessToken\FormEncodedBodyExtractor; +use Symfony\Component\Security\Http\AccessToken\HeaderAccessTokenExtractor; +use Symfony\Component\Security\Http\AccessToken\QueryAccessTokenExtractor; +use Symfony\Component\Security\Http\Authenticator\AccessTokenAuthenticator; +use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; +use Symfony\Component\Security\Http\Tests\Authenticator\InMemoryAccessTokenHandler; + +class ChainedAccessTokenExtractorsTest extends TestCase +{ + private InMemoryUserProvider $userProvider; + private AccessTokenAuthenticator $authenticator; + private AccessTokenHandlerInterface $accessTokenHandler; + + protected function setUp(): void + { + $this->userProvider = new InMemoryUserProvider(); + $this->accessTokenHandler = new InMemoryAccessTokenHandler(); + } + + /** + * @dataProvider provideSupportData + */ + public function testSupport($request): void + { + $this->setUpAuthenticator(); + + $this->assertNull($this->authenticator->supports($request)); + } + + public function provideSupportData(): iterable + { + yield [new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer VALID_ACCESS_TOKEN'])]; + yield [new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer INVALID_ACCESS_TOKEN'])]; + } + + public function testAuthenticate(): void + { + $this->accessTokenHandler->add('VALID_ACCESS_TOKEN', 'foo'); + $this->setUpAuthenticator(); + + $request = new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer VALID_ACCESS_TOKEN']); + $passport = $this->authenticator->authenticate($request); + $this->assertInstanceOf(SelfValidatingPassport::class, $passport); + } + + /** + * @dataProvider provideInvalidAuthenticateData + */ + public function testAuthenticateInvalid($request, $errorMessage, $exceptionType = BadRequestHttpException::class): void + { + $this->expectException($exceptionType); + $this->expectExceptionMessage($errorMessage); + + $this->setUpAuthenticator(); + + $this->authenticator->authenticate($request); + } + + public function provideInvalidAuthenticateData(): iterable + { + $request = new Request(); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'BAD']); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'JWT FOO']); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer contains invalid characters such as whitespaces']); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'BearerVALID_ACCESS_TOKEN']); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer INVALID_ACCESS_TOKEN']); + yield [$request, 'Invalid access token or invalid user.', BadCredentialsException::class]; + } + + private function setUpAuthenticator(): void + { + $this->authenticator = new AccessTokenAuthenticator( + $this->userProvider, + $this->accessTokenHandler, + new ChainAccessTokenExtractor([ + new FormEncodedBodyExtractor(), + new QueryAccessTokenExtractor(), + new HeaderAccessTokenExtractor(), + ]) + ); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/Authenticator/AccessToken/FormEncodedBodyAccessTokenAuthenticatorTest.php b/src/Symfony/Component/Security/Http/Tests/Authenticator/AccessToken/FormEncodedBodyAccessTokenAuthenticatorTest.php new file mode 100644 index 0000000000000..5f251bb71f197 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Authenticator/AccessToken/FormEncodedBodyAccessTokenAuthenticatorTest.php @@ -0,0 +1,127 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Authenticator\AccessToken; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\User\InMemoryUserProvider; +use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface; +use Symfony\Component\Security\Http\AccessToken\FormEncodedBodyExtractor; +use Symfony\Component\Security\Http\Authenticator\AccessTokenAuthenticator; +use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; +use Symfony\Component\Security\Http\Tests\Authenticator\InMemoryAccessTokenHandler; + +class FormEncodedBodyAccessTokenAuthenticatorTest extends TestCase +{ + private InMemoryUserProvider $userProvider; + private AccessTokenAuthenticator $authenticator; + private AccessTokenHandlerInterface $accessTokenHandler; + + protected function setUp(): void + { + $this->userProvider = new InMemoryUserProvider(); + $this->accessTokenHandler = new InMemoryAccessTokenHandler(); + } + + public function testSupport(): void + { + $this->setUpAuthenticator(); + $request = new Request([], [], [], [], [], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']); + $request->request->set('access_token', 'INVALID_ACCESS_TOKEN'); + $request->setMethod(Request::METHOD_POST); + + $this->assertNull($this->authenticator->supports($request)); + } + + public function testSupportsWithCustomParameter(): void + { + $this->setUpAuthenticator('protection-token'); + $request = new Request([], [], [], [], [], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']); + $request->request->set('protection-token', 'INVALID_ACCESS_TOKEN'); + $request->setMethod(Request::METHOD_POST); + + $this->assertNull($this->authenticator->supports($request)); + } + + public function testAuthenticate(): void + { + $this->accessTokenHandler->add('VALID_ACCESS_TOKEN', 'foo'); + $this->setUpAuthenticator(); + $request = new Request([], [], [], [], [], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded'], 'access_token=VALID_ACCESS_TOKEN'); + $request->request->set('access_token', 'VALID_ACCESS_TOKEN'); + $request->setMethod(Request::METHOD_POST); + + $passport = $this->authenticator->authenticate($request); + $this->assertInstanceOf(SelfValidatingPassport::class, $passport); + } + + public function testAuthenticateWithCustomParameter(): void + { + $this->accessTokenHandler->add('VALID_ACCESS_TOKEN', 'foo'); + $this->setUpAuthenticator('protection-token'); + $request = new Request([], [], [], [], [], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']); + $request->request->set('protection-token', 'VALID_ACCESS_TOKEN'); + $request->setMethod(Request::METHOD_POST); + + $passport = $this->authenticator->authenticate($request); + $this->assertInstanceOf(SelfValidatingPassport::class, $passport); + } + + /** + * @dataProvider provideInvalidAuthenticateData + */ + public function testAuthenticateInvalid($request, $errorMessage, $exceptionType = BadRequestHttpException::class): void + { + $this->expectException($exceptionType); + $this->expectExceptionMessage($errorMessage); + + $this->setUpAuthenticator(); + + $this->authenticator->authenticate($request); + } + + public function provideInvalidAuthenticateData(): iterable + { + $request = new Request(); + $request->setMethod(Request::METHOD_GET); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request(); + $request->setMethod(Request::METHOD_POST); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer VALID_ACCESS_TOKEN']); + $request->setMethod(Request::METHOD_POST); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request(); + $request->setMethod(Request::METHOD_POST); + $request->request->set('foo', 'VALID_ACCESS_TOKEN'); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request([], [], [], [], [], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']); + $request->setMethod(Request::METHOD_POST); + $request->request->set('access_token', 'INVALID_ACCESS_TOKEN'); + yield [$request, 'Invalid access token or invalid user.', BadCredentialsException::class]; + } + + private function setUpAuthenticator(string $parameter = 'access_token'): void + { + $this->authenticator = new AccessTokenAuthenticator( + $this->userProvider, + $this->accessTokenHandler, + new FormEncodedBodyExtractor($parameter) + ); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/Authenticator/AccessToken/HeaderAccessTokenAuthenticatorTest.php b/src/Symfony/Component/Security/Http/Tests/Authenticator/AccessToken/HeaderAccessTokenAuthenticatorTest.php new file mode 100644 index 0000000000000..89e91e34feecf --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Authenticator/AccessToken/HeaderAccessTokenAuthenticatorTest.php @@ -0,0 +1,151 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Authenticator\AccessToken; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\User\InMemoryUserProvider; +use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface; +use Symfony\Component\Security\Http\AccessToken\HeaderAccessTokenExtractor; +use Symfony\Component\Security\Http\Authenticator\AccessTokenAuthenticator; +use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; +use Symfony\Component\Security\Http\Tests\Authenticator\InMemoryAccessTokenHandler; + +class HeaderAccessTokenAuthenticatorTest extends TestCase +{ + private InMemoryUserProvider $userProvider; + private AccessTokenAuthenticator $authenticator; + private AccessTokenHandlerInterface $accessTokenHandler; + + protected function setUp(): void + { + $this->userProvider = new InMemoryUserProvider(); + $this->accessTokenHandler = new InMemoryAccessTokenHandler(); + } + + /** + * @dataProvider provideSupportData + */ + public function testSupport($request): void + { + $this->setUpAuthenticator(); + + $this->assertNull($this->authenticator->supports($request)); + } + + public function provideSupportData(): iterable + { + yield [new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer VALID_ACCESS_TOKEN'])]; + yield [new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer INVALID_ACCESS_TOKEN'])]; + } + + /** + * @dataProvider provideSupportsWithCustomTokenTypeData + */ + public function testSupportsWithCustomTokenType($request, $result): void + { + $this->setUpAuthenticator('Authorization', 'JWT'); + + $this->assertSame($result, $this->authenticator->supports($request)); + } + + public function provideSupportsWithCustomTokenTypeData(): iterable + { + yield [new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'JWT VALID_ACCESS_TOKEN']), null]; + yield [new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'JWT INVALID_ACCESS_TOKEN']), null]; + yield [new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer VALID_ACCESS_TOKEN']), false]; + yield [new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer INVALID_ACCESS_TOKEN']), false]; + } + + /** + * @dataProvider provideSupportsWithCustomHeaderParameter + */ + public function testSupportsWithCustomHeaderParameter($request, $result): void + { + $this->setUpAuthenticator('X-FOO'); + + $this->assertSame($result, $this->authenticator->supports($request)); + } + + public function provideSupportsWithCustomHeaderParameter(): iterable + { + yield [new Request([], [], [], [], [], ['HTTP_X_FOO' => 'Bearer VALID_ACCESS_TOKEN']), null]; + yield [new Request([], [], [], [], [], ['HTTP_X_FOO' => 'Bearer INVALID_ACCESS_TOKEN']), null]; + yield [new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer VALID_ACCESS_TOKEN']), false]; + yield [new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer INVALID_ACCESS_TOKEN']), false]; + } + + public function testAuthenticate(): void + { + $this->accessTokenHandler->add('VALID_ACCESS_TOKEN', 'foo'); + $this->setUpAuthenticator(); + + $request = new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer VALID_ACCESS_TOKEN']); + $passport = $this->authenticator->authenticate($request); + $this->assertInstanceOf(SelfValidatingPassport::class, $passport); + } + + public function testAuthenticateWithCustomTokenType(): void + { + $this->accessTokenHandler->add('VALID_ACCESS_TOKEN', 'foo'); + $this->setUpAuthenticator('Authorization', 'JWT'); + + $request = new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'JWT VALID_ACCESS_TOKEN']); + $passport = $this->authenticator->authenticate($request); + $this->assertInstanceOf(SelfValidatingPassport::class, $passport); + } + + /** + * @dataProvider provideInvalidAuthenticateData + */ + public function testAuthenticateInvalid($request, $errorMessage, $exceptionType = BadRequestHttpException::class): void + { + $this->expectException($exceptionType); + $this->expectExceptionMessage($errorMessage); + + $this->setUpAuthenticator(); + + $this->authenticator->authenticate($request); + } + + public function provideInvalidAuthenticateData(): iterable + { + $request = new Request(); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'BAD']); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'JWT FOO']); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer contains invalid characters such as whitespaces']); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'BearerVALID_ACCESS_TOKEN']); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer INVALID_ACCESS_TOKEN']); + yield [$request, 'Invalid access token or invalid user.', BadCredentialsException::class]; + } + + private function setUpAuthenticator(string $headerParameter = 'Authorization', string $tokenType = 'Bearer'): void + { + $this->authenticator = new AccessTokenAuthenticator( + $this->userProvider, + $this->accessTokenHandler, + new HeaderAccessTokenExtractor($headerParameter, $tokenType) + ); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/Authenticator/AccessToken/QueryAccessTokenAuthenticatorTest.php b/src/Symfony/Component/Security/Http/Tests/Authenticator/AccessToken/QueryAccessTokenAuthenticatorTest.php new file mode 100644 index 0000000000000..c1a8206115452 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Authenticator/AccessToken/QueryAccessTokenAuthenticatorTest.php @@ -0,0 +1,119 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Authenticator\AccessToken; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\User\InMemoryUserProvider; +use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface; +use Symfony\Component\Security\Http\AccessToken\QueryAccessTokenExtractor; +use Symfony\Component\Security\Http\Authenticator\AccessTokenAuthenticator; +use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; +use Symfony\Component\Security\Http\Tests\Authenticator\InMemoryAccessTokenHandler; + +class QueryAccessTokenAuthenticatorTest extends TestCase +{ + private InMemoryUserProvider $userProvider; + private AccessTokenAuthenticator $authenticator; + private AccessTokenHandlerInterface $accessTokenHandler; + + protected function setUp(): void + { + $this->userProvider = new InMemoryUserProvider(); + $this->accessTokenHandler = new InMemoryAccessTokenHandler(); + } + + public function testSupport(): void + { + $this->setUpAuthenticator(); + $request = new Request(); + $request->query->set('access_token', 'INVALID_ACCESS_TOKEN'); + + $this->assertNull($this->authenticator->supports($request)); + } + + public function testSupportsWithCustomParameter(): void + { + $this->setUpAuthenticator('protection-token'); + $request = new Request(); + $request->query->set('protection-token', 'INVALID_ACCESS_TOKEN'); + + $this->assertNull($this->authenticator->supports($request)); + } + + public function testAuthenticate(): void + { + $this->accessTokenHandler->add('VALID_ACCESS_TOKEN', 'foo'); + $this->setUpAuthenticator(); + $request = new Request(); + $request->query->set('access_token', 'VALID_ACCESS_TOKEN'); + + $passport = $this->authenticator->authenticate($request); + $this->assertInstanceOf(SelfValidatingPassport::class, $passport); + } + + public function testAuthenticateWithCustomParameter(): void + { + $this->accessTokenHandler->add('VALID_ACCESS_TOKEN', 'foo'); + $this->setUpAuthenticator('protection-token'); + $request = new Request(); + $request->query->set('protection-token', 'VALID_ACCESS_TOKEN'); + + $passport = $this->authenticator->authenticate($request); + $this->assertInstanceOf(SelfValidatingPassport::class, $passport); + } + + /** + * @dataProvider provideInvalidAuthenticateData + */ + public function testAuthenticateInvalid($request, $errorMessage, $exceptionType = BadRequestHttpException::class): void + { + $this->expectException($exceptionType); + $this->expectExceptionMessage($errorMessage); + + $this->setUpAuthenticator(); + + $this->authenticator->authenticate($request); + } + + public function provideInvalidAuthenticateData(): iterable + { + $request = new Request(); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request([], [], [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer VALID_ACCESS_TOKEN']); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request(); + $request->query->set('foo', 'VALID_ACCESS_TOKEN'); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request(); + $request->query->set('access_token', 123456789); + yield [$request, 'Invalid credentials.', BadCredentialsException::class]; + + $request = new Request(); + $request->query->set('access_token', 'INVALID_ACCESS_TOKEN'); + yield [$request, 'Invalid access token or invalid user.', BadCredentialsException::class]; + } + + private function setUpAuthenticator(string $parameter = 'access_token'): void + { + $this->authenticator = new AccessTokenAuthenticator( + $this->userProvider, + $this->accessTokenHandler, + new QueryAccessTokenExtractor($parameter) + ); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/Authenticator/InMemoryAccessTokenHandler.php b/src/Symfony/Component/Security/Http/Tests/Authenticator/InMemoryAccessTokenHandler.php new file mode 100644 index 0000000000000..9ace3ba8324a7 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Authenticator/InMemoryAccessTokenHandler.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Authenticator; + +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface; + +class InMemoryAccessTokenHandler implements AccessTokenHandlerInterface +{ + /** + * @var array + */ + private $accessTokens = []; + + public function getUserIdentifierFrom(string $accessToken): string + { + if (!\array_key_exists($accessToken, $this->accessTokens)) { + throw new BadCredentialsException('Invalid access token or invalid user.'); + } + + return $this->accessTokens[$accessToken]; + } + + public function remove(string $accessToken): self + { + unset($this->accessTokens[$accessToken]); + + return $this; + } + + public function add(string $accessToken, string $user): self + { + $this->accessTokens[$accessToken] = $user; + + return $this; + } +}