Passwordless Authentication in Ruby on Rails with Devise

With the increasing number of daily routine mobile/web applications people use, password management becomes an issue. Almost all applications now provide social login, but some people would prefer not to use that method.

Account hacking and password leaking are also major concerns in the technical world. This article will guide you through implementing a login/registration system that does not require a password. Rather it will send you an email with a confirmation token every time you try to login. No memorization required, no password to leak, no hacking.

These are the key takeaways from this article:

  1. Overriding Devise views
  2. Overriding Devise controllers
  3. Implementing an authentication strategy with Devise

Many people are familiar with the first two, but the third is seen less often, because not all applications require customized authentication systems.

If you are starting from scratch and devise gem is not yet added to the Rails application, go ahead and follow the instructions on their official documentation. Before migrating the database, uncomment the following lines from db/migrate/20200411090959_devise_create_users.rb:

1
2
3
4
t.string   :confirmation_token
t.datetime :confirmed_at
t.datetime :confirmation_sent_at
t.string   :unconfirmed_email # Only if using reconfirmable

Also add confirmable to app/models/user.rb.

This article is based on the user of the user model, so at this point you should have integrated devise using the user model. Now, override a method that will allow you to register a user without a password. In app/models/user.rb:

1
2
3
def password_required?
 false
end

Next, add token fields to the user model in your terminal.

1
rails generate migration add_token_fields_to_user

In your db/migrate/abcdxyz_add_token_fields_to_user.rb:

1
2
3
4
5
6
class AddTokenFieldsToUser < ActiveRecord::Migration[6.0]
 def change
   add_column :users, :login_token, :string
   add_column :users, :login_token_valid_until, :datetime
 end
end

Now add a strategy, passwordless_authenticatable, in your terminal.

1
2
3
4
5
mkdir lib/devise
mkdir lib/devise/models
mkdir lib/devise/strategies
touch lib/devise/models/passwordless_authenticatable.rb
touch lib/devise/strategies/passwordless_authenticatable.rb

Now in your lib/devise/models/passwordless_authenticatable.rb, add this:

1
2
3
4
5
6
7
8
9
require Rails.root.join('lib/devise/strategies/passwordless_authenticatable')

module Devise
  module Models
    module PasswordlessAuthenticatable
      extend ActiveSupport::Concern
    end
  end
end

In lib/devise/strategies/passwordless_authenticatable.rb, add this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
require 'devise/strategies/authenticatable'
require_relative '../../../app/mailers/user_mailer'
module Devise
  module Strategies
    class PasswordlessAuthenticatable < Authenticatable

      def authenticate!
        if params[:user].present?
          user = User.find_by(email: params[:user][:email])

          if user&.update(login_token: SecureRandom.hex(10),
                          login_token_valid_until: Time.now + 60.minutes)
            url = Rails.application.routes.url_helpers.email_confirmation_url(login_token: user.login_token)

            UserMailer.validate_email(User.first, url).deliver_now

            fail!("An email was sent to you with a magic link.")
          end

        end
      end
    end
  end
end

Warden::Strategies.add(:passwordless_authenticatable, Devise::Strategies::PasswordlessAuthenticatable)

Now in config/initializers/devise.rb, add this.

1
2
3
4
5
6
Devise.add_module(:passwordless_authenticatable, {
    strategy: true,
    controller: :sessions,
    model: 'devise/models/passwordless_authenticatable',
    route: :session
  })

Don’t worry about line number 2 and 15 for now, the mailer will be added in further steps. Here, line number 17 is worth noticing - authentication is failed because you don’t want to let the user login without confirming through email.

In config/environments/development.rb:

1
Rails.application.routes.default_url_options = { host: 'http://localhost:3000' }

Now add this strategy in app/models/user.rb file - it should look something like this:

1
2
3
4
5
6
7
8
9
10
11
class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         :confirmable, :passwordless_authenticatable

  def password_required?
    false
  end
end

Override the sessions_controller that comes from devise. To do that, first generate your controller.

1
rails g devise:controllers users -c=sessions

Add two methods in app/controllers/users/sessions_controller.rb:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# frozen_string_literal: true

class Users::SessionsController < Devise::SessionsController
  
  def sign_in_with_token
    user = User.find_by(login_token: params[:login_token])

    if user.present?
      user.update(login_token: nil, login_token_valid_until: 1.year.ago)
      sign_in(user)
      redirect_to root_path
    else
      flash[:alert] = 'There was an error while login. Please enter your email again.'
      redirect_to new_user_session_path
    end
  end

  def redirect_from_magic_link
    @login_token = params[:login_token] if params[:login_token].present?
  end
end

In config/routes.rb, add the following.

1
2
3
4
5
6
7
8
9
devise_for :users, controllers: {
    sessions: 'users/sessions',
    registrations: 'users/registrations'
  }

  devise_scope :user do
    get 'email_confirmation', to: 'users/sessions#redirect_from_magic_link'
    post 'sign_in_with_token', to: 'users/sessions#sign_in_with_token'
  end

Let’s also generate views. First, add this line to config/initializers/devise.rb:

1
config.scoped_views = true

Now in your terminal:

1
rails generate devise:views users

Now remove all the password related fields from all the views, and create a new file app/views/users/sessions/redirect_from_magic_link.html.erb.

1
2
3
4
5
6
7
8
9
10
11
12
13
<h2>Log in</h2>

<%= form_tag('/sign_in_with_token') do %>
  <div class="field">
    <%= hidden_field_tag :login_token, @login_token %>
  </div>

  <div class="actions">
    <%= submit_tag "Log in" %>
  </div>
<% end %>

<%= render "users/shared/links" %>

Next it’s time to add a mailer.

1
rails g mailer user

In app/mailers/user_mailer.rb, add this:

1
2
3
4
5
6
7
class UserMailer < ApplicationMailer
  def validate_email(user, url)
    @user = user
    @url  = url
    mail to: @user.email, subject: 'Sign in into mywebsite.com'
  end
end

Create app/views/user_mailer/validate_email.html.erb:

1
2
<p><%= @user.email  %></p>
<%= link_to "Confirm", @url %>

To catch email, configure letter opener in development. Follow this guide to add letter opener to your project.

Now you have successfully added passwordless authentication in your Rails application. Happy authenticating!