Skip to content

Commit a8afe10

Browse files
weaverryanfabpot
authored andcommitted
[Security] Magic login link authentication
1 parent f06f2f0 commit a8afe10

30 files changed

+1330
-6
lines changed

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ class UnusedTagsPass implements CompilerPassInterface
7474
'routing.route_loader',
7575
'security.expression_language_provider',
7676
'security.remember_me_aware',
77+
'security.authenticator.login_linker',
7778
'security.voter',
7879
'serializer.encoder',
7980
'serializer.normalizer',

src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/EntryPointFactoryInterface.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,5 @@ interface EntryPointFactoryInterface
2626
* This does not mean that the entry point is also used. This is managed
2727
* by the "entry_point" firewall setting.
2828
*/
29-
public function registerEntryPoint(ContainerBuilder $container, string $id, array $config): ?string;
29+
public function registerEntryPoint(ContainerBuilder $container, string $firewallName, array $config): ?string;
3030
}

src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/FormLoginFactory.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,9 @@ protected function createEntryPoint(ContainerBuilder $container, string $id, arr
9797
return $this->registerEntryPoint($container, $id, $config);
9898
}
9999

100-
public function registerEntryPoint(ContainerBuilder $container, string $id, array $config): string
100+
public function registerEntryPoint(ContainerBuilder $container, string $firewallName, array $config): string
101101
{
102-
$entryPointId = 'security.authentication.form_entry_point.'.$id;
102+
$entryPointId = 'security.authentication.form_entry_point.'.$firewallName;
103103
$container
104104
->setDefinition($entryPointId, new ChildDefinition('security.authentication.form_entry_point'))
105105
->addArgument(new Reference('security.http_utils'))

src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/GuardAuthenticationFactory.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ public function createAuthenticator(ContainerBuilder $container, string $firewal
114114
return $authenticatorIds;
115115
}
116116

117-
public function registerEntryPoint(ContainerBuilder $container, string $id, array $config): ?string
117+
public function registerEntryPoint(ContainerBuilder $container, string $firewallName, array $config): ?string
118118
{
119119
try {
120120
return $this->determineEntryPoint(null, $config);

src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/HttpBasicFactory.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,9 @@ public function addConfiguration(NodeDefinition $node)
8282
;
8383
}
8484

85-
public function registerEntryPoint(ContainerBuilder $container, string $id, array $config): string
85+
public function registerEntryPoint(ContainerBuilder $container, string $firewallName, array $config): string
8686
{
87-
$entryPointId = 'security.authentication.basic_entry_point.'.$id;
87+
$entryPointId = 'security.authentication.basic_entry_point.'.$firewallName;
8888
$container
8989
->setDefinition($entryPointId, new ChildDefinition('security.authentication.basic_entry_point'))
9090
->addArgument($config['realm'])
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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\NodeBuilder;
15+
use Symfony\Component\Config\Definition\Builder\NodeDefinition;
16+
use Symfony\Component\Config\FileLocator;
17+
use Symfony\Component\DependencyInjection\ChildDefinition;
18+
use Symfony\Component\DependencyInjection\ContainerBuilder;
19+
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
20+
use Symfony\Component\DependencyInjection\Reference;
21+
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
22+
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
23+
use Symfony\Component\Security\Http\LoginLink\LoginLinkHandler;
24+
25+
/**
26+
* @internal
27+
* @experimental in 5.2
28+
*/
29+
class LoginLinkFactory extends AbstractFactory implements AuthenticatorFactoryInterface
30+
{
31+
public function addConfiguration(NodeDefinition $node)
32+
{
33+
/** @var NodeBuilder $builder */
34+
$builder = $node->children();
35+
36+
$builder
37+
->scalarNode('check_route')
38+
->isRequired()
39+
->info('Route that will validate the login link - e.g. app_login_link_verify')
40+
->end()
41+
->arrayNode('signature_properties')
42+
->prototype('scalar')->end()
43+
->requiresAtLeastOneElement()
44+
->info('An array of properties on your User that are used to sign the link. If any of these change, all existing links will become invalid')
45+
->example(['email', 'password'])
46+
->end()
47+
->integerNode('lifetime')
48+
->defaultValue(600)
49+
->info('The lifetime of the login link in seconds')
50+
->end()
51+
->integerNode('max_uses')
52+
->defaultNull()
53+
->info('Max number of times a login link can be used - null means unlimited within lifetime.')
54+
->end()
55+
->scalarNode('used_link_cache')
56+
->info('Cache service id used to expired links of max_uses is set')
57+
->end()
58+
->scalarNode('success_handler')
59+
->info(sprintf('A service id that implements %s', AuthenticationSuccessHandlerInterface::class))
60+
->end()
61+
->scalarNode('failure_handler')
62+
->info(sprintf('A service id that implements %s', AuthenticationFailureHandlerInterface::class))
63+
->end()
64+
->scalarNode('provider')
65+
->info('the user provider to load users from.')
66+
->end()
67+
;
68+
69+
foreach (array_merge($this->defaultSuccessHandlerOptions, $this->defaultFailureHandlerOptions) as $name => $default) {
70+
if (\is_bool($default)) {
71+
$builder->booleanNode($name)->defaultValue($default);
72+
} else {
73+
$builder->scalarNode($name)->defaultValue($default);
74+
}
75+
}
76+
}
77+
78+
public function getKey()
79+
{
80+
return 'login-link';
81+
}
82+
83+
public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId): string
84+
{
85+
if (!class_exists(LoginLinkHandler::class)) {
86+
throw new \LogicException('Login login link requires symfony/security-http:^5.2.');
87+
}
88+
89+
if (!$container->hasDefinition('security.authenticator.login_link')) {
90+
$loader = new PhpFileLoader($container, new FileLocator(\dirname(__DIR__).'/../../Resources/config'));
91+
$loader->load('security_authenticator_login_link.php');
92+
}
93+
94+
if (null !== $config['max_uses'] && !isset($config['used_link_cache'])) {
95+
$config['used_link_cache'] = 'security.authenticator.cache.expired_links';
96+
}
97+
98+
$expiredStorageId = null;
99+
if (isset($config['used_link_cache'])) {
100+
$expiredStorageId = 'security.authenticator.expired_login_link_storage.'.$firewallName;
101+
$container
102+
->setDefinition($expiredStorageId, new ChildDefinition('security.authenticator.expired_login_link_storage'))
103+
->replaceArgument(0, new Reference($config['used_link_cache']))
104+
->replaceArgument(1, $config['lifetime']);
105+
}
106+
107+
$linkerId = 'security.authenticator.login_link_handler.'.$firewallName;
108+
$linkerOptions = [
109+
'route_name' => $config['check_route'],
110+
'lifetime' => $config['lifetime'],
111+
'max_uses' => $config['max_uses'] ?? null,
112+
];
113+
$container
114+
->setDefinition($linkerId, new ChildDefinition('security.authenticator.abstract_login_link_handler'))
115+
->replaceArgument(1, new Reference($userProviderId))
116+
->replaceArgument(3, $config['signature_properties'])
117+
->replaceArgument(5, $linkerOptions)
118+
->replaceArgument(6, $expiredStorageId ? new Reference($expiredStorageId) : null)
119+
->addTag('security.authenticator.login_linker', ['firewall' => $firewallName])
120+
;
121+
122+
$authenticatorId = 'security.authenticator.login_link.'.$firewallName;
123+
$container
124+
->setDefinition($authenticatorId, new ChildDefinition('security.authenticator.login_link'))
125+
->replaceArgument(0, new Reference($linkerId))
126+
->replaceArgument(2, new Reference($this->createAuthenticationSuccessHandler($container, $firewallName, $config)))
127+
->replaceArgument(3, new Reference($this->createAuthenticationFailureHandler($container, $firewallName, $config)))
128+
->replaceArgument(4, [
129+
'check_route' => $config['check_route'],
130+
]);
131+
132+
return $authenticatorId;
133+
}
134+
135+
public function getPosition()
136+
{
137+
return 'form';
138+
}
139+
140+
protected function createAuthProvider(ContainerBuilder $container, string $id, array $config, string $userProviderId)
141+
{
142+
throw new \Exception('The old authentication system is not supported with login_link.');
143+
}
144+
145+
protected function getListenerId()
146+
{
147+
throw new \Exception('The old authentication system is not supported with login_link.');
148+
}
149+
150+
protected function createListener(ContainerBuilder $container, string $id, array $config, string $userProvider)
151+
{
152+
throw new \Exception('The old authentication system is not supported with login_link.');
153+
}
154+
155+
protected function createEntryPoint(ContainerBuilder $container, string $id, array $config, ?string $defaultEntryPointId)
156+
{
157+
throw new \Exception('The old authentication system is not supported with login_link.');
158+
}
159+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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\LoginLink;
13+
14+
use Psr\Container\ContainerInterface;
15+
use Symfony\Bundle\SecurityBundle\Security\FirewallMap;
16+
use Symfony\Component\HttpFoundation\Request;
17+
use Symfony\Component\HttpFoundation\RequestStack;
18+
use Symfony\Component\Security\Core\User\UserInterface;
19+
use Symfony\Component\Security\Http\LoginLink\LoginLinkDetails;
20+
use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface;
21+
22+
/**
23+
* Decorates the login link handler for the current firewall.
24+
*
25+
* @author Ryan Weaver <[email protected]>
26+
*/
27+
class FirewallAwareLoginLinkHandler implements LoginLinkHandlerInterface
28+
{
29+
private $firewallMap;
30+
private $loginLinkHandlerLocator;
31+
private $requestStack;
32+
33+
public function __construct(FirewallMap $firewallMap, ContainerInterface $loginLinkHandlerLocator, RequestStack $requestStack)
34+
{
35+
$this->firewallMap = $firewallMap;
36+
$this->loginLinkHandlerLocator = $loginLinkHandlerLocator;
37+
$this->requestStack = $requestStack;
38+
}
39+
40+
public function createLoginLink(UserInterface $user): LoginLinkDetails
41+
{
42+
return $this->getLoginLinkHandler()->createLoginLink($user);
43+
}
44+
45+
public function consumeLoginLink(Request $request): UserInterface
46+
{
47+
return $this->getLoginLinkHandler()->consumeLoginLink($request);
48+
}
49+
50+
private function getLoginLinkHandler(): LoginLinkHandlerInterface
51+
{
52+
if (null === $request = $this->requestStack->getCurrentRequest()) {
53+
throw new \LogicException('Cannot determine the correct LoginLinkHandler to use: there is no active Request and so, the firewall cannot be determined. Try using the specific login link handler service.');
54+
}
55+
56+
$firewallName = $this->firewallMap->getFirewallConfig($request)->getName();
57+
if (!$this->loginLinkHandlerLocator->has($firewallName)) {
58+
throw new \InvalidArgumentException(sprintf('No login link handler found. Did you add a login_link key under your "%s" firewall?', $firewallName));
59+
}
60+
61+
return $this->loginLinkHandlerLocator->get($firewallName);
62+
}
63+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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\Bundle\SecurityBundle\LoginLink\FirewallAwareLoginLinkHandler;
15+
use Symfony\Component\Security\Http\Authenticator\LoginLinkAuthenticator;
16+
use Symfony\Component\Security\Http\LoginLink\ExpiredLoginLinkStorage;
17+
use Symfony\Component\Security\Http\LoginLink\LoginLinkHandler;
18+
use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface;
19+
20+
return static function (ContainerConfigurator $container) {
21+
$container->services()
22+
->set('security.authenticator.login_link', LoginLinkAuthenticator::class)
23+
->abstract()
24+
->args([
25+
abstract_arg('the login link handler instance'),
26+
service('security.http_utils'),
27+
abstract_arg('authentication success handler'),
28+
abstract_arg('authentication failure handler'),
29+
abstract_arg('options'),
30+
])
31+
32+
->set('security.authenticator.abstract_login_link_handler', LoginLinkHandler::class)
33+
->abstract()
34+
->args([
35+
service('router'),
36+
abstract_arg('user provider'),
37+
service('property_accessor'),
38+
abstract_arg('signature properties'),
39+
'%kernel.secret%',
40+
abstract_arg('options'),
41+
abstract_arg('expired login link storage'),
42+
])
43+
44+
->set('security.authenticator.expired_login_link_storage', ExpiredLoginLinkStorage::class)
45+
->abstract()
46+
->args([
47+
abstract_arg('cache pool service'),
48+
abstract_arg('expired login link storage'),
49+
])
50+
51+
->set('security.authenticator.cache.expired_links')
52+
->parent('cache.app')
53+
->private()
54+
->tag('cache.pool')
55+
56+
->set('security.authenticator.firewall_aware_login_link_handler', FirewallAwareLoginLinkHandler::class)
57+
->args([
58+
service('security.firewall.map'),
59+
tagged_locator('security.authenticator.login_linker', 'firewall'),
60+
service('request_stack'),
61+
])
62+
->alias(LoginLinkHandlerInterface::class, 'security.authenticator.firewall_aware_login_link_handler')
63+
64+
->set('security.authenticator.entity_login_link_user_handler', EntityLoginLinkUserHandler::class)
65+
->abstract()
66+
->args([
67+
service('doctrine'),
68+
abstract_arg('user entity class name'),
69+
])
70+
71+
;
72+
};

src/Symfony/Bundle/SecurityBundle/SecurityBundle.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\HttpBasicLdapFactory;
2929
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\JsonLoginFactory;
3030
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\JsonLoginLdapFactory;
31+
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\LoginLinkFactory;
3132
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\LoginThrottlingFactory;
3233
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\RememberMeFactory;
3334
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\RemoteUserFactory;
@@ -66,6 +67,7 @@ public function build(ContainerBuilder $container)
6667
$extension->addSecurityListenerFactory(new AnonymousFactory());
6768
$extension->addSecurityListenerFactory(new CustomAuthenticatorFactory());
6869
$extension->addSecurityListenerFactory(new LoginThrottlingFactory());
70+
$extension->addSecurityListenerFactory(new LoginLinkFactory());
6971

7072
$extension->addUserProviderFactory(new InMemoryFactory());
7173
$extension->addUserProviderFactory(new LdapFactory());

0 commit comments

Comments
 (0)