Skip to content

Commit 2248639

Browse files
committed
feature #37846 [Security] Lazily load the user during the check passport event (wouterj)
This PR was merged into the 5.2-dev branch. Discussion ---------- [Security] Lazily load the user during the check passport event | Q | A | ------------- | --- | Branch? | master | Bug fix? | no | New feature? | yes | Deprecations? | yes | Tickets | Fix #37436 | License | MIT | Doc PR | tbd **Before** ```php class ApiKeyAuthenticator extends AbstractAuthenticator { // ... public function authenticate(Request $request): PassportInterface { $email = $request->headers->get('X-USER-EMAIL'); if (false === strpos($email, '@')) { throw new BadCredentialsException('Email is not a valid email address.'); } $user = $this->userRepository->findOneBy(['email' => $email]); if (null === $user) { throw new UsernameNotFoundException(); } return new SelfValidatingPassport($user); } } ``` **After** ```php class ApiKeyAuthenticator extends AbstractAuthenticator { // ... public function authenticate(Request $request): PassportInterface { $email = $request->headers->get('X-USER-EMAIL'); if (false === strpos($email, '@')) { throw new BadCredentialsException('Email is not a valid email address.'); } // a global ChainUserProvider (or firewall provider if explicitly configured) will be // used to load the User with $email as username return new SelfValidatingPassport($email); // or a custom closure to load the user return new SelfValidatingPassport(new UserBadge($email, function ($username) { return $this->userRepository->findOneBy(['email' => $username]); }); } } ``` Doing it this way has a couple advantages (some of which are already mentioned in the issue): * Some listeners on `CheckPassportEvent` need to execute *before* loading the user - to reduce resources (e.g. CSRF protection, if CSRF fails, no DB call should be made to load user - and also login throttling); * Some listeners require knowing the username of the login action (e.g. login throttling on IP and username); * The `UserProviderListener` allows to remove yet another centralized action in the authentication process from the authenticator class to the Symfony framework. Automatic User Provider integration --- Instead of passing the credentials and a closure to `UserBadge`, you can also just pass a (string) username. The user provider will then be used to load the user. This only works for `custom_authenticators` as of this moment. * By default, a chain user provider with all configured `user_providers` will be used as the user provider; * However, if you explicitly configure a `provider` for that firewall, that provider will be used (using a listener with higher priority). Commits ------- 907ef31 Lazily load the user during the check passport event
2 parents 53a8f7d + 907ef31 commit 2248639

35 files changed

+570
-88
lines changed

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
use Symfony\Component\Security\Core\User\UserProviderInterface;
4242
use Symfony\Component\Security\Http\Controller\UserValueResolver;
4343
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
44+
use Symfony\Component\Security\Http\Event\CheckPassportEvent;
4445
use Twig\Extension\AbstractExtension;
4546

4647
/**
@@ -342,6 +343,12 @@ private function createFirewall(ContainerBuilder $container, string $id, array $
342343
throw new InvalidConfigurationException(sprintf('Invalid firewall "%s": user provider "%s" not found.', $id, $firewall['provider']));
343344
}
344345
$defaultProvider = $providerIds[$normalizedName];
346+
347+
if ($this->authenticatorManagerEnabled) {
348+
$container->setDefinition('security.listener.'.$id.'.user_provider', new ChildDefinition('security.listener.user_provider.abstract'))
349+
->addTag('kernel.event_listener', ['event' => CheckPassportEvent::class, 'priority' => 2048, 'method' => 'checkPassport'])
350+
->replaceArgument(0, new Reference($defaultProvider));
351+
}
345352
} elseif (1 === \count($providerIds)) {
346353
$defaultProvider = reset($providerIds);
347354
}
@@ -632,7 +639,7 @@ private function getUserProvider(ContainerBuilder $container, string $id, array
632639
return $userProvider;
633640
}
634641

635-
if ('remember_me' === $factoryKey || 'anonymous' === $factoryKey) {
642+
if ('remember_me' === $factoryKey || 'anonymous' === $factoryKey || 'custom_authenticators' === $factoryKey) {
636643
return 'security.user_providers';
637644
}
638645

src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@
2323
use Symfony\Component\Security\Http\Authenticator\RememberMeAuthenticator;
2424
use Symfony\Component\Security\Http\Authenticator\RemoteUserAuthenticator;
2525
use Symfony\Component\Security\Http\Authenticator\X509Authenticator;
26+
use Symfony\Component\Security\Http\Event\CheckPassportEvent;
2627
use Symfony\Component\Security\Http\EventListener\CheckCredentialsListener;
2728
use Symfony\Component\Security\Http\EventListener\PasswordMigratingListener;
2829
use Symfony\Component\Security\Http\EventListener\RememberMeListener;
2930
use Symfony\Component\Security\Http\EventListener\SessionStrategyListener;
3031
use Symfony\Component\Security\Http\EventListener\UserCheckerListener;
32+
use Symfony\Component\Security\Http\EventListener\UserProviderListener;
3133
use Symfony\Component\Security\Http\Firewall\AuthenticatorManagerListener;
3234

3335
return static function (ContainerConfigurator $container) {
@@ -73,6 +75,18 @@
7375
])
7476
->tag('kernel.event_subscriber')
7577

78+
->set('security.listener.user_provider', UserProviderListener::class)
79+
->args([
80+
service('security.user_providers'),
81+
])
82+
->tag('kernel.event_listener', ['event' => CheckPassportEvent::class, 'priority' => 1024, 'method' => 'checkPassport'])
83+
84+
->set('security.listener.user_provider.abstract', UserProviderListener::class)
85+
->abstract()
86+
->args([
87+
abstract_arg('user provider'),
88+
])
89+
7690
->set('security.listener.password_migrating', PasswordMigratingListener::class)
7791
->args([
7892
service('security.encoder_factory'),
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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\Functional;
13+
14+
class AuthenticatorTest extends AbstractWebTestCase
15+
{
16+
/**
17+
* @dataProvider provideEmails
18+
*/
19+
public function testGlobalUserProvider($email)
20+
{
21+
$client = $this->createClient(['test_case' => 'Authenticator', 'root_config' => 'implicit_user_provider.yml']);
22+
23+
$client->request('GET', '/profile', [], [], [
24+
'HTTP_X-USER-EMAIL' => $email,
25+
]);
26+
$this->assertJsonStringEqualsJsonString('{"email":"'.$email.'"}', $client->getResponse()->getContent());
27+
}
28+
29+
/**
30+
* @dataProvider provideEmails
31+
*/
32+
public function testFirewallUserProvider($email, $withinFirewall)
33+
{
34+
$client = $this->createClient(['test_case' => 'Authenticator', 'root_config' => 'firewall_user_provider.yml']);
35+
36+
$client->request('GET', '/profile', [], [], [
37+
'HTTP_X-USER-EMAIL' => $email,
38+
]);
39+
40+
if ($withinFirewall) {
41+
$this->assertJsonStringEqualsJsonString('{"email":"'.$email.'"}', $client->getResponse()->getContent());
42+
} else {
43+
$this->assertJsonStringEqualsJsonString('{"error":"Username could not be found."}', $client->getResponse()->getContent());
44+
}
45+
}
46+
47+
public function provideEmails()
48+
{
49+
yield ['jane@example.org', true];
50+
yield ['john@example.org', false];
51+
}
52+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
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\Functional\Bundle\AuthenticatorBundle;
13+
14+
use Symfony\Component\HttpFoundation\JsonResponse;
15+
use Symfony\Component\HttpFoundation\Request;
16+
use Symfony\Component\HttpFoundation\Response;
17+
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
18+
use Symfony\Component\Security\Core\Exception\AuthenticationException;
19+
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
20+
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
21+
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
22+
use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface;
23+
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
24+
25+
class ApiAuthenticator extends AbstractAuthenticator
26+
{
27+
public function supports(Request $request): ?bool
28+
{
29+
return $request->headers->has('X-USER-EMAIL');
30+
}
31+
32+
public function authenticate(Request $request): PassportInterface
33+
{
34+
$email = $request->headers->get('X-USER-EMAIL');
35+
if (false === strpos($email, '@')) {
36+
throw new BadCredentialsException('Email is not a valid email address.');
37+
}
38+
39+
return new SelfValidatingPassport(new UserBadge($email));
40+
}
41+
42+
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
43+
{
44+
return null;
45+
}
46+
47+
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
48+
{
49+
return new JsonResponse([
50+
'error' => $exception->getMessageKey(),
51+
], JsonResponse::HTTP_FORBIDDEN);
52+
}
53+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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\Functional\Bundle\AuthenticatorBundle;
13+
14+
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
15+
16+
class ProfileController extends AbstractController
17+
{
18+
public function __invoke()
19+
{
20+
$this->denyAccessUnlessGranted('ROLE_USER');
21+
22+
return $this->json(['email' => $this->getUser()->getUsername()]);
23+
}
24+
}

src/Symfony/Bundle/SecurityBundle/Tests/Functional/CsrfFormLoginTest.php

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,6 @@ public function testFormLoginWithInvalidCsrfToken($options)
5151
$client = $this->createClient($options);
5252

5353
$form = $client->request('GET', '/login')->selectButton('login')->form();
54-
if ($options['enable_authenticator_manager'] ?? false) {
55-
$form['user_login[username]'] = 'johannes';
56-
$form['user_login[password]'] = 'test';
57-
}
5854
$form['user_login[_token]'] = '';
5955
$client->submit($form);
6056

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
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+
return [
13+
new Symfony\Bundle\FrameworkBundle\FrameworkBundle(),
14+
new Symfony\Bundle\SecurityBundle\SecurityBundle(),
15+
];
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
framework:
2+
secret: test
3+
router: { resource: "%kernel.project_dir%/%kernel.test_case%/routing.yml", utf8: true }
4+
test: ~
5+
default_locale: en
6+
profiler: false
7+
session:
8+
storage_id: session.storage.mock_file
9+
10+
services:
11+
logger: { class: Psr\Log\NullLogger }
12+
Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AuthenticatorBundle\ProfileController:
13+
public: true
14+
calls:
15+
- ['setContainer', ['@Psr\Container\ContainerInterface']]
16+
tags: [container.service_subscriber]
17+
Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AuthenticatorBundle\ApiAuthenticator: ~
18+
19+
security:
20+
enable_authenticator_manager: true
21+
22+
encoders:
23+
Symfony\Component\Security\Core\User\User: plaintext
24+
25+
providers:
26+
in_memory:
27+
memory:
28+
users:
29+
'jane@example.org': { password: test, roles: [ROLE_USER] }
30+
in_memory2:
31+
memory:
32+
users:
33+
'john@example.org': { password: test, roles: [ROLE_USER] }
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
imports:
2+
- { resource: ./config.yml }
3+
4+
security:
5+
firewalls:
6+
api:
7+
pattern: /
8+
provider: in_memory
9+
custom_authenticator: Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AuthenticatorBundle\ApiAuthenticator
10+
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
imports:
2+
- { resource: ./config.yml }
3+
4+
security:
5+
firewalls:
6+
api:
7+
pattern: /
8+
custom_authenticator: Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AuthenticatorBundle\ApiAuthenticator
9+

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy