Subscribe for email updates:

March 14, 2022

2 responses

Passwordless login with Rails 7

Ruby on Rails ships with no user authentication layer. The core maintainers of the framework made this decision as it could vary per app how you might want to handle such a feat.

Most developers I come across reach for the devise gem that works very well for most of your needs. There are even additional gems offering newer features like inviting users, one-time passwords, and more.

Recently I questioned the whole notion of a login and password mechanism. We are conditioned to this way of "access" but it's quite cumbersome when it comes down to it.

Users are forced to remember their login and password credentials which leads to them choosing something they can remember. Sadly, this removes a lot of security features from the whole point of a secret login and password.

Knowing there was probably a pre-made solution out there I did some searching. I found a gem called passwordless which seems to answer the calling I was on the hunt for.

Much of the gem could probably be accomplished without the additional dependency but it includes some handy features.

Let's take a tour...

Generating an app

I'll generate a fresh Rails 7 application and pass a few preferences I prefer for CSS and JavaScript. We really won't be using these too much in this guide but I wanted to bundle it just in case.

rails new passwordless_app -c tailwind -j esbuild

After cd-ing into the app folder I'll generate a new User model.

rails g model User email username

Adding the passwordless gem

Inside your Gemfile add the passwordless gem to the end.

# Gemfile

gem 'passwordless'

Then run the following three commands to install necessary dependencies and migrations.

bundle install
rails passwordless:install:migrations
rails db:migrate

Inside the migration file you'll see a new table with a series of new columns present:

# db/migrations/....
class CreatePasswordlessSessions < ActiveRecord::Migration[5.1]
  def change
    create_table :passwordless_sessions do |t|
      t.belongs_to(
        :authenticatable,
        polymorphic: true,
        index: {name: "authenticatable"}
      )
      t.datetime :timeout_at, null: false
      t.datetime :expires_at, null: false
      t.datetime :claimed_at
      t.text :user_agent, null: false
      t.string :remote_addr, null: false
      t.string :token, null: false

      t.timestamps
    end
  end
end

The migration creates a polymorphic association called "authenticatable" which means you can add this to any model. User is quite common but maybe your app has a Subscriber or a Member for example.

Additionally, there are columns for timeouts, expirations, tokens, and more. This helps allow persistent logins without all the normal cookie-dependent stuff.

Specify which field on the user to use

On the user model, we need to give the passwordless gem a field to target. In this case, I'll use the email column we added in a previous step.

# app/models/user.rb

class User < ApplicationRecord
  validates :email, presence: true, uniqueness: { case_sensitive: false }

  passwordless_with :email
end

Because we defined passwordless_with in the user model we can then mount the bundled engine to match.

# config/routes.rb
Rails.application.routes.draw do
  passwordless_for :users
  # other routing...
end

Current user helper methods

The passwordless gem doesn't give you the current_user helper automatically but luckily it's not a chore to add. Inside our application_controller.rb file we need to extend the app a touch.

# app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  include Passwordless::ControllerHelpers


  helper_method :current_user

  private

  def current_user
    @current_user ||= authenticate_by_session(User)
  end

  def require_user!
    return if current_user
    redirect_to users.sign_in_path, alert: 'Please sign in to view this content'
  end
end

We include a ControllerHelpers module from the Passwordless module/engine. In doing so we get the method called authenticate_by_session. That extracts some logic from our app and is powered by the engine from the gem.

Two private methods are added to find the current user and check if the current user is authenticated.

So now in other controllers, we can use this method in a callback function.

Example authentication on a static controller

Let's make a fictional controller for the sake of example. Maybe there's an action on it we want to require people to authenticate for.

rails g controller static index members_only --skip-routes --skip-assets --skip-helpers

    create  app/controllers/static_controller.rb
    invoke  erb
    create    app/views/static
    create    app/views/static/index.html.erb
    create    app/views/static/members_only.html.erb
    invoke  test_unit
    create    test/controllers/static_controller_test.rb
    invoke  helper
    create    app/helpers/static_helper.rb
    invoke    test_unit

I'll generate a StaticController with an index and members_only action skipping any automatic stuff we don't really need.

Setting a default root route is probably ideal

Rails.application.routes.draw do
  passwordless_for :users
  root to:"static#index" # add this line
end

Now we can boot up the server:

bin/dev

I'll add some markup to make the page a bit more appealing.

<!-- app/views/static/index.html.erb-->
<div class="max-w-2xl p-8 rounded-xl mx-auto text-center my-16 bg-gray-50">
  <h1 class="font-black text-3xl">Super-mega Passwordless App</h1>

  <p class="text-lg text-gray-600 my-4">Sign up for members-only access</p>

  <div class="flex items-center space-x-3">
    <%= link_to "Sign in", users.sign_in_path, class: "bg-sky-500 text-white
    px-3 py-2 font-semibold" %> <%= link_to "Create account", new_user_path,
    class: "bg-sky-500 text-white px-3 py-2 font-semibold" %>
  </div>
</div>

Note the sign-in path now users users.sign_in_path. If you recall how we define our routing this is now required to access these defined paths within the gem's engine. Head to localhost:3000/rails/info/routes and look for the engine's routes to see all that comes bundled.

Unfortunately, this won't render correctly at the moment because we are missing some additional routing and controller logic for our User resource. I'll add that next.

Users configuration

Create a new users controller in app/controllers/

Inside it, we'll add the following action.

# app/controllers/users_controller.rb

class UsersController < ApplicationController
  def new
    @user = User.new
  end

  def create
    @user = User.new user_params

    if @user.save
      sign_in @user
      redirect_to root_path, flash: { notice: 'Welcome aboard!' }
    else
      render :new
    end
  end


  private

    def user_params
      params.require(:user).permit(:username, :email)
    end
end

Now we need some routing

Rails.application.routes.draw do
  passwordless_for :users
  resources :users # add this line
  root to:"static#index"
end

Finally, we need a new form and view. I'll create two files. One is new.html.erb and the other is _form.html.erb. Those will live inside app/views/users/

<!-- app/views/users/new.html.erb -->
<div class="max-w-xl p-8 rounded-xl mx-auto text-center my-16 bg-gray-50">
  <h1 class="font-black text-2xl mb-6">Create an account</h1>
  <%= render "form", user: @user %>
</div>

Here's the form.

<!-- app/views/users/_form.html.erb -->
<%= form_with(model: user, data: {turbo: false}) do |form| %>
<div class="mb-6">
  <%= form.label :username, class: "block text-left mb-2" %> <%= form.text_field
  :username, class: "block w-full px-3 py-2 focus:ring-sky-100 ring-4 border
  ring-transparent focus:outline-none rounded" %>
</div>

<div class="mb-6">
  <%= form.label :email, class: "block text-left mb-2" %> <%= form.email_field
  :email, class: "block w-full px-3 py-2 focus:ring-sky-100 ring-4 border
  ring-transparent focus:outline-none rounded" %>
</div>

<%= form.submit "Create account", class: "bg-emerald-500 font-medium px-3 py-2
text-white w-full text-center hover:bg-emerald-600 cursor-pointer rounded" %> <%
end %>

At this point, you should be able to create a new account and successfully sign in. Since our app is extremely lean right now it's tough to know that we are indeed authenticated.

I'll update the static/index.html.erb view to check the state.

<!-- app/views/static/index.html.erb-->
<div class="max-w-2xl p-8 rounded-xl mx-auto text-center my-16 bg-gray-50">
  <h1 class="font-black text-3xl">Super-mega Passwordless App</h1>

  <% if current_user.present? %>
  <p class="py-3">
    You are signed in as
    <span class="font-semibold text-sky-800"
      ><%= current_user.username %>(<%= current_user.email %>)</span
    >
  </p>
  <%= link_to "Sign out", users.sign_out_path, class: "bg-sky-500 text-white
  px-3 py-2 font-semibold rounded" %> <% else %>
  <p class="text-lg text-gray-600 my-4">Sign up for members-only access</p>
  <div class="flex items-center space-x-3 justify-center">
    <%= link_to "Sign in", users.sign_in_path, class: "bg-sky-500 text-white
    px-3 py-2 font-semibold rounded" %> <%= link_to "Create account",
    new_user_path, class: "bg-emerald-500 text-white px-3 py-2 font-semibold
    rounded" %>
  </div>
  <% end %>
</div>

If you sign out and click sign in again you might notice a "Send magic link" button now in view. This comes bundled with the gem but it's very primitive. Let's adjust this design by copying the gem's views and changing those.

An engine will know to use the views in our app as opposed to the gems since it's a top-down structure. So long as we make use of the same logic we can get away with just updating the design.

I created a folder structure like the following:

app/views
      /passwordless
          /sessions
          -- create.html.erb
          -- new.html.erb

We'll adjust each file...

Here's the new.html.erb file

<!-- app/views/passwordless/sessions/new.html.erb-->
<div class="max-w-xl p-8 rounded-xl mx-auto text-center my-16 bg-gray-50">
  <h1 class="font-black text-2xl mb-6">Sign in</h1>
  <%= form_for @session, url: send(Passwordless.mounted_as).sign_in_path, data:
  {turbo: false } do |f| %> <% email_field_name =
  :"passwordless[#{@email_field}]" %> <%= text_field_tag email_field_name,
  params.fetch(email_field_name, nil), class: "block w-full px-3 py-2
  focus:ring-sky-100 ring-4 border ring-transparent focus:outline-none rounded
  mb-3", placeholder: "Enter email" %> <%= f.submit
  I18n.t('passwordless.sessions.new.submit'), class: "bg-sky-500 font-medium
  px-3 py-2 text-white w-full text-center hover:bg-sky-600 cursor-pointer
  rounded" %> <% end %>
</div>

The create.html.erb file

<!-- app/views/passwordless/sessions/create.html.erb-->
<div class="max-w-xl p-8 rounded-xl mx-auto text-center my-16 bg-gray-50">
  <p>
    <%= I18n.t('passwordless.sessions.create.email_sent_if_record_found') %>
  </p>
</div>

There is also a magic_link.text.erb file. This can be extended a bit but it's mostly a design exercise. We'll leverage the console's logging to capture the unique magic link we'll need all the same.

If you wanted to copy that template over and adjust the design feel free!

Authenticating with a magic link

Now that we have the updated views we can give a "Sign in" a shot. From the root path click "Sign in". That should redirect you to a form where you enter your email.

After entering the email you signed up with click the "Send magic link" button then head to your console to see the logs.

Bugs

You might get an error at this point on a fresh rails app.

This is because of a default_url_options method we need to define locally. This is responsible for emailing logic that Rails taps into for the mailer side of the framework. Ultimately, in production, you would tap into some sort of email delivery service. Their guides would give you the URL you need (usually your app's main domain).

In our case, since everything is in a local environment we need to tell the app as such.

#config/environments/development.rb
...
Rails.application.configure do
  # a bunch of configs
  config.action_mailer.default_url_options = {host: "localhost", port: 3000}
end

This line defines essentially what our rails app server is running on locally. By default that is localhost:3000.

Try the "Send magic link" form again and check your logs. You should see something like this:

Started POST "/users/sign_in" for 127.0.0.1 at 2022-03-14 10:41:21 -0500
10:41:21 web.1  | Processing by Passwordless::SessionsController#create as HTML
10:41:21 web.1  |   Parameters: {"authenticity_token"=>"[FILTERED]", "passwordless"=>"[FILTERED]", "commit"=>"Send magic link", "authenticatable"=>"user"}
10:41:21 web.1  |   User Load (0.3ms)  SELECT "users".* FROM "users" WHERE (lower(email) = '[email protected]') ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
10:41:21 web.1  |   TRANSACTION (0.1ms)  begin transaction
10:41:21 web.1  |   Passwordless::Session Load (0.2ms)  SELECT "passwordless_sessions".* FROM "passwordless_sessions" WHERE "passwordless_sessions"."token" = ? LIMIT ?  [["token", "[FILTERED]"], ["LIMIT", 1]]
10:41:21 web.1  |   Passwordless::Session Create (0.7ms)  INSERT INTO "passwordless_sessions" ("authenticatable_type", "authenticatable_id", "timeout_at", "expires_at", "claimed_at", "user_agent", "remote_addr", "token", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)  [["authenticatable_type", "User"], ["authenticatable_id", 1], ["timeout_at", "2022-03-14 16:41:21.288739"], ["expires_at", "2023-03-14 15:41:21.288526"], ["claimed_at", nil], ["user_agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:99.0) Gecko/20100101 Firefox/99.0"], ["remote_addr", "127.0.0.1"], ["token", "[FILTERED]"], ["created_at", "2022-03-14 15:41:21.292647"], ["updated_at", "2022-03-14 15:41:21.292647"]]
10:41:21 web.1  |   TRANSACTION (0.9ms)  commit transaction
10:41:21 web.1  |   Rendering /Users/<username>/.rbenv/versions/3.0.3/lib/ruby/gems/3.0.0/gems/passwordless-0.10.0/app/views/passwordless/mailer/magic_link.text.erb
10:41:21 web.1  |   Rendered /Users/<username>/.rbenv/versions/3.0.3/lib/ruby/gems/3.0.0/gems/passwordless-0.10.0/app/views/passwordless/mailer/magic_link.text.erb (Duration: 0.8ms | Allocations: 216)
10:41:21 web.1  | Passwordless::Mailer#magic_link: processed outbound mail in 4.3ms
10:41:21 web.1  | Delivered mail [email protected] (2.2ms)
10:41:21 web.1  | Date: Mon, 14 Mar 2022 10:41:21 -0500
10:41:21 web.1  | From: [email protected]
10:41:21 web.1  | To: [email protected]
10:41:21 web.1  | Message-ID: <[email protected]>
10:41:21 web.1  | Subject: =?UTF-8?Q?Your_magic_link_=E2=9C=A8?=
10:41:21 web.1  | Mime-Version: 1.0
10:41:21 web.1  | Content-Type: text/plain;
10:41:21 web.1  |  charset=UTF-8
10:41:21 web.1  | Content-Transfer-Encoding: 7bit
10:41:21 web.1  |
10:41:21 web.1  | Here's your link: http://localhost:3000/users/sign_in/dkdsZDQ4HP3v_T1V1kgRSlCy-xfD3DeE4JlRKvvidjg
10:41:21 web.1  |
10:41:21 web.1  |   Rendering layout layouts/application.html.erb
10:41:21 web.1  |   Rendering passwordless/sessions/create.html.erb within layouts/application
10:41:21 web.1  |   Rendered passwordless/sessions/create.html.erb within layouts/application (Duration: 0.5ms | Allocations: 147)
10:41:21 web.1  |   Rendered layout layouts/application.html.erb (Duration: 2.6ms | Allocations: 1808)
10:41:21 web.1  | Completed 200 OK in 24ms (Views: 3.4ms | ActiveRecord: 2.3ms | Allocations: 13295)

All we really need to confirm is that the email gets sent. Inside is a link to sign in.

http://localhost:3000/users/sign_in/dkdsZDQ4HP3v_T1V1kgRSlCy-xfD3DeE4JlRKvvidjg

When you visit that link, you are authenticated!

Protecting controllers

We made a members_only view on our StaticController in a previous step. To lock this down to only authenticated users we can pass a callback function within the controller.

# app/controllers/static_controller.rb
class StaticController < ApplicationController
  before_action :require_user!, only: :members_only

  def index
  end

  def members_only
  end
end

Now we need to update the routes once more.

# config/routes.rb

Rails.application.routes.draw do
  passwordless_for :users
  resources :users
  root to:"static#index"

  get "static/members_only", as: :members_only # add this line
end

Then I'll update our index template once more with a new link for signed-in users

<!--app/views/static/index.html.erb -->
<div class="max-w-2xl p-8 rounded-xl mx-auto text-center my-16 bg-gray-50">
  <h1 class="font-black text-3xl">Super-mega Passwordless App</h1>

  <% if current_user.present? %>
  <p class="py-3">
    You are signed in as
    <span class="font-semibold text-sky-800"
      ><%= current_user.username %>(<%= current_user.email %>)</span
    >
  </p>
  <%= link_to "View members only content", members_only_path, class: "underline
  block mb-6" %>
  <%= link_to "Sign out", users.sign_out_path, class: "bg-sky-500
  text-white px-3 py-2 font-semibold rounded" %> <% else %>
  <p class="text-lg text-gray-600 my-4">Sign up for members-only access</p>
  <div class="flex items-center space-x-3 justify-center">
    <%= link_to "Sign in", users.sign_in_path, class: "bg-sky-500 text-white
    px-3 py-2 font-semibold rounded" %> <%= link_to "Create account",
    new_user_path, class: "bg-emerald-500 text-white px-3 py-2 font-semibold
    rounded" %>
  </div>
  <% end %>
</div>

Then the members_only page can have some dummy content for now.

<div class="max-w-2xl p-8 rounded-xl mx-auto text-center my-16 bg-gray-50">
  <h1 class="font-black text-3xl mb-6">Super-mega Members-only Content</h1>
  <p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Rerum repudiandae quisquam mollitia in vero voluptas eos amet! Impedit, mollitia vel saepe voluptas tempore velit. Adipisci repellat porro eum ullam culpa.</p>

  <%= link_to "Back", root_path, class: "block my-2 underline" %>
</div>

If you sign out and try to visit the page directly now (http://localhost:3000/static/members_only) it will prompt you to sign in. Perfect!

I didn't originally include the markup for our flash messages but in case you want to view the messages in action add this code to your layout/application.html.erb template.

<!-- app/views/layouts/application.html.erb-->
<html>
 <!-- ... -->
  <body>
    <% if notice %>
      <div role="alert" class="py-3 text-white bg-blue-600 text-center">
        <p><%= sanitize notice %></p>
      </div>
    <% end %>

    <% if alert %>
      <div role="alert" class="py-3 text-white bg-red-600 text-center">
        <p><%= sanitize alert %></p>
      </div>
    <% end %>

  </body>
</html>
Tags: passwordless

Leave a reply

Sign in or Sign up to leave a response.

2 responses

Icons/flag
Icons/link diagonal

Hey! Great walkthrough thank you, helped me get this feature to work. Is there a way to have the user enter either username OR email in a single input field to receive the magic link? I saw your article about doing that with the devise gem, but I'd like to keep the application slim and fast :)

Andy Leverenz
Icons/flag
Icons/link diagonal

Is there a way to have the user enter either username OR email in a single input field to receive the magic link?

This is probably possible. You would need to do a lookup by username or password before sending the magic link. The gem probably doesn't handle multiple fields to honor the magic link with this so it might be something you need to extend.

Est. reading time: 14 minutes
Stats: 1,143 views

Categories

Collection

Part of the Ruby on Rails collection