Building a Magic Login Link in Rails

Building a Magic Login Link in Rails

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.

app/models/login_token.rb

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 of LoginToken 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.

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 link
  • create will create the login token and email the user
  • show 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. Maybe use 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!

Did you find this article valuable?

Support Avi Flombaum by becoming a sponsor. Any amount is appreciated!