A popular feature in applications is the ability to request a magic link to your account's email address that will log you into the application without a password. I guess it's known as passwordless login or magic links.
After adding the reset password feature to the authentication system in our rails application, I thought it'd be fun to add a magic login link. It turns out it was pretty easy.
Adding Login Tokens to the User Model
The first step is setting up the User
model to be able to store a token and an expiration for that token that we can email to the user to trigger the login flow.
rails g migration AddLoginTokenToUsers login_token:string login_token_expires_at:datetime
class AddLoginTokenToUsers < ActiveRecord::Migration[7.1]
def change
add_column :users, :login_token, :string
add_column :users, :login_token_expires_at, :datetime
end
end
We can then build another model to encapsulate the logic for generating and expiring the token. I'm going to put the entire LoginToken
model here at once but we'd normally build this functionality as we need it.
class LoginToken
include ActiveModel::Model
attr_accessor :user, :email
def save
@user = User.find_by(email: email)
if @user
@user.login_token = SecureRandom.urlsafe_base64
@user.login_token_expires_at = 1.hour.from_now
@user.save
UserMailer.login_link(@user).deliver_later
end
end
def expire!
@user.update(login_token: nil, login_token_expires_at: nil)
end
def self.find_by_valid_token(token)
user = User.where("login_token = ? AND login_token_expires_at > ?", token, Time.current).first
new.tap { |l| l.user = user } if user
end
end
We'll use the methods as follows:
LoginToken#save
will generate a token and expiration for the user and email them a link to login. This will be called when the user requests a login link.LoginToken#expire!
will remove the token and expiration from the user. This will be called when the user logs in.LoginToken.find_by_valid_token
will find a user by the token if it's valid and return an instance ofLoginToken
with the user set. This will be called when the user clicks the link in the email.
With this model set, we can setup the controller and views.
Login Links Controller
Let's generate a controller to handle requesting the magic login link. It'll have 3 actions:
new
will render a form to request the login linkcreate
will create the login token and email the usershow
will find the user by the token and log them in. This is an odd choice/name for this action but it's what I'm going with. It stays RESTful, I guess, and I couldn't come up with something better. Maybeuse
would've been better.
rails g controller LoginLinks new create show
We can route the actions as follows:
Rails.application.routes.draw do
resources :login_links, only: [:new, :create, :show]
end
Here's the controller:
app/controllers/login_links_controller.rb
class LoginLinksController < ApplicationController
def show
@login_token = LoginToken.find_by_valid_token(params[:id])
if @login_token && @login_token.user
@login_token.expire!
sign_in(@login_token.user)
redirect_to root_path, notice: "You have successfully logged in!"
else
redirect_to new_login_link_path, alert: "Your login link has expired. Please request a new one."
end
end
def new
@login_token = LoginToken.new
end
def create
@login_token = LoginToken.new(login_token_params)
if @login_token.save
redirect_to root_path, notice: "Login link sent!"
else
flash.now[:alert] = "There was a problem sending the login link."
render :new, status: 422
end
end
private
def login_token_params
params.require(:login_token).permit(:email)
end
end
The form to request a login link looks like:
app/views/login_links/new.html.erb
<%= render_form_for(@login_token, url: login_links_path) do |f| %>
<div class="grid gap-2">
<div class="grid gap-1">
<%= f.label :email, class: "sr-only" %>
<%= f.email_field :email, placeholder: "name@example.com",
autocomplete: :email,
autocorrect: "email",
autocapitalize: "none" %>
</div>
<%= f.submit "Send Login Link" %>
</div>
<% end %>
That's really it, everything else is pretty standard, there's the mailer view:
<p>Hello,</p>
<p>You requested a link to login.</p>
<p><%= link_to 'Click here to login', login_link_url(@user.login_token) %></p>
<p>If you didn't request this, please ignore this email.</p>
<p>This link is valid for 1 hour.</p>
As you can see, it is all very similar to Reset Password functionality.
Conclusion
Once you get the hang of dealing with these tokens, building things like confirmable, resetable, passwordless login, gets easier and easier. If you wanted you could even build a token concern that encapsulates all of this functionality and use it for all of these features.
Refactor: Tokenable
I decided to refactor the token functionality into a concern. I'm not sure if it's better or not but it's a little cleaner.
app/models/concerns/tokenable.rb
module Tokenable
extend ActiveSupport::Concern
included do
include ActiveModel::Model
attr_accessor :user, :email
end
def save
@user = User.find_by(email: email)
if @user
@user.send("#{self.class.token_field}=", SecureRandom.urlsafe_base64)
@user.send("#{self.class.token_field}_expires_at=", 1.hour.from_now)
@user.save
end
end
def expire!
@user.update("#{self.class.token_field}": nil, "#{self.class.token_field}_expires_at": nil)
end
class_methods do
def find_by_valid_token(token)
user = User.where("#{token_field} = ? AND #{token_field}_expires_at > ?", token, Time.current).first
new.tap { |l| l.user = user } if user
end
def token_field
@token_field ||= name.to_s.underscore
end
end
end
```git
With this the PasswordReset and LoginToken models are much simpler:
```ruby
class PasswordResetToken
include Tokenable
end
class LoginToken
include Tokenable
end
In order to use them, I had to make some naming changes to keep things introspectable and metaprogrammable but the conventions make sense.
Would love feedback on this concern.
Hope these posts have been helpful!