Skip to content

[Security] Add a method in the security helper to ease programmatic login (#40662) #41274

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jul 5, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Symfony/Bundle/SecurityBundle/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ CHANGELOG
* Add the `Security` helper class
* Deprecate the `Symfony\Component\Security\Core\Security` service alias, use `Symfony\Bundle\SecurityBundle\Security\Security` instead
* Add `Security::getFirewallConfig()` to help to get the firewall configuration associated to the Request
* Add `Security::login()` to login programmatically

6.1
---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -277,16 +277,25 @@ private function createFirewalls(array $config, ContainerBuilder $container)

// load firewall map
$mapDef = $container->getDefinition('security.firewall.map');
$map = $authenticationProviders = $contextRefs = [];
$map = $authenticationProviders = $contextRefs = $authenticators = [];
foreach ($firewalls as $name => $firewall) {
if (isset($firewall['user_checker']) && 'security.user_checker' !== $firewall['user_checker']) {
$customUserChecker = true;
}

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

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

if (!$firewallAuthenticators) {
$authenticators[$name] = null;
} else {
$firewallAuthenticatorRefs = [];
foreach ($firewallAuthenticators as $authenticatorId) {
$firewallAuthenticatorRefs[$authenticatorId] = new Reference($authenticatorId);
}
$authenticators[$name] = ServiceLocatorTagPass::register($container, $firewallAuthenticatorRefs);
}
$contextId = 'security.firewall.map.context.'.$name;
$isLazy = !$firewall['stateless'] && (!empty($firewall['anonymous']['lazy']) || $firewall['lazy']);
$context = new ChildDefinition($isLazy ? 'security.firewall.lazy_context' : 'security.firewall.context');
Expand All @@ -301,6 +310,10 @@ private function createFirewalls(array $config, ContainerBuilder $container)
$contextRefs[$contextId] = new Reference($contextId);
$map[$contextId] = $matcher;
}
$container
->getDefinition('security.helper')
->replaceArgument(1, $authenticators)
;

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

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

// Security disabled?
if (false === $firewall['security']) {
return [$matcher, [], null, null];
return [$matcher, [], null, null, []];
}

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

return [$matcher, $listeners, $exceptionListener, null !== $logoutListenerId ? new Reference($logoutListenerId) : null];
return [$matcher, $listeners, $exceptionListener, null !== $logoutListenerId ? new Reference($logoutListenerId) : null, $firewallAuthenticationProviders];
}

private function createContextListener(ContainerBuilder $container, string $contextKey, ?string $firewallEventDispatcherId)
Expand Down
16 changes: 11 additions & 5 deletions src/Symfony/Bundle/SecurityBundle/Resources/config/security.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,17 @@
->set('security.untracked_token_storage', TokenStorage::class)

->set('security.helper', Security::class)
->args([service_locator([
'security.token_storage' => service('security.token_storage'),
'security.authorization_checker' => service('security.authorization_checker'),
'security.firewall.map' => service('security.firewall.map'),
])])
->args([
service_locator([
'security.token_storage' => service('security.token_storage'),
'security.authorization_checker' => service('security.authorization_checker'),
'security.user_authenticator' => service('security.user_authenticator')->ignoreOnInvalid(),
'request_stack' => service('request_stack'),
'security.firewall.map' => service('security.firewall.map'),
'security.user_checker' => service('security.user_checker'),
]),
abstract_arg('authenticators'),
])
->alias(Security::class, 'security.helper')
->alias(LegacySecurity::class, 'security.helper')
->deprecate('symfony/security-bundle', '6.2', 'The "%alias_id%" service alias is deprecated, use "'.Security::class.'" instead.')
Expand Down
65 changes: 64 additions & 1 deletion src/Symfony/Bundle/SecurityBundle/Security/Security.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,24 @@

use Psr\Container\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Exception\LogicException;
use Symfony\Component\Security\Core\Security as LegacySecurity;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface;
use Symfony\Contracts\Service\ServiceProviderInterface;

/**
* Helper class for commonly-needed security tasks.
*
* @author Ryan Weaver <ryan@symfonycasts.com>
* @author Robin Chalas <robin.chalas@gmail.com>
* @author Arnaud Frézet <arnaud@larriereguichet.fr>
*
* @final
*/
class Security extends LegacySecurity
{
public function __construct(private ContainerInterface $container)
public function __construct(private readonly ContainerInterface $container, private readonly array $authenticators = [])
{
parent::__construct($container, false);
}
Expand All @@ -31,4 +39,59 @@ public function getFirewallConfig(Request $request): ?FirewallConfig
{
return $this->container->get('security.firewall.map')->getFirewallConfig($request);
}

/**
* @param UserInterface $user The user to authenticate
* @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
* @param string|null $firewallName The firewall name - required only if multiple firewalls are configured
*/
public function login(UserInterface $user, string $authenticatorName = null, string $firewallName = null): void
{
$request = $this->container->get('request_stack')->getCurrentRequest();
$firewallName ??= $this->getFirewallConfig($request)?->getName();

if (!$firewallName) {
throw new LogicException('Unable to login as the current route is not covered by any firewall.');
}

$authenticator = $this->getAuthenticator($authenticatorName, $firewallName);

$this->container->get('security.user_checker')->checkPreAuth($user);
$this->container->get('security.user_authenticator')->authenticateUser($user, $authenticator, $request);
}

private function getAuthenticator(?string $authenticatorName, string $firewallName): AuthenticatorInterface
{
if (!\array_key_exists($firewallName, $this->authenticators)) {
throw new LogicException(sprintf('No authenticators found for firewall "%s".', $firewallName));
}

/** @var ServiceProviderInterface $firewallAuthenticatorLocator */
$firewallAuthenticatorLocator = $this->authenticators[$firewallName];

if (!$authenticatorName) {
$authenticatorIds = array_keys($firewallAuthenticatorLocator->getProvidedServices());

if (!$authenticatorIds) {
throw new LogicException(sprintf('No authenticator was found for the firewall "%s".', $firewallName));
}
if (1 < \count($authenticatorIds)) {
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)));
}

return $firewallAuthenticatorLocator->get($authenticatorIds[0]);
}

if ($firewallAuthenticatorLocator->has($authenticatorName)) {
return $firewallAuthenticatorLocator->get($authenticatorName);
}

$authenticatorId = 'security.authenticator.'.$authenticatorName.'.'.$firewallName;

if (!$firewallAuthenticatorLocator->has($authenticatorId)) {
throw new LogicException(sprintf('Unable to find an authenticator named "%s" for the firewall "%s". Available authenticators: "%s".', $authenticatorName, implode('", "', $firewallAuthenticatorLocator->getProvidedServices())));
}

return $firewallAuthenticatorLocator->get($authenticatorId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
namespace Symfony\Bundle\SecurityBundle\Tests\Functional;

use Symfony\Bundle\SecurityBundle\Security\FirewallConfig;
use Symfony\Bundle\SecurityBundle\Security\Security;
use Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\SecuredPageBundle\Security\Core\User\ArrayUserProvider;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\User\InMemoryUser;
Expand Down Expand Up @@ -81,6 +83,22 @@ public function userWillBeMarkedAsChangedIfRolesHasChangedProvider()
],
];
}

/**
* @testWith ["json_login"]
* ["Symfony\\Bundle\\SecurityBundle\\Tests\\Functional\\Bundle\\AuthenticatorBundle\\ApiAuthenticator"]
*/
public function testLoginWithBuiltInAuthenticator(string $authenticator)
{
$client = $this->createClient(['test_case' => 'SecurityHelper', 'root_config' => 'config.yml', 'debug' => true]);
static::getContainer()->get(WelcomeController::class)->authenticator = $authenticator;
$client->request('GET', '/welcome');
$response = $client->getResponse();

$this->assertInstanceOf(JsonResponse::class, $response);
$this->assertSame(200, $response->getStatusCode());
$this->assertSame(['message' => 'Welcome @chalasr!'], json_decode($response->getContent(), true));
}
}

final class UserWithoutEquatable implements UserInterface, PasswordAuthenticatedUserInterface
Expand Down Expand Up @@ -189,3 +207,20 @@ public function eraseCredentials(): void
{
}
}

class WelcomeController
{
public $authenticator = 'json_login';

public function __construct(private Security $security)
{
}

public function welcome()
{
$user = new InMemoryUser('chalasr', '', ['ROLE_USER']);
$this->security->login($user, $this->authenticator);

return new JsonResponse(['message' => sprintf('Welcome @%s!', $this->security->getUser()->getUserIdentifier())]);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ services:
alias: security.token_storage
public: true

Symfony\Bundle\SecurityBundle\Tests\Functional\WelcomeController:
arguments: ['@security.helper']
public: true

Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AuthenticatorBundle\ApiAuthenticator: ~

security:
enable_authenticator_manager: true
providers:
Expand All @@ -20,3 +26,11 @@ security:

firewalls:
default:
json_login:
username_path: user.login
password_path: user.password
custom_authenticators:
- 'Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\AuthenticatorBundle\ApiAuthenticator'

access_control:
- { path: ^/foo, roles: PUBLIC_ACCESS }
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
welcome:
path: /welcome
defaults: { _controller: Symfony\Bundle\SecurityBundle\Tests\Functional\WelcomeController::welcome }
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,17 @@
use Symfony\Bundle\SecurityBundle\Security\Security;
use Symfony\Component\DependencyInjection\ServiceLocator;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Security\Core\User\InMemoryUser;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Http\Authentication\UserAuthenticatorInterface;
use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface;
use Symfony\Contracts\Service\ServiceProviderInterface;

class SecurityTest extends TestCase
{
Expand Down Expand Up @@ -111,6 +117,52 @@ public function getFirewallConfigTests()
yield [$request, new FirewallConfig('main', 'acme_user_checker')];
}

public function testAutoLogin()
{
$request = new Request();
$authenticator = $this->createMock(AuthenticatorInterface::class);
$requestStack = $this->createMock(RequestStack::class);
$firewallMap = $this->createMock(FirewallMap::class);
$firewall = new FirewallConfig('main', 'main');
$userAuthenticator = $this->createMock(UserAuthenticatorInterface::class);
$user = $this->createMock(UserInterface::class);
$userChecker = $this->createMock(UserCheckerInterface::class);

$container = $this->createMock(ContainerInterface::class);
$container
->expects($this->atLeastOnce())
->method('get')
->willReturnMap([
['request_stack', $requestStack],
['security.firewall.map', $firewallMap],
['security.user_authenticator', $userAuthenticator],
['security.user_checker', $userChecker],
])
;

$requestStack->expects($this->once())->method('getCurrentRequest')->willReturn($request);
$firewallMap->expects($this->once())->method('getFirewallConfig')->willReturn($firewall);
$userAuthenticator->expects($this->once())->method('authenticateUser')->with($user, $authenticator, $request);
$userChecker->expects($this->once())->method('checkPreAuth')->with($user);

$firewallAuthenticatorLocator = $this->createMock(ServiceProviderInterface::class);
$firewallAuthenticatorLocator
->expects($this->once())
->method('getProvidedServices')
->willReturn(['security.authenticator.custom.dev' => $authenticator])
;
$firewallAuthenticatorLocator
->expects($this->once())
->method('get')
->with('security.authenticator.custom.dev')
->willReturn($authenticator)
;

$security = new Security($container, ['main' => $firewallAuthenticatorLocator]);

$security->login($user);
}

private function createContainer(string $serviceId, object $serviceObject): ContainerInterface
{
return new ServiceLocator([$serviceId => fn () => $serviceObject]);
Expand Down