abevoelker / devise-passwordless

Devise passwordless logins using emailed magic links
MIT License
201 stars 37 forks source link

Multi-device passwordless flow #10

Closed tleish closed 1 year ago

tleish commented 2 years ago

Any interest in updating the gem to support the flow to allow authenticating a user logging in on device A while authenticating on devise B?

abevoelker commented 2 years ago

Sure, if someone wanted to work on it. I think not everyone would want it though so the ergonomics would have to be opt-in / configurable

tleish commented 2 years ago

Sure, if someone wanted to work on it.

That was my plan, just wanted to confirm before working on an MR.

I think not everyone would want it though so the ergonomics would have to be opt-in / configurable

Agreed.

Here's some of the requirements I considering:

MagicLinkController#create: Login

  1. User enters email
  2. Ruby resets the session (to protect against session fixation) with session[:expires_at] = Time.current + Devise.passwordless_login_within
  3. Set session['waiting_for_authorized_magic_link_user']
  4. Gem includes new session_id in the LoginToken
  5. Redirect user to waiting for magic link auth.

MagicLinkController#update: Validate MagicLink

  1. User opens magic link URL
  2. Ruby get session_id from LoginToken and checks if session_id still exists and
  3. If session exists, updates session with session['authorized_by_magic_link'] = true
session = find_session(token[:data][:session_id])
return unless session
session['authorized_magic_link_user'] = 'user@example.com'
save_session(token[:data][:session_id], session)
  1. If current_user.session_id == LoginToken[data][session_id], then redirect to waiting for magic link auth page.

MagicLinkController#index: Waiting for Magic Link Auth

  1. If session['waiting_for_authorized_magic_link_user'] == true, show spinner with "Email sent to abc***@gmail.com. Click authentication link to complete login."
  2. If session['waiting_for_authorized_magic_link_user'] == false, flash error and redirect to login page
  3. If session['authorized_magic_link_user'], expire existing session and authenticate user to new session using session['authorized_magic_link_user'] and redirect to home page.

Refresh Options for Waiting for Magic Link Auth

Additional Thoughts

Any thoughts, feedback or red flags?

abevoelker commented 2 years ago

Thank you for offering to work on this and all the thought put into the design. Especially your consideration of the security implications with session fixation and the thought put into maximally backwards-compatible page refresh options. Much appreciated.

On first read (I may need to ruminate on it a bit more), in broad strokes I do like your approach but a couple things come up in my mind.

Firstly, the bit in the process that begins around "checks if session_id still exists" (with the find_session pseudocode) - that seems to presume a database of some sort that stores sessions (correct me if I'm misreading that). That will not work with the default Rails stateless session store (CookieStore) where the session is stored entirely in the (signed) browser cookie. There's no centralized database of sessions to look up IDs in proactively.

Rather, to verify that a session is valid you'd basically have to wait expectantly for a later request to come in with the expected session ID or whatever.

So at that point, you have to temporarily store that tidbit of data that the magic link was clicked until the initiating session pops its head up again.

I haven't had any chance to play with ActionCable yet but perhaps that would be the best approach that would avoid having to store temporary state. What if when a device initiates the login with MagicLinkController#create, that device becomes subscribed to a temporary (invalid after Devise.passwordless_login_within), one-off private channel between it and the backend. When the magic link is clicked by the other device, the backend sends a message to that channel notifying the initial device of success and calling Devise's sign_in to log them in. Actually I'm not sure if the backend is able to do processing at that time to upgrade them to a logged-in user, but if not another strategy could be broadcasting a valid login token to that private channel that the device could then use to log itself in. If that makes sense?

Lastly, this is only occurring to me now but I wonder about the enhanced risk of email clients prefetching links found in emails with this multi-device flow. If certain email clients are fetching magic links then it's not exactly great for the typical magic link flow as the email client becomes logged in as that user when fetching the links. But unless the email client decides to start behaving like a human it's not as bad as it could be. For this multi-device flow it could be especially disastrous though as the email client would be unknowingly logging in a third party device behind it. Sort of a confused deputy attack?

Perhaps for this reason the multi-device flow should require magic links to be validated using a POST request rather than GET, with the initial GET displaying a button the user has to click to perform the POST (or something like that).

tleish commented 2 years ago

Thank you for offering to work on this and all the thought put into the design. Especially your consideration of the security implications with session fixation and the thought put into maximally backwards-compatible page refresh options. Much appreciated.

On first read (I may need to ruminate on it a bit more), in broad strokes I do like your approach but a couple things come up in my mind.

Firstly, the bit in the process that begins around "checks if session_id still exists" (with the find_session pseudocode) - that seems to presume a database of some sort that stores sessions (correct me if I'm misreading that). That will not work with the default Rails stateless session store (CookieStore) where the session is stored entirely in the (signed) browser cookie. There's no centralized database of sessions to look up IDs in proactively.

Rather, to verify that a session is valid you'd basically have to wait expectantly for a later request to come in with the expected session ID or whatever.

So at that point, you have to temporarily store that tidbit of data that the magic link was clicked until the initiating session pops its head up again.

Correct, this strategy requires something to store state on the backend (Redis, database, etc)

I haven't had any chance to play with ActionCable yet but perhaps that would be the best approach that would avoid having to store temporary state. What if when a device initiates the login with MagicLinkController#create, that device becomes subscribed to a temporary (invalid after Devise.passwordless_login_within), one-off private channel between it and the backend. When the magic link is clicked by the other device, the backend sends a message to that channel notifying the initial device of success and calling Devise's sign_in to log them in. Actually I'm not sure if the backend is able to do processing at that time to upgrade them to a logged-in user, but if not another strategy could be broadcasting a valid login token to that private channel that the device could then use to log itself in. If that makes sense?

ActionCable is great, but in my experience it should be used to enhance the user experience. If it's required for the application to work, then this solution might fail. Also, many Rails instances do not enable ActionCable. Having ActionCable as an option (not requirement) is a better approach IMHO. I assume applications will more likely have a backend persistent storage than they will ActionCable.

Lastly, this is only occurring to me now but I wonder about the enhanced risk of email clients prefetching links found in emails with this multi-device flow. If certain email clients are fetching magic links then it's not exactly great for the typical magic link flow as the email client becomes logged in as that user when fetching the links. But unless the email client decides to start behaving like a human it's not as bad as it could be. For this multi-device flow it could be especially disastrous though as the email client would be unknowingly logging in a third party device behind it. Sort of a confused deputy attack?

Perhaps for this reason the multi-device flow should require magic links to be validated using a POST request rather than GET, with the initial GET displaying a button the user has to click to perform the POST (or something like that).

I wasn't aware of this, I'm glad you pointed it out. Seems like the GET URL in the email would show reveal a webpage with a POST "Confirm" button. Optionally, you could even add a CAPTCHA on this form if you really want to avoid bots.

abevoelker commented 2 years ago

Fair enough, I'll defer to your experience with ActionCable since I haven't really used it. For what it's worth I'd be okay with requiring a certain feature if it makes implementation significantly easier. But it sounds like that may not be the case here re: ActionCable.

The more I think about this, as we are discussing in terms of abstract devices, the need for persistent storage and adding a button to press, it's reminding me a lot of reinventing an OAuth2 flow. Do you think this use case could be satisfied by using existing OAuth2 libraries that integrate with Devise or do you see this as a different use case?

tleish commented 2 years ago

Do you think this use case could be satisfied by using existing OAuth2 libraries that integrate with Devise or do you see this as a different use case?

I think it's half-way between an OAuth2 and a simple passwordless magicklink implementation.

I think an app might initially have:

  1. passwordless authentication

Then as it matures:

  1. passwordless authentication
  2. Social Login A
  3. Social Login B

If you only offered social login options, users who do not have one of the social accounts would be required to and create a new social account with a username and password. At this point, just have them create a local username and password and you've lost the simplicity and less friction of a passwordless login.

For users without a social login option, you could say "use passwordless if you are on the same device you want to login, but use username and password if on a devise that does not have email", but that seems like a silly explanation to suggest to users.

FYI: Medium.com provides both social and a passwordless login option image

abevoelker commented 2 years ago

My fault for not being clearer. I didn't mean social login to external sites, I meant that your Rails app itself would be an OAuth2 provider, likely using a gem like doorkeeper.

I was imagining a flow sort of like the OAuth2 "device flow" commonly seen when you're say authorizing an AppleTV to access your account. In that flow you generate a magic code that you then enter while logged in to your account, and that grants access to your account to that device (which you can later revoke):

youtube-1

youtube-2

There is a doorkeeper extension gem that enables that type of flow (example app).

The difference I was thinking would be you wouldn't be entering a magic code, rather the magic link you click in your email would directly prompt you that Device A is requesting access to your account, and you're prompted with Allow and Deny buttons (i.e. skip right to the second picture I posted above).

Thinking about it a little more, I think the use case is a little different after all in that I think what you're proposing is Device B's access being totally transient / incidental, i.e. the user isn't managing long-term access across different devices, from the user's perspective we're only logging in on Device A. But hopefully you can see why my mind made the comparison.

Long story short I say green light to proceed with your original proposal, if you run into any issues or have more questions feel free to post here. I agree with your original inclination to try putting this under its own controller if possible. Thanks and good luck! :tada:

tleish commented 2 years ago

Make sense. The conversation has been very helpful and changed some of the way I planned to implement it.

tleish commented 2 years ago

Correct me if I'm wrong, but this addition seems slightly more secure. If a hacker (or the bot of a hacker) gained access to the email (traveling through multiple servers) and opened the magic link within the timeframe, they would be able to login as that user. With this magick link confirmation, clicking on the link would login the original request.

I get that at that point, a hacker could always go to the website and enter the email again while capturing the email a 2nd time at which point the argument suggests it's just as secure/insecure as reset password.

abevoelker commented 2 years ago

Sorry, which addition are you referring to being more secure?

abevoelker commented 1 year ago

Just doing some repo tidying up - closing this issue since the conversation seems to have run its course. Feel free to reopen if you disagree. :wave: