Dev

Build Magic link authentication for Devise natively in Rails 7.1 thanks to generates_token_for

Profil Picture

Guillaume Briday

3 minutes

Today, I decided to implement a Magic Link authentication system for my SaaS app, Timecop-app. The app’s authentication is powered by Devise and Rails 7.2.

Rails 7.1 introduced several new features, but the one we're focusing on here is the generates_token_for method. This feature is particularly useful because it lets us create signed tokens with expiration dates, all without needing to modify the database.

While there are many gems available that add passwordless authentication to Devise, I chose not to install any additional gems. With Rails' native capabilities, it's now straightforward to implement this functionality without extra dependencies, which is awesome!

Many of these gems haven’t been updated in months or years, and some come with unnecessary complexity. However, if you're looking for a gem with extra features, devise-passwordless is probably the best option.

Configure your model

As mentioned earlier, there's no need to update the database. You simply need to implement the generates_token_for method in the User model:

# app/models/user.rb

class User < ActiveRecord::Base
  generates_token_for :magic_login, expires_in: 15.minutes do
    # The magic_login token will expire every time the user signs in.
    last_sign_in_at
  end

  scope :confirmed, -> { where.not(confirmed_at: nil) }
end

This setup ensures that the magic link token expires 15 minutes after it's generated or when the user signs in, whichever comes first.

Implement the logic in the controller

Next, we'll implement a controller to handle the logic for Magic Link authentication. The process is straightforward and involves setting up a basic CRUD controller with a show method to sign in the user, a new method to display the form, and a create method to send the token via a MagicLoginMailer.

Here’s how you can set it up:

# app/controllers/magic_logins_controller.rb

class MagicLoginsController < ApplicationController
  def new
  end

  def create
    user = User.confirmed.find_by(email: params[:email]) # Restrict search to confirmed users to prevent spam.

    MagicLoginMailer.login(user).deliver_later if user.present?

    redirect_to new_magic_logins_path, notice: t('devise.magic_logins.successful.created')
  end
end

Add the form to your views

Now, add the form to your views to generate and send the magic link:

<%# app/views/magic_logins/new.html.erb %>

<%= form_with url: magic_logins_path do |f| %>
  <%= f.label :email %>
  <%= f.email_field :email,
                    required: true,
                    autocomplete: 'email' %>

  <%= f.submit t('devise.sessions.new.send_magic_link') %>
<% end %>

This setup allows users to request a magic link by entering their email, which is then processed and sent to them via email.

Update the routes

Finally, ensure your routes are configured correctly by adding the resource. Since we're dealing with a single token without an /:id, the resource must be singular:

resource :magic_logins, only: %i[show new create]

Implement throttling to mitigate spam

To limit abusive requests, you can utilize the rack-attack gem for request throttling. Add the following configuration to throttle the new endpoint and help prevent spam:

# config/initializers/rack_attack.rb

class Rack::Attack
  throttle('magic_logins/create', limit: 5, period: 15.minutes) do |request|
    request.ip if request.path == '/magic_logins' && request.post?
  end
end

To send the magic link to users, let's create a Mailer that handles this process:

# app/mailers/magic_login_mailer.rb

class MagicLoginMailer < ApplicationMailer
  def login(user)
    @user = user
    @token = user.generate_token_for(:magic_login) # Generate the temporary token

    mail(to: user.email, subject: t('magic_login_mailer.login.subject'))
  end
end

Create the mailer's views

Next, create the view that will be used in the email sent to the user:

# app/views/magic_login_mailer/login.html.erb

<%= link_to magic_logins_url(token: @token) do %>
  <%= t('devise.sessions.new.sign_in_with_magic_link') %>
<% end %>

Sign in the user with Devise

To complete the Magic Link authentication, we'll implement the show method in our MagicLoginsController to sign in the user when they click the link:

# app/controllers/magic_logins_controller.rb

class MagicLoginsController < ApplicationController
  def show
    user = User.find_by_token_for(:magic_login, params[:token])

    if user.present?
      sign_in(user) # This will invalidate the token by updating `last_sign_in_at`.

      redirect_to root_path, notice: t('devise.sessions.signed_in')
    else
      flash[:alert] = t('devise.failure.magic_link_expired')

      redirect_to new_magic_logins_path
    end
  end

  # ...
end

And that's it! It's as simple as that.

With Rails 7.1+, there's no need for extra gems that may be outdated or overly complex. The built-in features make setting up Magic Link authentication straightforward and efficient.

Simplify your time tracking with Timecop

Timecop is a time tracking app that brings simplicity in your day to day life.

Timecop projects