-
-
Notifications
You must be signed in to change notification settings - Fork 9.8k
[Security] Magic login link authentication #38177
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
Conversation
0524d02 to
ecb6f7d
Compare
| protected function createAuthProvider(ContainerBuilder $container, string $id, array $config, string $userProviderId) | ||
| { | ||
| throw new \Exception('The old authentication system is not supported with magic_link.'); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In this PR, I realized that the base class - AbstractFactory (which is tied to the OLD authentication system) needs to be "split" a little bit - there are a few places where we still require this class, even if you don't want to implement the old system (e.g. the security Configuration class calls the AbstractFactory::addConfiguration() method). Anyways, that's for another PR - I shouldn't need to extend AbstractFactory, but I still do until we clean that up.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we really need to extend AbstractFactory here? Can't we "just" implement the interfaces?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this may be more possible than I thought - it's on the todo list
|
|
||
| public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId): string | ||
| { | ||
| if (!$container->hasDefinition('security.authenticator.magic_link')) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was a new idea: why load all of the services for an authenticator if that authenticator is not used? Should save a small amount of time while building the container.
src/Symfony/Component/Security/Http/MagicLink/MagicLoginLinker.php
Outdated
Show resolved
Hide resolved
ecb6f7d to
50661a4
Compare
|
Why didn't you choose to implement this feature with a signed token? It would avoid using a db |
|
Because you can't revoke specific signed tokens? I would agree that having something as sensitive as logins without any control over it would not be a great way to implement it. |
src/Symfony/Bridge/Doctrine/Security/MagicLink/MagicLoginLinkTokenEntityTrait.php
Outdated
Show resolved
Hide resolved
src/Symfony/Bridge/Doctrine/Security/MagicLink/MagicLoginLinkTokenEntityTrait.php
Outdated
Show resolved
Hide resolved
src/Symfony/Bridge/Doctrine/Security/MagicLink/MagicLoginLinkTokenRepositoryTrait.php
Outdated
Show resolved
Hide resolved
src/Symfony/Bridge/Doctrine/Security/MagicLink/MagicLoginLinkTokenEntityTrait.php
Outdated
Show resolved
Hide resolved
...ny/Bundle/SecurityBundle/DependencyInjection/Security/Factory/EntryPointFactoryInterface.php
Show resolved
Hide resolved
src/Symfony/Component/Security/Http/MagicLink/MagicLinkTokenStorageInterface.php
Outdated
Show resolved
Hide resolved
src/Symfony/Component/Security/Http/MagicLink/MagicLoginLinkDetails.php
Outdated
Show resolved
Hide resolved
The token would have a very short TTL (like one hour)
This is how jwt works and so many people are using it (and most of the time this is for wrong reasons, but this is out of topic) |
I know JWT and understand that, thank you. That doesn't change the fact the you are passing control over an essential part of your application out of your control. This feature is fundamentally the same as a "password reset" procedure (as both allow login without knowing the credentials) – and for example Laravel decided to NOT just use signed URLs, but keep using a database-based system: laravel/framework#23706 Maybe there are some arguments that also apply here. |
|
I'm not too familiar with JWT tokens. If you have a token that's valid for 1h, how do you expire this? Usually you'd want a login url valid for maybe 10 to 30 minutes and only usable once. The latter doesn't seem possible with a token that can only expire after time. |
|
Regarding the linked Laravel issue, read laravel/framework#23706 (comment) which makes me think that they chose not to do it because they had an already working solution. But they do point out that having a database has some issues (when the table schema changes for instance). I think it's worth exploring a solution without a DB? |
|
I think it would be beneficial to be able to
In case of just signed URLs, you could invalidate it by changing your signing secret, but that seems rather excessive. Invalidation after usage doesn't seem to be possible at all. In this case I would be as careful as possible, as there is no real way back, if you gave control away to the user. |
You can not natively. If you want to do so, you have to save all rejected tokens in a DB, and for each login (so each API call) you look at your DB if the token is expired. And... you totally defeat the purpose of JWT. Anyway, JWT is out of topic here :) This was just an example to demonstracte that "signed token" can not be revoked natively About signed token VS token in DB, I think signed token is good enough. I totally agree that DB token are more secure though. BUT Let's be pragmatic. In theory, you can revoke a DB token, but is this a real use case? What would be the workflow?
IMHO, it's acceptable to use a signed token with a very short validity (lets say 15 minutes). We could / should mention that flaw in the documentation and that's all. And if someone want something in the DB, they could implement it on their own. So let's try to summary everything to take a decision:
Note: in the signed token, we could also add the User IP address to increase the security. |
I would advise against using this very short durations because emails can take time to be delivered (eg when the destination server has greylisting enabled) |
For that reason, I wouldn't include user IP address in the signature either. The probability of changing IPs is not zero if we consider a timespan of several hours. |
|
Hi! Magic login links are still relatively new and there isn't clear guidance on security aspects. Some people show using JWT (so, links that cannot be expired after a single use) while others are adamant that they must expire after the first use. Reset passwords links should expire after a single use, and a magic link is similar to those. In the absence of clear guidance, we should see what the biggest services do: both Auth0 and Slack only allow a magic link to be used once. That is enough for me to think that we should "err" on the side of caution and stored tokens in the database where they can be invalidated after 1 use. I still think the users will be very easy to implement. In both cases, a Maker command will take care of everything. With a database layer, the only added files are the entity & repository classes, both of which are quite small thanks to a trait in core (and of course, you could implement your own storage layer). In reset-password-bundle we haven't gotten any complaints or pushback yet on requiring persistence. |
|
|
||
| private function generateRandomString(int $bytes): string | ||
| { | ||
| return strtr(base64_encode(random_bytes($bytes)), '+/', '-_'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could use ByteString::fromRandom() here, if depending on the String component is not a problem.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, that's a nice function - I would love to use that.
What do others think? It would mean adding a small dependency to all of symfony/security-http.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure it really worth it
But I'm not against it
I don't think we should consider this. Magic login is a login action, it should not take more than 1 or 2 minutes. Otherwise it's not at all UX friendly enough to ever login. Which is also a reason why I originally considered using the Notifier component here; that would allow switching to an alternative (e.g. sms or notification) using the same authenticator. |
src/Symfony/Component/Security/Http/Authenticator/MagicLinkAuthenticator.php
Outdated
Show resolved
Hide resolved
src/Symfony/Bridge/Doctrine/Security/MagicLink/MagicLoginLinkTokenRepositoryTrait.php
Outdated
Show resolved
Hide resolved
c221259 to
77b4c8a
Compare
|
This is ready! Probably the biggest decision is this: should we accept this into core? Or would we prefer this as its own bundle (e.g. |
|
Regarding where to merge this feature, I think we should have a broader discussion about such features. Would we want to have totp auth in Symfony or rely on a third party bundle? Would we want to have the security bundles from SymfonyCasts in Symfony itself? I can see pros and cons, it's not clear cut to me. |
|
While the features are fairly situational, I'd say the same goes for things like ldap login. I can already think of 2 different scenarios where I'd want to use this feature in my application. I'm not against putting this in a 3rd party bundle, though I think it would be awesome to have it work out-of-the-box in the code of Symfony. |
|
Both solutions are OK to me (because we know that However, I'd prefer to keep this (and other common features) inside the Symfony security package. "Decoupling" every feature and creating "micro packages" would be worse in my opinion (for us to maintain, for us to document, for developers to use, etc.). |
|
I'm in favor of merging this in core. Regarding the general question, I'd say that only bundles/features that have a strong dependency on third-party libs should live outside of core. It's mostly features that implement complex protocols like JWT, 2FA, OAuth, WebAuthn, ... those are features that should build on robust implementations, proven by crypto/security experts. |
|
+1 for having it in core as well |
wouterj
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please also mark all new classes as @experimental in Symfony 5.2.
As shortly discussed on Slack: I think we should either build on top of the current user providers, or directly use the current user providers - in favor of the new LoginLinkUserHandler.
src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginLinkFactory.php
Show resolved
Hide resolved
src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginLinkFactory.php
Outdated
Show resolved
Hide resolved
src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator_login_link.php
Outdated
Show resolved
Hide resolved
src/Symfony/Component/Security/Http/Authenticator/LoginLinkAuthenticator.php
Outdated
Show resolved
Hide resolved
src/Symfony/Component/Security/Http/LoginLink/ExpiredLoginLinkStorage.php
Show resolved
Hide resolved
src/Symfony/Component/Security/Http/LoginLink/ExpiredLoginLinkStorage.php
Show resolved
Hide resolved
src/Symfony/Component/Security/Http/LoginLink/LoginLinkDetails.php
Outdated
Show resolved
Hide resolved
0f5f4e0 to
f43b8fc
Compare
src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginLinkFactory.php
Show resolved
Hide resolved
| * @author Ryan Weaver <ryan@symfonycasts.com> | ||
| * @experimental in 5.2 | ||
| */ | ||
| class LoginLinkDetails |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this implements \Stringable?
src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginLinkFactory.php
Outdated
Show resolved
Hide resolved
|
This is ready again! I'm quite proud of this version :). The PR description has been fully updated. Highlights:
Cheers! |
wouterj
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks great!
If you add a lastAuthenticatedAt property to the signature properties and user, you can get the one-time pass and "invalidate all historic links upon login" functionality for free. Seems like this is a very flexible approach.
88ce8e8 to
d8ed065
Compare
|
Btw, this PR should be ready |
|
I really like the new design. I did not review the code (yet) But 👍🏼 for the feature |
yceruto
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Tested and works great !
d653ee4 to
a8afe10
Compare
|
Thank you @weaverryan. |
|
Hello there, I would like to give my point of view regarding the DB vs Token discussion. Would it be possible to allow both approaches for this feature ? I do think both approaches are both relevant. In my opinion, I would rather use the database approach. But for some Symfony projects, the app doesn't have its own database (tiny app with in memory user) or authentication is shared cross multiple services. In such case, the token approach would be the only way to go. If Symfony decide to support two storages, would it allows switching from one to the other ? Developers would start using database then would want to switch to token (JWT style). For a specific period, they would allow both link storages to not invalidate previously generated links. If you agree with this, should I create a new feature request to address this improvement ? I'm available on Slack. |
|
Hi!
Cheers! |
|
@weaverryan thx for your response. Regarding your point, it is clear to me that my understanding is incomplete. I'll address my points later if needed in other feature request, etc... |

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 with the "enter your email" form and a route for the "check" functionality (similar to
form_login)B) Activate in
security.yaml:Done! This will generate a URL that looks something like this:
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_propertiesabove.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_usesconfig 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
lastLoginLinkSentAtfield toUserand including this insignature_properties.C) We do invalidate all links if the user's email address is changed (assuming the
emailis included insignature_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: