We recently discussed the benefits of passwordless authentication. Now let's take a look at how to implement passwordless authentication in Ruby on Rails
rails g migration add_login_token_to_users login_token:uuid
class MagicLoginsController < ApplicationController
def new; end
def create
if (user = User.find_by(login_token_params))
user.generate_login_token!
LoginMailer.with(user: user).magic_login_email.deliver_later
redirect_to(new_magic_login_path, notice: 'Please check your inbox')
else
redirect_to(new_magic_login_path, alert: 'It looks like that email is not registered')
end
end
private def login_token_params
params.require(:login_token).permit(:email)
end
end
Rails.application.routes.draw do
resources :magic_logins, only: %i[new create]
end
<%= form_for(User.new, url: magic_logins_path, method: :post do %>
<%= f.label :email %>
<%= f.email_field :email %>
<%= f.submit 'Send me a magic login link' %>
<% end %>
class User < ApplicationRecord
def generate_login_token!
update_column(:login_token, SecureRandom.uuid)
end
end
class LoginMailer < ApplicationMailer
def magic_login_email
user = params[:user]
@magic_login_url = magic_logins_url(token: user.login_token)
mail(to: user.email, subject: "Here's your login link")
end
end
<h1>Here's your login link</h1>
<%= link_to 'Log in now', @magic_login_url %>
class MagicLoginsController < ApplicationController
def index
redirect_to root_path unless params[:token].present?
user = User.find_by(login_token: params[:token])
if user
sign_in(user)
user.update_column(:login_token, nil)
redirect_to after_sign_in_path, notice: 'Logged in successfully!'
else
redirect_to new_magic_login_path, alert: 'The link you used was invalid. Please request a new login link'
end
end
end
rails g migration add_login_token_generated_at_to_users login_token_generated_at:datetime
class MagicLoginsController < ApplicationController
def index
redirect_to root_path unless params[:token].present?
user = User.find_by(login_token: params[:token])
if user
if user.login_token_generated_at < 15.minutes.ago
redirect_to new_magic_login_path, alert: 'The link you used has expired. Please request a new login link'
end
sign_in(user)
user.update_column(:login_token, nil)
redirect_to after_sign_in_path, notice: 'Logged in successfully!'
else
redirect_to new_magic_login_path, alert: 'The link you used was invalid. Please request a new login link'
end
end
end
Rails.application.routes.draw do
resources :magic_logins, only: %i[index new create]
end
Looking for a new challenge? Join Our Team
From us to your inbox weekly.
However I'm still puzzled : what if someone intercept the email (or whatever, forwarded, click on someone else's device...) ?
Should'nt we manage a two part token ? First part sent by email on request, while the other part is part of request response and therefore stored on the device the request has been made ?
This article just fleshes out the basics, you can take this in any direction you like. Email in insecure so you're right, certain measures can be taken to reduce the risks of exposing an account to malicious actors. For example, a lot of apps will limit the validity of a login token to 15 minutes.
However, if a users email account is compromised there's not much we can do to protect the user. In this case, even password based logins are just as vulnerable due to password resets emails. After generating a password reset, or a passwordless login, a malicious actor can change the email to their own address and then they have full control of the account. If this is a serious concern for your application then you should either be doing identity verification for important account changes, or allow your users to reclaim their accounts with some form of identity verification.
It is possible to manage a token's audience with Rails' MessageEncryptor now, however, if you restrict a token to a specific device or browser you are restricting the UX so it's important to make these choices deliberately. For example, a user may be logging in on their mobile device but generating a login email from their computer. Maybe they find that flow easier, who knows.
Should be easy to test with an integration test. https://guides.rubyonrails.org/testing.html#integration-testing
You’ll need to trigger the sign in email, then find the email in the outgoing messages, click on the link inside the email and then verify you are successfully logged in.
I recommend reading through the link I posted above and also search through stack overflow for similar tests. I’ll try and find time to edit the blog and add more details if I can
1) the index action in the MagicLoginsController was never created in the routes file (only :new and :create were);
2) what's generating new_login_token_path and after_sign_in_path?
3) how does it all start? are (prospective) users visiting /magic_logins/new ? (just an empty action without template at present)
I realize this wasn't meant to be a complete tutorial but I'd love to see it fleshed out a bit more.
Thanks for commenting. Looks like I missed/messed-up a couple of details
1) the index action in the MagicLoginsController was never created in the routes file (only :new and :create were);
Thanks, I overlooked this and have updated the article.
2) what's generating new_login_token_path and after_sign_in_path?
new_login_token_path is a typo, should be new_magic_login_path
after_sign_in_path is from Devise, it's widely used for handling user sessions so didn't explain this.
3) how does it all start? are (prospective) users visiting /magic_logins/new ? (just an empty action without template at present)
The new page is similar to a normal login page except instead of having email/username and password, you'd just have the email/username field.
I've added an example to the article.
Thanks for the updates! A few other (small) things I noticed:
- the view for the new action won't work as is, it needs to be:
<%= form_for(User.new, url: magic_logins_path, method: :post) do |f| %>
- the index action of the MagicLoginsController gives a double redirect error, so need to return from the first one:
redirect_to root_path and return unless params[:token].present?
- login_token_params didn't work, I changed to:
params.require(:user).permit(:email)