8000 feature #41274 [Security] Add a method in the security helper to ease… · symfony/symfony@6243772 · GitHub
[go: up one dir, main page]

Skip to content

Commit 6243772

Browse files
committed
feature #41274 [Security] Add a method in the security helper to ease programmatic login (#40662) (johnkrovitch, chalasr)
This PR was merged into the 6.2 branch. Discussion ---------- [Security] Add a method in the security helper to ease programmatic login (#40662) | Q | A | ------------- | --- | Branch? | 6.x | Bug fix? | no | New feature? | yes | Deprecations? | no | Tickets | Fix #40662 | License | MIT | Doc PR | This PR aims to ease the programmatic login using the Security helper, to fix (#40662). A simple method has been added to the Security helper. It take a user and an optional Authenticator. If no authenticator is passed, we find all authenticators for the current firewall. Then if only one is matching we use this one. If several authenticators are found, an exception is thrown to avoid any magic (by choosing the first for example), the user HAS to provide an authenticator. Thanks Commits ------- 37efa72 Add functional test & fix reviews d7724d2 [Security] Add a method in the security helper to ease programmatic login (#40662)
2 parents 2e824e0 + 37efa72 commit 6243772

File tree

8 files changed

+197
-10
lines changed

8 files changed

+197
-10
lines changed

src/Symfony/Bundle/SecurityBundle/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ CHANGELOG
77
* Add the `Security` helper class
88
* Deprecate the `Symfony\Component\Security\Core\Security` service alias, use `Symfony\Bundle\SecurityBundle\Security\Security` instead
99
* Add `Security::getFirewallConfig()` to help to get the firewall configuration associated to the Request
10+
* Add `Security::login()` to login programmatically
1011

1112
6.1
1213
---

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

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -277,16 +277,25 @@ private function createFirewalls(array $config, ContainerBuilder $container)
277277

278278
// load firewall map
279279
$mapDef = $container->getDefinition('security.firewall.map');
280-
$map = $authenticationProviders = $contextRefs = [];
280+
$map = $authenticationProviders = $contextRefs = $authenticators = [];
281281
foreach ($firewalls as $name => $firewall) {
282282
if (isset($firewall['user_checker']) && 'security.user_checker' !== $firewall['user_checker']) {
283283
$customUserChecker = true;
284284
}
285285

286286
$configId = 'security.firewall.map.config.'.$name;
287287

288-
[$matcher, $listeners, $exceptionListener, $logoutListener] = $this->createFirewall($container, $name, $firewall, $authenticationProviders, $providerIds, $configId);
288+
[$matcher, $listeners, $exceptionListener, $logoutListener, $firewallAuthenticators] = $this->createFirewall($container, $name, $firewall, $authenticationProviders, $providerIds, $configId);
289289

290+
if (!$firewallAuthenticators) {
291+
$authenticators[$name] = null;
292+
} else {
293+
$firewallAuthenticatorRefs = [];
294+
foreach ($firewallAuthenticators as $authenticatorId) {
295+
$firewallAuthenticatorRefs[$authenticatorId] = new Reference($authenticatorId);
296+
}
297+
$authenticators[$name] = ServiceLocatorTagPass::register($container, $firewallAuthenticatorRefs);
298+
}
290299
$contextId = 'security.firewall.map.context.'.$name;
291300
$isLazy = !$firewall['stateless'] && (!empty($firewall['anonymous']['lazy']) || $firewall['lazy']);
292301
$context = new ChildDefinition($isLazy ? 'security.firewall.lazy_context' : 'security.firewall.context');
@@ -301,6 +310,10 @@ private function createFirewalls(array $config, ContainerBuilder $container)
301310
$contextRefs[$contextId] = new Reference($contextId);
302311
$map[$contextId] = $matcher;
303312
}
313+
$container
314+
->getDefinition('security.helper')
315+
->replaceArgument(1, $authenticators)
316+
;
304317

305318
$container->setAlias('security.firewall.context_locator', (string) ServiceLocatorTagPass::register($container, $contextRefs));
306319

@@ -335,7 +348,7 @@ private function createFirewall(ContainerBuilder $container, string $id, array $
335348

336349
// Security disabled?
337350
if (false === $firewall['security']) {
338-
return [$matcher, [], null, null];
351+
return [$matcher, [], null, null, []];
339352
}
340353

341354
$config->replaceArgument(4, $firewall['stateless']);
@@ -528,7 +541,7 @@ private function createFirewall(ContainerBuilder $container, string $id, array $
528541
$config->replaceArgument(10, $listenerKeys);
529542
$config->replaceArgument(11, $firewall['switch_user'] ?? null);
530543

531-
return [$matcher, $listeners, $exceptionListener, null !== $logoutListenerId ? new Reference($logoutListenerId) : null];
544+
return [$matcher, $listeners, $exceptionListener, null !== $logoutListenerId ? new Reference($logoutListenerId) : null, $firewallAuthenticationProviders];
532545
}
533546

534547
private function createContextListener(ContainerBuilder $container, string $contextKey, ?string $firewallEventDispatcherId)

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

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,17 @@
7777
->set('security.untracked_token_storage', TokenStorage::class)
7878

7979
->set('security.helper', Security::class)
80-
->args([service_locator([
81-
'security.token_storage' => service('security.token_storage'),
82-
'security.authorization_checker' => service('security.authorization_checker'),
83-
'security.firewall.map' => service('security.firewall.map'),
84-
])])
80+
->args([
81+
service_locator([
82+
'security.token_storage' => service('security.token_storage'),
83+
'security.authorization_checker' => service('security.authorization_checker'),
84+
'security.user_authenticator' => service('security.user_authenticator')->ignoreOnInvalid(),
85+
'request_stack' => service('request_stack'),
86+
'security.firewall.map' => service('security.firewall.map'),
87+
'security.user_checker' => service('security.user_checker'),
88+
]),
89+
abstract_arg('authenticators'),
90+
])
8591
->alias(Security::class, 'security.helper')
8692
->alias(LegacySecurity::class, 'security.helper')
8793
->deprecate('symfony/security-bundle', '6.2', 'The "%alias_id%" service alias is deprecated, use "'.Security::class.'" instead.')

src/Symfony/Bundle/SecurityBundle/Security/Security.php

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,24 @@
1313

1414
use Psr\Container\ContainerInterface;
1515
use Symfony\Component\HttpFoundation\Request;
16+
use Symfony\Component\Security\Core\Exception\LogicException;
1617
use Symfony\Component\Security\Core\Security as LegacySecurity;
18+
use Symfony\Component\Security\Core\User\UserInterface;
19+
use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface;
20+
use Symfony\Contracts\Service\ServiceProviderInterface;
1721

1822
/**
1923
* Helper class for commonly-needed security tasks.
2024
*
25+
* @author Ryan Weaver <ryan@symfonycasts.com>
26+
* @author Robin Chalas <robin.chalas@gmail.com>
27+
* @author Arnaud Frézet <arnaud@larriereguichet.fr>
28+
*
2129
* @final
2230
*/
2331
class Security extends LegacySecurity
2432
{
25-
public function __construct(private ContainerInterface $container)
33+
public function __construct(private readonly ContainerInterface $container, private readonly array $authenticators = [])
2634
{
2735
parent::__construct($container, false);
2836
}
@@ -31,4 +39,59 @@ public function getFirewallConfig(Request $request): ?FirewallConfig
3139
{
3240
return $this->container->get('security.firewall.map')->getFirewallConfig($request);
3341
}
42+
43+
/**
44+
* @param UserInterface $user The user to authenticate
45+
* @param string|null $authenticatorName The authenticator name (e.g. "form_login") or service id (e.g. SomeApiKeyAuthenticator::class) - required only if multiple authenticators are configured
46+
* @param string|null $firewallName The firewall name - required only if multiple firewalls are configured
47+
*/
48+
public function login(UserInterface $user, string $authenticatorName = null, string $firewallName = null): void
49+
{
50+
$request = $this->container->get('request_stack')->getCurrentRequest();
51+
$firewallName ??= $this->getFirewallConfig($request)?->getName();
52+
53+
if (!$firewallName) {
54+
throw new LogicException('Unable to login as the current route is not covered by any firewall.');
55+
}
56+
57+
$authenticator = $this->getAuthenticator($authenticatorName, $firewallName);
58+
59+
$this->container->get('security.user_checker')->checkPreAuth($user);
60+
$this->container->get('security.user_authenticator')->authenticateUser($user, $authenticator, $request);
61+
}
62+
63+
private function getAuthenticator(?string $authenticatorName, string $firewallName): AuthenticatorInterface
64+
{
65+
if (!\array_key_exists($firewallName, $this->authenticators)) {
66+
throw new LogicException(sprintf('No authenticators found for firewall "%s".', $firewallName));
67+
}
68+
69+
/** @var ServiceProviderInterface $firewallAuthenticatorLocator */
70+
$firewallAuthenticatorLocator = $this->authenticators[$firewallName];
71+
72+
if (!$authenticatorName) {
73+
$authenticatorIds = array_keys($firewallAuthenticatorLocator->getProvidedServices());
74+
75+
if (!$authenticatorIds) {
76+
throw new LogicException(sprintf('No authenticator was found for the firewall "%s".', $firewallName));
77+
}
78+
if (1 < \count($authenticatorIds)) {
79+
throw new LogicException(sprintf('Too much authenticators were found for the current firewall "%s". You must provide an instance of "%s" to login programmatically. The available authenticators for the firewall "%s" are "%s".', $firewallName, AuthenticatorInterface::class, $firewallName, implode('" ,"', $authenticatorIds)));
80+
}
81+
82+
return $firewallAuthenticatorLocator->get($authenticatorIds[0]);
83+
}
84+
85+
if ($firewallAuthenticatorLocator->has($authenticatorName)) {
86+
return $firewallAuthenticatorLocator->get($authenticatorName);
87+
}
88+
89+
$authenticatorId = 'security.authenticator.'.$authenticatorName.'.'.$firewallName;
90+
91+
if (!$firewallAuthenticatorLocator->has($authenticatorId)) {
92+
throw new LogicException(sprintf('Unable to find an authenticator named "%s" for the firewall "%s". Available authenticators: "%s".', $authenticatorName, implode('", "', $firewallAuthenticatorLocator->getProvidedServices())));
93+
}
94+
95+
return $firewallAuthenticatorLocator->get($authenticatorId);
96+
}
3497
}

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
namespace Symfony\Bundle\SecurityBundle\Tests\Functional;
1313

1414
use Symfony\Bundle\SecurityBundle\Security\FirewallConfig;
15+
use Symfony\Bundle\SecurityBundle\Security\Security;
1516
use Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\SecuredPageBundle\Security\Core\User\ArrayUserProvider;
17+
use Symfony\Component\HttpFoundation\JsonResponse;
1618
use Symfony\Component\HttpFoundation\Request;
1719
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
1820
use Symfony\Component\Security\Core\User\InMemoryUser;
@@ -81,6 +83,22 @@ public function userWillBeMarkedAsChangedIfRolesHasChangedProvider()
8183
],
8284
];
8385
}
86+
87+
/**
88+
* @testWith ["json_login"]
89+
* ["Symfony\\Bundle\\SecurityBundle\\Tests\\Functional\\Bundle\\AuthenticatorBundle\\ApiAuthenticator"]
90+
*/
91+
public function testLoginWithBuiltInAuthenticator(string $authenticator)
92+
{
93+
$client = $this->createClient(['test_case' => 'SecurityHelper', 'root_config' => 'config.yml', 'debug' => true]);
94+
static::getContainer()->get(WelcomeController::class)->authenticator = $authenticator;
95+
$client->request('GET', '/welcome');
96+
$response = $client->getResponse();
97+
98+
$this->assertInstanceOf(JsonResponse::class, $response);
99+
$this->assertSame(200, $response->getStatusCode());
100+
$this->assertSame(['message' => 'Welcome @chalasr!'], json_decode($response->getContent(), true));
101+
}
84102
}
85103

86104
final class UserWithoutEquatable implements UserInterface, PasswordAuthenticatedUserInterface
@@ -189,3 +207,20 @@ public function eraseCredentials(): void
189207
{
190208
}
191209
}
210+
211+
class WelcomeController
212+
{
213+
public $authenticator = 'json_login';
214+
215+
public function __construct(private Security $security)
216+
{
217+
}
218+
219+
public function welcome()
220+
{
221+
$user = new InMemoryUser('chalasr', '', ['ROLE_USER']);
222+
$this->security->login($user, $this->authenticator);
223+
224+
return new JsonResponse(['message' => sprintf('Welcome @%s!', $this->security->getUser()->getUserIdentifier())]);
225+
}
226+
}

src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/SecurityHelper/config.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ services:
1111
alias: security.token_storage
1212
public: true
1313

14+
Symfony\Bundle\SecurityBundle\Tests\Functional\WelcomeController:
15+
arguments: ['@security.helper']
16+
public: true
17+
18+
Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AuthenticatorBundle\ApiAuthenticator: ~
19+
1420
security:
1521
enable_authenticator_manager: true
1622
providers:
@@ -20,3 +26,11 @@ security:
2026

2127
firewalls:
2228
default:
29+
json_login:
30+
username_path: user.login
31+
password_path: user.password
32+
custom_authenticators:
33+
- 'Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AuthenticatorBundle\ApiAuthenticator'
34+
35+
access_control:
36+
- { path: ^/foo, roles: PUBLIC_ACCESS }
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
welcome:
2+
path: /welcome
3+
defaults: { _controller: Symfony\Bundle\SecurityBundle\Tests\Functional\WelcomeController::welcome }

src/Symfony/Bundle/SecurityBundle/Tests/Security/SecurityTest.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,17 @@
1818
use Symfony\Bundle\SecurityBundle\Security\Security;
1919
use Symfony\Component\DependencyInjection\ServiceLocator;
2020
use Symfony\Component\HttpFoundation\Request;
21+
use Symfony\Component\HttpFoundation\RequestStack;
2122
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
2223
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
2324
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
2425
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
2526
use Symfony\Component\Security\Core\User\InMemoryUser;
27+
use Symfony\Component\Security\Core\User\UserCheckerInterface;
28+
use Symfony\Component\Security\Core\User\UserInterface;
29+
use Symfony\Component\Security\Http\Authentication\UserAuthenticatorInterface;
30+
use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface;
31+
use Symfony\Contracts\Service\ServiceProviderInterface;
2632

2733
class SecurityTest extends TestCase
2834
{
@@ -111,6 +117,52 @@ public function getFirewallConfigTests()
111117
yield [$request, new FirewallConfig('main', 'acme_user_checker')];
112118
}
113119

120+
public function testAutoLogin()
121+
{
122+
$request = new Request();
123+
$authenticator = $this->createMock(AuthenticatorInterface::class);
124+
$requestStack = $this->createMock(RequestStack::class);
125+
$firewallMap = $this->createMock(FirewallMap::class);
126+
$firewall = new FirewallConfig('main', 'main');
127+
$userAuthenticator = $this->createMock(UserAuthenticatorInterface::class);
128+
$user = $this->createMock(UserInterface::class);
129+
$userChecker = $this->createMock(UserCheckerInterface::class);
130+
131+
$container = $this->createMock(ContainerInterface::class);
132+
$container
133+
->expects($this->atLeastOnce())
134+
->method('get')
135+
->willReturnMap([
136+
['request_stack', $requestStack],
137+
['security.firewall.map', $firewallMap],
138+
['security.user_authenticator', $userAuthenticator],
139+
['security.user_checker', $userChecker],
140+
])
141+
;
142+
143+
$requestStack->expects($this->once())->method('getCurrentRequest')->willReturn($request);
144+
$firewallMap->expects($this->once())->method('getFirewallConfig')->willReturn($firewall);
145+
$userAuthenticator->expects($this->once())->method('authenticateUser')->with($user, $authenticator, $request);
146+
$userChecker->expects($this->once())->method('checkPreAuth')->with($user);
147+
148+
$firewallAuthenticatorLocator = $this->createMock(ServiceProviderInterface::class);
149+
$firewallAuthenticatorLocator
150+
->expects($this->once())
151+
->method('getProvidedServices')
152+
->willReturn(['security.authenticator.custom.dev' => $authenticator])
153+
;
154+
$firewallAuthenticatorLocator
155+
->expects($this->once())
156+
->method('get')
157+
->with('security.authenticator.custom.dev')
158+
->willReturn($authenticator)
159+
;
160+
161+
$security = new Security($container, ['main' => $firewallAuthenticatorLocator]);
162+
163+
$security->login($user);
164+
}
165+
114166
private function createContainer(string $serviceId, object $serviceObject): ContainerInterface
115167
{
116168
return new ServiceLocator([$serviceId => fn () => $serviceObject]);

0 commit comments

Comments
 (0)
0