Skip to content

Conversation

@weaverryan
Copy link
Member

@weaverryan weaverryan commented Sep 13, 2020

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 with the "enter your email" form and a route for the "check" functionality (similar to form_login)
B) Activate in security.yaml:

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/verify?user=weaverryan@gmail.com&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:

  • 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?

protected function createAuthProvider(ContainerBuilder $container, string $id, array $config, string $userProviderId)
{
throw new \Exception('The old authentication system is not supported with magic_link.');
}
Copy link
Member Author

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.

Copy link
Member

@fabpot fabpot Sep 16, 2020

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?

Copy link
Member Author

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')) {
Copy link
Member Author

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.

@weaverryan weaverryan force-pushed the magic_link_authentication branch from ecb6f7d to 50661a4 Compare September 13, 2020 17:14
@lyrixx
Copy link
Member

lyrixx commented Sep 13, 2020

Why didn't you choose to implement this feature with a signed token? It would avoid using a db

@apfelbox
Copy link
Contributor

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.

@nicolas-grekas nicolas-grekas added this to the next milestone Sep 14, 2020
@lyrixx
Copy link
Member

lyrixx commented Sep 14, 2020

Because you can't revoke specific signed tokens?

The token would have a very short TTL (like one hour)

I would agree that having something as sensitive as logins without any control over it would not be a great way to implement it

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)

@apfelbox
Copy link
Contributor

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.

@linaori
Copy link
Contributor

linaori commented Sep 14, 2020

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.

@fabpot
Copy link
Member

fabpot commented Sep 14, 2020

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?

@apfelbox
Copy link
Contributor

apfelbox commented Sep 14, 2020

I think it would be beneficial to be able to

  • invalidate specific tokens
  • allow only a single usage per token

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.
I understand however, that the ease of usage (and the fact that it's optional) shifts the decision in the direction of just signed URLs.

@lyrixx
Copy link
Member

lyrixx commented Sep 14, 2020

I'm not too familiar with JWT tokens. If you have a token that's valid for 1h, how do you expire this?

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?

  1. Someone ask a token
  2. then say "Oh, now I don't want this token !"
  3. Contact the support to delete it,
  4. Hope they will delete in within XX minutes (if they don't, anyway the link will not work)

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:

Pros Cons
DB * works only once
* revocable
* need a DB
* more complex (core)
* need custom code (userland)
* more complex (userland)
signed * does't need custom code
* very simple to implement (Core)
* very simple to implement (User land)
* does not need DB
* can not be revoked
* works many times

Note: in the signed token, we could also add the User IP address to increase the security.

@nicolas-grekas
Copy link
Member

very short validity (lets say 15 minutes)

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)

@javiereguiluz
Copy link
Member

emails can take time to be delivered

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.

@weaverryan
Copy link
Member Author

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.

Screen Shot 2020-09-14 at 9 45 43 AM

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)), '+/', '-_');
Copy link
Member

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.

Copy link
Member Author

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.

Copy link
Member

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

@wouterj
Copy link
Member

wouterj commented Sep 14, 2020

emails can take time to be delivered

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.

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.

@weaverryan weaverryan force-pushed the magic_link_authentication branch from c221259 to 77b4c8a Compare September 16, 2020 10:35
@weaverryan
Copy link
Member Author

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. symfonycasts/magic-link)?

@fabpot
Copy link
Member

fabpot commented Sep 16, 2020

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.

@linaori
Copy link
Contributor

linaori commented Sep 16, 2020

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.

@javiereguiluz
Copy link
Member

javiereguiluz commented Sep 16, 2020

Both solutions are OK to me (because we know that symfonycasts/* packages are of high quality and they will be maintained for newer Symfony versions).

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.).

@chalasr
Copy link
Member

chalasr commented Sep 16, 2020

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.
For the rest, it's subjective (whether it is common enough, well implemented, and the core team is comfortable enough for maintaining it).

@fabpot
Copy link
Member

fabpot commented Sep 16, 2020

+1 for having it in core as well

Copy link
Member

@wouterj wouterj left a 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.

@weaverryan weaverryan force-pushed the magic_link_authentication branch from 0f5f4e0 to f43b8fc Compare September 29, 2020 00:47
* @author Ryan Weaver <ryan@symfonycasts.com>
* @experimental in 5.2
*/
class LoginLinkDetails
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this implements \Stringable?

@weaverryan
Copy link
Member Author

This is ready again! I'm quite proud of this version :). The PR description has been fully updated. Highlights:

  1. No storage - everything is done with a signed URL (it doesn't actually use UriSigner, but the same concept). The user loading uses the existing user provider system.
  2. A cache is used to store "token usage" so that you can limit tokens to be used a set number of times
  3. The new signature_properties allows the user to configure which fields on User should invalidate all existing tokens when changed. Common examples would be [id, email, password]. Assuming User::getUsername() returns a true username (not an email or something else), then this would invalid links if the email address changed, the password changed or if suddenly the username were connected with a different "id" in the database. You can even use this to invalidate all existing login links with an extra field on user (I describe this in the PR description).

Cheers!

Copy link
Member

@wouterj wouterj left a 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.

@weaverryan weaverryan force-pushed the magic_link_authentication branch 2 times, most recently from 88ce8e8 to d8ed065 Compare September 30, 2020 13:05
@weaverryan
Copy link
Member Author

Btw, this PR should be ready

@lyrixx
Copy link
Member

lyrixx commented Oct 2, 2020

I really like the new design. I did not review the code (yet) But 👍🏼 for the feature

Copy link
Member

@yceruto yceruto left a 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 !

@fabpot fabpot force-pushed the magic_link_authentication branch from d653ee4 to a8afe10 Compare October 3, 2020 06:23
@fabpot
Copy link
Member

fabpot commented Oct 3, 2020

Thank you @weaverryan.

@fabpot fabpot merged commit 534466d into symfony:master Oct 3, 2020
@weaverryan weaverryan deleted the magic_link_authentication branch October 3, 2020 17:20
@walva
Copy link
Contributor

walva commented Oct 4, 2020

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.
I am working on a project where the security is shared cross domain and is managed by a JS middleware even if the user service, which is responsible to validate the password, is written in Symfony. We decided to use a JWT style authentication.

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.

security:
    enable_authenticator_manager: true
    # ...
    firewalls:
        # ...
        main:
            # ...
            login_link:
                check_route: 'magic_link_verify'
                signature_properties: [id, password, email]
                # allow both token or database
                default_signature_storage: token
                link_storages:
                    database:
                        enabled: "today < 31 december 2020" # use expression language ?
                        properties: [id, password, email]
                        ttl: 24 hours
                    token:
                        enabled: true
                        properties: [password, email]
                        ttl: 12 hours
                        token_storage: ~ # cache.app | other cache | redis | other ?

If you agree with this, should I create a new feature request to address this improvement ? I'm available on Slack.

@weaverryan
Copy link
Member Author

Hi!

  1. it is “sort of” possible to do database storage with the current implementation. But what’s stored is not the token itself, but a list of tokens that should be invalidated. I’m not sure that’s what you need, but wanted to mention it.

  2. in general, what is your need for the database solution?

  3. if you do have a valid use case, it would probably be fine to implement an extension point of some sort (not sure exactly what) so that the behavior of the login link handler could work with both the current signed url or a database. A PR would be welcomed. But I’m not sure we would want full integration (I.e. making database storage a first class citizen that you could configure to activate) until we have more people demanding it.

Cheers!

@nicolas-grekas nicolas-grekas modified the milestones: next, 5.2 Oct 5, 2020
@fabpot fabpot mentioned this pull request Oct 5, 2020
@walva
Copy link
Contributor

walva commented Oct 6, 2020

@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...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.