Skip to content

Commit 0524d02

Browse files
committed
Magic login link authentication system
1 parent 2740631 commit 0524d02

21 files changed

+1210
-0
lines changed
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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\Bridge\Doctrine\Security\MagicLink;
13+
14+
use Doctrine\ORM\Mapping as ORM;
15+
16+
/**
17+
* Trait that can be used by your entity to help implement StoredMagicLinkTokenInterface
18+
*
19+
* @author Ryan Weaver <ryan@symfonycasts.com>
20+
*/
21+
trait MagicLoginLinkTokenEntityTrait
22+
{
23+
/**
24+
* @ORM\Column(type="string", length=20)
25+
*/
26+
private $selector;
27+
28+
/**
29+
* @ORM\Column(type="string", length=100)
30+
*/
31+
private $hashedVerifier;
32+
33+
/**
34+
* @ORM\Column(type="datetime_immutable")
35+
*/
36+
private $createdAt;
37+
38+
/**
39+
* @ORM\Column(type="datetime_immutable")
40+
*/
41+
private $expiresAt;
42+
43+
/**
44+
* Call this from your constructor to initialize the fieds
45+
*/
46+
private function initialize(\DateTimeInterface $expiresAt, string $selector, string $hashedVerifier)
47+
{
48+
$this->createdAt = new \DateTimeImmutable('now');
49+
$this->expiresAt = $expiresAt;
50+
$this->selector = $selector;
51+
$this->hashedVerifier = $hashedVerifier;
52+
}
53+
54+
public function getSelector()
55+
{
56+
return $this->selector;
57+
}
58+
59+
public function getCreatedAt(): \DateTimeInterface
60+
{
61+
return $this->createdAt;
62+
}
63+
64+
public function getExpiresAt(): \DateTimeInterface
65+
{
66+
return $this->expiresAt;
67+
}
68+
69+
public function getHashedVerifier(): string
70+
{
71+
return $this->hashedVerifier;
72+
}
73+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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\Bridge\Doctrine\Security\MagicLink;
13+
14+
use Symfony\Component\Security\Core\User\UserInterface;
15+
use Symfony\Component\Security\Http\MagicLink\StoredMagicLinkTokenInterface;
16+
17+
/**
18+
* Trait that can be used on a Doctrine entity to help implement MagicLinkTokenStorageInterface.
19+
*
20+
* Using this trait requires following a few conventions:
21+
*
22+
* A) Your repository needs a createMagicToken method that returns
23+
* a new instance of your entity:
24+
*
25+
* private function createMagicToken(string $selector, string $hashedVerifier, UserInterface $user, \DateTimeInterface $expiresAt): StoredMagicLinkTokenInterface
26+
*
27+
* B) Your entity needs a "selector" and "expiresAt" properties.
28+
*
29+
* @author Ryan Weaver <ryan@symfonycasts.com>
30+
*/
31+
trait MagicLoginLinkTokenRepositoryTrait
32+
{
33+
public function storeToken(string $selector, string $hashedVerifier, UserInterface $user, \DateTimeInterface $expiresAt): void
34+
{
35+
if (!method_exists($this, 'createMagicToken')) {
36+
throw new \LogicException(sprintf('In order to use MagicLinkRepositoryTrait, the class "%s" must either have a createMagicToken() or override the storeToken() method.', get_class($this)));
37+
}
38+
39+
$magicLoginToken = $this->createMagicToken($selector, $hashedVerifier, $user, $expiresAt);
40+
41+
$this->getEntityManager()->persist($magicLoginToken);
42+
$this->getEntityManager()->flush();
43+
}
44+
45+
public function findToken(string $selector): ?StoredMagicLinkTokenInterface
46+
{
47+
return $this->findOneBy(['selector' => $selector]);
48+
}
49+
50+
public function invalidateToken(string $selector): void
51+
{
52+
$this->createQueryBuilder('mlt')
53+
->delete()
54+
->where('mlt.selector = :selector')
55+
->setParameter('selector', $selector)
56+
->getQuery()
57+
->execute();
58+
}
59+
60+
public function removeExpiredTokens(): void
61+
{
62+
// keep very-recently expired tokens so that you
63+
// can show "token is expired" message if desired
64+
$time = new \DateTimeImmutable('-1 day');
65+
$query = $this->createQueryBuilder('mlt')
66+
->delete()
67+
->where('mlt.expiresAt <= :time')
68+
->setParameter('time', $time)
69+
->getQuery()
70+
;
71+
72+
$query->execute();
73+
}
74+
}

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.magic_linker',
7778
'security.voter',
7879
'serializer.encoder',
7980
'serializer.normalizer',
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
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\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\MagicLink\MagicLinkTokenStorageInterface;
22+
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
23+
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
24+
25+
/**
26+
* @internal
27+
*/
28+
class MagicLinkLoginFactory extends AbstractFactory implements AuthenticatorFactoryInterface
29+
{
30+
public function addConfiguration(NodeDefinition $node)
31+
{
32+
/** @var NodeBuilder $builder */
33+
$builder = $node->children();
34+
35+
$builder
36+
->scalarNode('check_route')
37+
->isRequired()
38+
->info('Route that will validate the magic link - e.g. app_magic_link_verify')
39+
->end()
40+
->scalarNode('storage_service')
41+
->isRequired()
42+
->info(sprintf('A service id that implements %s', MagicLinkTokenStorageInterface::class))
43+
->end()
44+
->integerNode('lifetime')
45+
->defaultValue(1800)
46+
->info('The lifetime of the magic link in seconds')
47+
->end()
48+
->scalarNode('token_parameter')
49+
->defaultValue('token')
50+
->end()
51+
->scalarNode('success_handler')
52+
->info(sprintf('A service id that implements %s', AuthenticationSuccessHandlerInterface::class))
53+
->end()
54+
->scalarNode('failure_handler')
55+
->info(sprintf('A service id that implements %s', AuthenticationFailureHandlerInterface::class))
56+
->end()
57+
;
58+
59+
foreach (array_merge($this->defaultSuccessHandlerOptions, $this->defaultFailureHandlerOptions) as $name => $default) {
60+
if (\is_bool($default)) {
61+
$builder->booleanNode($name)->defaultValue($default);
62+
} else {
63+
$builder->scalarNode($name)->defaultValue($default);
64+
}
65+
}
66+
}
67+
68+
public function getKey()
69+
{
70+
return 'magic-link';
71+
}
72+
73+
public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId): string
74+
{
75+
if (!$container->hasDefinition('security.authenticator.magic_link')) {
76+
$loader = new PhpFileLoader($container, new FileLocator(\dirname(__DIR__).'/../../Resources/config'));
77+
$loader->load('security_authenticator_magic_link.php');
78+
}
79+
80+
$linkerId = 'security.authenticator.magic_login_linker.'.$firewallName;
81+
$container
82+
->setDefinition($linkerId, new ChildDefinition('security.authenticator.abstract_magic_login_linker'))
83+
->replaceArgument(0, new Reference($config['storage_service']))
84+
->replaceArgument(3, $config['lifetime'])
85+
->replaceArgument(4, $config['check_route'])
86+
->addTag('security.authenticator.magic_linker', ['firewall' => $firewallName])
87+
;
88+
89+
$authenticatorId = 'security.authenticator.magic_link.'.$firewallName;
90+
$container
91+
->setDefinition($authenticatorId, new ChildDefinition('security.authenticator.magic_link'))
92+
->replaceArgument(0, new Reference($linkerId))
93+
->replaceArgument(2, new Reference($this->createAuthenticationSuccessHandler($container, $firewallName, $config)))
94+
->replaceArgument(3, new Reference($this->createAuthenticationFailureHandler($container, $firewallName, $config)))
95+
->replaceArgument(4, [
96+
'check_route' => $config['check_route'],
97+
'token_parameter' => $config['token_parameter'],
98+
]);
99+
100+
return $authenticatorId;
101+
}
102+
103+
public function getPosition()
104+
{
105+
return 'form';
106+
}
107+
108+
protected function createAuthProvider(ContainerBuilder $container, string $id, array $config, string $userProviderId)
109+
{
110+
throw new \Exception('The old authentication system is not supported with magic_link');
111+
}
112+
113+
protected function getListenerId()
114+
{
115+
throw new \Exception('The old authentication system is not supported with magic_link');
116+
}
117+
118+
protected function createListener(ContainerBuilder $container, string $id, array $config, string $userProvider)
119+
{
120+
throw new \Exception('The old authentication system is not supported with magic_link');
121+
}
122+
123+
protected function createEntryPoint(ContainerBuilder $container, string $id, array $config, ?string $defaultEntryPointId)
124+
{
125+
throw new \Exception('The old authentication system is not supported with magic_link');
126+
}
127+
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use Symfony\Component\Security\Http\Authenticator\FormLoginAuthenticator;
2121
use Symfony\Component\Security\Http\Authenticator\HttpBasicAuthenticator;
2222
use Symfony\Component\Security\Http\Authenticator\JsonLoginAuthenticator;
23+
use Symfony\Component\Security\Http\Authenticator\MagicLinkAuthenticator;
2324
use Symfony\Component\Security\Http\Authenticator\RememberMeAuthenticator;
2425
use Symfony\Component\Security\Http\Authenticator\RemoteUserAuthenticator;
2526
use Symfony\Component\Security\Http\Authenticator\X509Authenticator;
@@ -31,6 +32,9 @@
3132
use Symfony\Component\Security\Http\EventListener\UserCheckerListener;
3233
use Symfony\Component\Security\Http\EventListener\UserProviderListener;
3334
use Symfony\Component\Security\Http\Firewall\AuthenticatorManagerListener;
35+
use Symfony\Component\Security\Http\MagicLink\FirewallAwareMagicLoginLinker;
36+
use Symfony\Component\Security\Http\MagicLink\MagicLoginLinker;
37+
use Symfony\Component\Security\Http\MagicLink\MagicLoginLinkerInterface;
3438

3539
return static function (ContainerConfigurator $container) {
3640
$container->services()
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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\Component\DependencyInjection\Loader\Configurator;
13+
14+
use Symfony\Component\Security\Http\Authenticator\MagicLinkAuthenticator;
15+
use Symfony\Component\Security\Http\MagicLink\FirewallAwareMagicLoginLinker;
16+
use Symfony\Component\Security\Http\MagicLink\MagicLoginLinker;
17+
use Symfony\Component\Security\Http\MagicLink\MagicLoginLinkerInterface;
18+
19+
return static function (ContainerConfigurator $container) {
20+
$container->services()
21+
->set('security.authenticator.magic_link', MagicLinkAuthenticator::class)
22+
->abstract()
23+
->args([
24+
abstract_arg('the magic login linker instance'),
25+
service('security.http_utils'),
26+
abstract_arg('authentication success handler'),
27+
abstract_arg('authentication failure handler'),
28+
abstract_arg('options'),
29+
])
30+
31+
->set('security.authenticator.abstract_magic_login_linker', MagicLoginLinker::class)
32+
->abstract()
33+
->args([
34+
abstract_arg('authenticatable token storage'),
35+
service('router'),
36+
'%kernel.secret%',
37+
abstract_arg('lifetime'),
38+
abstract_arg('route name'),
39+
])
40+
41+
->set('security.authenticator.firewall_aware_magic_login_linker', FirewallAwareMagicLoginLinker::class)
42+
->args([
43+
service('security.firewall.map'),
44+
tagged_locator('security.authenticator.magic_linker', 'firewall'),
45+
service('request_stack'),
46+
])
47+
->alias(MagicLoginLinkerInterface::class, 'security.authenticator.firewall_aware_magic_login_linker')
48+
;
49+
};

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\MagicLinkLoginFactory;
3132
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\RememberMeFactory;
3233
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\RemoteUserFactory;
3334
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\X509Factory;
@@ -64,6 +65,7 @@ public function build(ContainerBuilder $container)
6465
$extension->addSecurityListenerFactory(new GuardAuthenticationFactory());
6566
$extension->addSecurityListenerFactory(new AnonymousFactory());
6667
$extension->addSecurityListenerFactory(new CustomAuthenticatorFactory());
68+
$extension->addSecurityListenerFactory(new MagicLinkLoginFactory());
6769

6870
$extension->addUserProviderFactory(new InMemoryFactory());
6971
$extension->addUserProviderFactory(new LdapFactory());
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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\Component\Security\Core\Exception;
13+
14+
/**
15+
* Thrown when a magic link is invalid.
16+
*
17+
* @author Ryan Weaver <ryan@symfonycasts.com>
18+
*/
19+
class InvalidMagicLinkAuthenticationException extends AuthenticationException
20+
{
21+
/**
22+
* {@inheritdoc}
23+
*/
24+
public function getMessageKey()
25+
{
26+
return 'Invalid or expired login link.';
27+
}
28+
}

0 commit comments

Comments
 (0)