Skip to content

Commit dfcf900

Browse files
committed
feature #46428 [Security] Access Token Authenticator (Spomky)
This PR was merged into the 6.2 branch. Discussion ---------- [Security] Access Token Authenticator | Q | A | ------------- | --- | Branch? | 6.2 | Bug fix? | yes | New feature? | yes<!-- please update src/**/CHANGELOG.md files --> | Deprecations? | no | Tickets | Fix #45844 | License | MIT | Doc PR | symfony/symfony-docs#16819 Hi, This PR aims at fixing #45844. It adds a new authenticator that is able to fetch a token in the request header and retrieve the associated user identifier. The authenticator delegates the token loading to a handler. This handler could manage opaque tokens (random strings stored in a database) or self-contained tokens such as JWT, Paseto, SAML... * [x] [RFC6750, section 2](https://datatracker.ietf.org/doc/html/rfc6750#section-2): Authenticated Requests * [x] Token in the request header ([section 2.1](https://datatracker.ietf.org/doc/html/rfc6750#section-2.1)) * [x] Token in the query string ([section 2.2](https://datatracker.ietf.org/doc/html/rfc6750#section-2.2)) * [x] Token in the request body ([section 2.3](https://datatracker.ietf.org/doc/html/rfc6750#section-2.3)) * [x] [RFC6750, section 3](https://datatracker.ietf.org/doc/html/rfc6750#section-3): The WWW-Authenticate Response Header Field * [x] [RFC6750, section 3.1](https://datatracker.ietf.org/doc/html/rfc6750#section-3.1): Error Codes * [x] Documentation: see symfony/symfony-docs#16819 * [x] Tests # Firewall Configuration This PR adds a new authenticator that covers the RFC6750: `access_token`. Also, it adds the possibility to extract the token from anywhere in the request. ## Basic Configuration ```yaml security: firewalls: main: pattern: ^/ access_token: token_handler: access_token.access_token_handler ``` ## Complete Configuration ```yaml security: firewalls: main: pattern: ^/ access_token: user_provider: 'dedicate_user_provider_for_this_firewall' success_handler: 'custom_success_handler' failure_handler: 'custom_failure_handler' 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' - 'custom_access_token_extractor' ``` # Token Handler This authenticator relies on a Token Handler. Its responsability is to * load the token * check the token (revocation, expiration time, digital signature...) * return the user ID associated to it Tokens could be of any kind: opaque strings or self-contained tokens such as JWT, Paseto, SAML2... ## Example: from a repository ```php <?php namespace App\Security; use App\Repository\AccessTokenRepository; use Symfony\Component\Security\Core\Exception\BadCredentialsException; use Symfony\Component\Security\Http\Authenticator\AccessTokenHandler as AccessTokenHandlerAliasInterface; class AccessTokenHandler implements AccessTokenHandlerAliasInterface { public function __construct(private readonly AccessTokenRepository $repository) { } public function getUserIdentifierFrom(string $token): string { $accessToken = $this->repository->findOneByValue($token); if ($accessToken === null || !$accessToken->isValid()) { throw new BadCredentialsException('Invalid credentials.'); } return $accessToken->getUserId(); } } ``` ## Example: from a JWT ```php <?php namespace App\Security; use App\Security\JWTLoader; use App\Security\JWTValidator; use Symfony\Component\Security\Core\Exception\BadCredentialsException; use Symfony\Component\Security\Http\Authenticator\AccessTokenHandler as AccessTokenHandlerAliasInterface; class AccessTokenHandler implements AccessTokenHandlerAliasInterface { public function __construct( private readonly JWTLoader $loader, private readonly JWTValidator $validator ) { } public function getUserIdentifierFrom(string $token): string { try { $token = $this->loader->loadJWT($token); $this->validator->validate($token); return $token->getClaim('sub'); } catch (\Throwable $e) { throw new BadCredentialsException('Invalid credentials.', $e->getCode, $e); } } } ``` Commits ------- e5873e8 [Security] Access Token Authenticator
2 parents 1426bba + e5873e8 commit dfcf900

39 files changed

+1994
-1
lines changed

src/Symfony/Bundle/SecurityBundle/CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ CHANGELOG
1010
* Add `Security::login()` to login programmatically
1111
* Add `Security::logout()` to logout programmatically
1212
* Add `security.firewalls.logout.enable_csrf` to enable CSRF protection using the default CSRF token generator
13+
* Add RFC6750 Access Token support to allow token-based authentication
1314

1415
6.1
1516
---
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory;
13+
14+
use Symfony\Component\Config\Definition\Builder\NodeDefinition;
15+
use Symfony\Component\DependencyInjection\ChildDefinition;
16+
use Symfony\Component\DependencyInjection\ContainerBuilder;
17+
use Symfony\Component\DependencyInjection\Reference;
18+
19+
/**
20+
* AccessTokenFactory creates services for Access Token authentication.
21+
*
22+
* @author Florent Morselli <florent.morselli@spomky-labs.com>
23+
*
24+
* @internal
25+
*/
26+
final class AccessTokenFactory extends AbstractFactory
27+
{
28+
private const PRIORITY = -40;
29+
30+
public function __construct()
31+
{
32+
$this->options = [];
33+
$this->defaultFailureHandlerOptions = [];
34+
$this->defaultSuccessHandlerOptions = [];
35+
}
36+
37+
public function addConfiguration(NodeDefinition $node): void
38+
{
39+
$builder = $node->children();
40+
41+
$builder
42+
->scalarNode('token_handler')->isRequired()->end()
43+
->scalarNode('user_provider')->defaultNull()->end()
44+
->scalarNode('realm')->defaultNull()->end()
45+
->scalarNode('success_handler')->defaultNull()->end()
46+
->scalarNode('failure_handler')->defaultNull()->end()
47+
->arrayNode('token_extractors')
48+
->fixXmlConfig('token_extractors')
49+
->beforeNormalization()
50+
->ifString()
51+
->then(static function (string $v): array { return [$v]; })
52+
->end()
53+
->cannotBeEmpty()
54+
->defaultValue([
55+
'security.access_token_extractor.header',
56+
])
57+
->scalarPrototype()->end()
58+
->end()
59+
;
60+
}
61+
62+
public function getPriority(): int
63+
{
64+
return self::PRIORITY;
65+
}
66+
67+
/**
68+
* {@inheritdoc}
69+
*/
70+
public function getKey(): string
71+
{
72+
return 'access_token';
73+
}
74+
75+
public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId): string
76+
{
77+
$userProvider = new Reference($config['user_provider'] ?? $userProviderId);
78+
$successHandler = isset($config['success_handler']) ? new Reference($this->createAuthenticationSuccessHandler($container, $firewallName, $config)) : null;
79+
$failureHandler = isset($config['failure_handler']) ? new Reference($this->createAuthenticationFailureHandler($container, $firewallName, $config)) : null;
80+
$authenticatorId = sprintf('security.authenticator.access_token.%s', $firewallName);
81+
$extractorId = $this->createExtractor($container, $firewallName, $config['token_extractors']);
82+
83+
$container
84+
->setDefinition($authenticatorId, new ChildDefinition('security.authenticator.access_token'))
85+
->replaceArgument(0, $userProvider)
86+
->replaceArgument(1, new Reference($config['token_handler']))
87+
->replaceArgument(2, new Reference($extractorId))
88+
->replaceArgument(3, $successHandler)
89+
->replaceArgument(4, $failureHandler)
90+
->replaceArgument(5, $config['realm'])
91+
;
92+
93+
return $authenticatorId;
94+
}
95+
96+
/**
97+
* @param array<string> $extractors
98+
*/
99+
private function createExtractor(ContainerBuilder $container, string $firewallName, array $extractors): string
100+
{
101+
$aliases = [
102+
'query_string' => 'security.access_token_extractor.query_string',
103+
'request_body' => 'security.access_token_extractor.request_body',
104+
'header' => 'security.access_token_extractor.header',
105+
];
106+
$extractors = array_map(static function (string $extractor) use ($aliases): string {
107+
return $aliases[$extractor] ?? $extractor;
108+
}, $extractors);
109+
110+
if (1 === \count($extractors)) {
111+
return current($extractors);
112+
}
113+
$extractorId = sprintf('security.authenticator.access_token.chain_extractor.%s', $firewallName);
114+
$container
115+
->setDefinition($extractorId, new ChildDefinition('security.authenticator.access_token.chain_extractor'))
116+
->replaceArgument(0, array_map(function (string $extractorId): Reference {return new Reference($extractorId); }, $extractors))
117+
;
118+
119+
return $extractorId;
120+
}
121+
}

src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php

+1
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ public function load(array $configs, ContainerBuilder $container)
9898
}
9999

100100
$loader->load('security_authenticator.php');
101+
$loader->load('security_authenticator_access_token.php');
101102

102103
if ($container::willBeAvailable('symfony/twig-bridge', LogoutUrlExtension::class, ['symfony/security-bundle'])) {
103104
$loader->load('templating_twig.php');
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
13+
14+
use Symfony\Component\Security\Http\AccessToken\ChainAccessTokenExtractor;
15+
use Symfony\Component\Security\Http\AccessToken\FormEncodedBodyExtractor;
16+
use Symfony\Component\Security\Http\AccessToken\HeaderAccessTokenExtractor;
17+
use Symfony\Component\Security\Http\AccessToken\QueryAccessTokenExtractor;
18+
use Symfony\Component\Security\Http\Authenticator\AccessTokenAuthenticator;
19+
20+
return static function (ContainerConfigurator $container) {
21+
$container->services()
22+
->set('security.access_token_extractor.header', HeaderAccessTokenExtractor::class)
23+
->set('security.access_token_extractor.query_string', QueryAccessTokenExtractor::class)
24+
->set('security.access_token_extractor.request_body', FormEncodedBodyExtractor::class)
25+
26+
->set('security.authenticator.access_token', AccessTokenAuthenticator::class)
27+
->abstract()
28+
->args([
29+
abstract_arg('user provider'),
30+
abstract_arg('access token handler'),
31+
abstract_arg('access token extractor'),
32+
null,
33+
null,
34+
null,
35+
])
36+
->call('setTranslator', [service('translator')->ignoreOnInvalid()])
37+
38+
->set('security.authenticator.access_token.chain_extractor', ChainAccessTokenExtractor::class)
39+
->abstract()
40+
->args([
41+
abstract_arg('access token extractors'),
42+
])
43+
;
44+
};

src/Symfony/Bundle/SecurityBundle/SecurityBundle.php

+2
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterTokenUsageTrackingPass;
2323
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\ReplaceDecoratedRememberMeHandlerPass;
2424
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\SortFirewallListenersPass;
25+
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\AccessTokenFactory;
2526
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\CustomAuthenticatorFactory;
2627
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\FormLoginFactory;
2728
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\FormLoginLdapFactory;
@@ -69,6 +70,7 @@ public function build(ContainerBuilder $container)
6970
$extension->addAuthenticatorFactory(new CustomAuthenticatorFactory());
7071
$extension->addAuthenticatorFactory(new LoginThrottlingFactory());
7172
$extension->addAuthenticatorFactory(new LoginLinkFactory());
73+
$extension->addAuthenticatorFactory(new AccessTokenFactory());
7274

7375
$extension->addUserProviderFactory(new InMemoryFactory());
7476
$extension->addUserProviderFactory(new LdapFactory());
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Bundle\SecurityBundle\Tests\DependencyInjection\Security\Factory;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\AccessTokenFactory;
16+
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
17+
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
18+
use Symfony\Component\DependencyInjection\ContainerBuilder;
19+
20+
class AccessTokenFactoryTest extends TestCase
21+
{
22+
public function testBasicServiceConfiguration()
23+
{
24+
$container = new ContainerBuilder();
25+
$config = [
26+
'token_handler' => 'in_memory_token_handler_service_id',
27+
'success_handler' => 'success_handler_service_id',
28+
'failure_handler' => 'failure_handler_service_id',
29+
'token_extractors' => ['BAR', 'FOO'],
30+
];
31+
32+
$factory = new AccessTokenFactory();
33+
$finalizedConfig = $this->processConfig($config, $factory);
34+
35+
$factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider');
36+
37+
$this->assertTrue($container->hasDefinition('security.authenticator.access_token.firewall1'));
38+
}
39+
40+
public function testDefaultServiceConfiguration()
41+
{
42+
$container = new ContainerBuilder();
43+
$config = [
44+
'token_handler' => 'in_memory_token_handler_service_id',
45+
];
46+
47+
$factory = new AccessTokenFactory();
48+
$finalizedConfig = $this->processConfig($config, $factory);
49+
50+
$factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider');
51+
52+
$this->assertTrue($container->hasDefinition('security.authenticator.access_token.firewall1'));
53+
}
54+
55+
public function testNoExtractorsDefined()
56+
{
57+
$this->expectException(InvalidConfigurationException::class);
58+
$this->expectExceptionMessage('The path "access_token.token_extractors" should have at least 1 element(s) defined.');
59+
$config = [
60+
'token_handler' => 'in_memory_token_handler_service_id',
61+
'success_handler' => 'success_handler_service_id',
62+
'failure_handler' => 'failure_handler_service_id',
63+
'token_extractors' => [],
64+
];
65+
66+
$factory = new AccessTokenFactory();
67+
$this->processConfig($config, $factory);
68+
}
69+
70+
public function testNoHandlerDefined()
71+
{
72+
$this->expectException(InvalidConfigurationException::class);
73+
$this->expectExceptionMessage('The child config "token_handler" under "access_token" must be configured.');
74+
$config = [
75+
'success_handler' => 'success_handler_service_id',
76+
'failure_handler' => 'failure_handler_service_id',
77+
];
78+
79+
$factory = new AccessTokenFactory();
80+
$this->processConfig($config, $factory);
81+
}
82+
83+
private function processConfig(array $config, AccessTokenFactory $factory)
84+
{
85+
$nodeDefinition = new ArrayNodeDefinition('access_token');
86+
$factory->addConfiguration($nodeDefinition);
87+
88+
$node = $nodeDefinition->getNode();
89+
$normalizedConfig = $node->normalize($config);
90+
91+
return $node->finalize($normalizedConfig);
92+
}
93+
}

0 commit comments

Comments
 (0)