Skip to content

Commit afd2c71

Browse files
committed
feature #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 ------- a8afe109d8 [Security] Magic login link authentication
2 parents ac91299 + 88842ec commit afd2c71

13 files changed

+770
-0
lines changed
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
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\Security\Http\Authenticator;
13+
14+
use Symfony\Component\HttpFoundation\Request;
15+
use Symfony\Component\HttpFoundation\Response;
16+
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
17+
use Symfony\Component\Security\Core\Exception\AuthenticationException;
18+
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
19+
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
20+
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
21+
use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface;
22+
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
23+
use Symfony\Component\Security\Http\HttpUtils;
24+
use Symfony\Component\Security\Http\LoginLink\Exception\InvalidLoginLinkAuthenticationException;
25+
use Symfony\Component\Security\Http\LoginLink\Exception\InvalidLoginLinkExceptionInterface;
26+
use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface;
27+
28+
/**
29+
* @author Ryan Weaver <[email protected]>
30+
* @experimental in 5.2
31+
*/
32+
final class LoginLinkAuthenticator extends AbstractAuthenticator implements InteractiveAuthenticatorInterface
33+
{
34+
private $loginLinkHandler;
35+
private $httpUtils;
36+
private $successHandler;
37+
private $failureHandler;
38+
private $options;
39+
40+
public function __construct(LoginLinkHandlerInterface $loginLinkHandler, HttpUtils $httpUtils, AuthenticationSuccessHandlerInterface $successHandler, AuthenticationFailureHandlerInterface $failureHandler, array $options)
41+
{
42+
$this->loginLinkHandler = $loginLinkHandler;
43+
$this->httpUtils = $httpUtils;
44+
$this->successHandler = $successHandler;
45+
$this->failureHandler = $failureHandler;
46+
$this->options = $options;
47+
}
48+
49+
public function supports(Request $request): ?bool
50+
{
51+
return $this->httpUtils->checkRequestPath($request, $this->options['check_route']);
52+
}
53+
54+
public function authenticate(Request $request): PassportInterface
55+
{
56+
$username = $request->get('user');
57+
58+
if (!$username) {
59+
throw new InvalidLoginLinkAuthenticationException('Missing user from link.');
60+
}
61+
62+
return new SelfValidatingPassport(
63+
new UserBadge($username, function () use ($request) {
64+
try {
65+
$user = $this->loginLinkHandler->consumeLoginLink($request);
66+
} catch (InvalidLoginLinkExceptionInterface $e) {
67+
throw new InvalidLoginLinkAuthenticationException('Login link could not be validated.', 0, $e);
68+
}
69+
70+
return $user;
71+
}),
72+
[]
73+
);
74+
}
75+
76+
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
77+
{
78+
return $this->successHandler->onAuthenticationSuccess($request, $token);
79+
}
80+
81+
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
82+
{
83+
return $this->failureHandler->onAuthenticationFailure($request, $exception);
84+
}
85+
86+
public function isInteractive(): bool
87+
{
88+
return true;
89+
}
90+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
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\Security\Http\LoginLink\Exception;
13+
14+
/**
15+
* @author Ryan Weaver <[email protected]>
16+
* @experimental in 5.2
17+
*/
18+
class ExpiredLoginLinkException extends \Exception implements InvalidLoginLinkExceptionInterface
19+
{
20+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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\Security\Http\LoginLink\Exception;
13+
14+
use Symfony\Component\Security\Core\Exception\AuthenticationException;
15+
16+
/**
17+
* Thrown when a login link is invalid.
18+
*
19+
* @author Ryan Weaver <[email protected]>
20+
* @experimental in 5.2
21+
*/
22+
class InvalidLoginLinkAuthenticationException extends AuthenticationException
23+
{
24+
/**
25+
* {@inheritdoc}
26+
*/
27+
public function getMessageKey()
28+
{
29+
return 'Invalid or expired login link.';
30+
}
31+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
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\Security\Http\LoginLink\Exception;
13+
14+
/**
15+
* @author Ryan Weaver <[email protected]>
16+
* @experimental in 5.2
17+
*/
18+
class InvalidLoginLinkException extends \Exception implements InvalidLoginLinkExceptionInterface
19+
{
20+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
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\Security\Http\LoginLink\Exception;
13+
14+
/**
15+
* @author Ryan Weaver <[email protected]>
16+
* @experimental in 5.2
17+
*/
18+
interface InvalidLoginLinkExceptionInterface extends \Throwable
19+
{
20+
}

LoginLink/ExpiredLoginLinkStorage.php

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
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\Security\Http\LoginLink;
13+
14+
use Psr\Cache\CacheItemPoolInterface;
15+
16+
/**
17+
* @final
18+
*/
19+
class ExpiredLoginLinkStorage
20+
{
21+
private $cache;
22+
private $lifetime;
23+
24+
public function __construct(CacheItemPoolInterface $cache, int $lifetime)
25+
{
26+
$this->cache = $cache;
27+
$this->lifetime = $lifetime;
28+
}
29+
30+
public function countUsages(string $hash): int
31+
{
32+
$key = rawurlencode($hash);
33+
if (!$this->cache->hasItem($key)) {
34+
return 0;
35+
}
36+
37+
return $this->cache->getItem($key)->get();
38+
}
39+
40+
public function incrementUsages(string $hash): void
41+
{
42+
$item = $this->cache->getItem(rawurlencode($hash));
43+
44+
if (!$item->isHit()) {
45+
$item->expiresAfter($this->lifetime);
46+
}
47+
48+
$item->set($this->countUsages($hash) + 1);
49+
$this->cache->save($item);
50+
}
51+
}

LoginLink/LoginLinkDetails.php

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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\Security\Http\LoginLink;
13+
14+
/**
15+
* @author Ryan Weaver <[email protected]>
16+
* @experimental in 5.2
17+
*/
18+
class LoginLinkDetails
19+
{
20+
private $url;
21+
private $expiresAt;
22+
23+
public function __construct(string $url, \DateTimeImmutable $expiresAt)
24+
{
25+
$this->url = $url;
26+
$this->expiresAt = $expiresAt;
27+
}
28+
29+
public function getUrl(): string
30+
{
31+
return $this->url;
32+
}
33+
34+
public function getExpiresAt(): \DateTimeImmutable
35+
{
36+
return $this->expiresAt;
37+
}
38+
39+
public function __toString()
40+
{
41+
return $this->url;
42+
}
43+
}

0 commit comments

Comments
 (0)