Skip to content

Commit 534466d

Browse files
committed
feature symfony#38177 [Security] Magic login link authentication (weaverryan)
This PR was squashed before being merged into the 5.2-dev branch. Discussion ---------- [Security] Magic login link authentication | Q | A | ------------- | --- | Branch? | master | Bug fix? | no | New feature? | yes | Deprecations? | no | Tickets | none | License | MIT | Doc PR | TODO Hi! This adds a Slack-style "magic link" login authenticator to the new login system: (A) enter your email into a form, (B) receive an email with a link in it (C) click that link and you are authenticated! For most users, implementing this would require: A) Create a [controller](https://github.com/weaverryan/symfony-magic-login-link-example/blob/master/src/Controller/MagicLinkLoginController.php) with the "enter your email" form and a route for the "check" functionality (similar to `form_login`) B) Activate in `security.yaml`: ```yml security: enable_authenticator_manager: true # ... firewalls: # ... main: # ... login_link: check_route: 'magic_link_verify' # this is an important and powerful option # 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 # tl;dr If you want the modification of ANY field to invalidate ALL existing magic links immediately, # then you can add it to this list. You could even add a "lastLoginLinkSentAt" to invalid # all existing login links when a new one is sent. signature_properties: [id, password, email] # optional - by default, links can be reused but have a 10 minute lifetime #max_uses: 3 #used_link_cache: cache.app ``` Done! This will generate a URL that looks something like this: > https://127.0.0.1:9033/login/[email protected]&expires=1601342578&hash=YzE1ZDJlYjM3YTMyMjgwZDdkYzg2ZjFlMjZhN2E5ZWRmMzk3NjAxNjRjYThiMjMzNmIxYzAzYzQ4NmQ2Zjk4NA%3D%3D We would implement a Maker command this config + login/controller. The implementation is done via a "signed URL" and an optional cache pool to "expire" links. The hash of the signed URL can contain any user fields you want, which give you a powerful mechanism to invalidate magic tokens on user data changes. See `signature_properties` above. #### Security notes: There is a LOT of variability about how secure these need to be: * A) Many/most implementation only allow links to be used ONE time. That is *possible* with this implementation, but is not the *default*. You CAN add a `max_uses` config which stores the expired links in a cache so they cannot be re-used. However, to make this work, you need to do more work by adding some "page" between the link the users clicks and *actually* using the login link. Why? Because unless you do this, email clients may follow the link to "preview" it and will "consume" the link. * B) Many implementations will invalidate all other login links for a user when a new one is created. We do *not* do that, but that IS possible (and we could even generate the code for it) by adding a `lastLoginLinkSentAt` field to `User` and including this in `signature_properties`. * C) We *do* invalidate all links if the user's email address is changed (assuming the `email` is included in `signature_properties`, which it should be). You can also invalidate on password change or whatever you want. * D) Some implementations add a "state" so that you can only use the link on the same device that created it. That is, in many cases, quite annoying. We do not currently support that, but we could in the future (and the user could add it themselves). Thanks! #### TODOS: * [x] A) more tests: functional (?) traits * [ ] B) documentation * [ ] C) MakerBundle PR * [ ] D) Make sure we have what we need to allow that "in between" page * [ ] E) Create a new cache pool instead of relying on cache.app? Commits ------- a8afe10 [Security] Magic login link authentication
2 parents f589ff4 + a8afe10 commit 534466d

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)