Password-less Auth with Rails

Overview

I much prefer websites and apps that offer a way to sign in without needing a password purely from a usability standpoint, but there are some great benefits for developers too, for example:

  • automatically confirm the user’s email address
  • no need for sign in and sign up flows
  • no need to implement “forgot password”

Based on this, for a recent project I implemented password-less authentication for the web. Since this project benefits from HealthKit integration on iOS, I decided to use Turbo Native for a hybrid iOS app which meant I had to extend this password-less flow in Swift too!

This post is about the Rails side of things. Look out for Part 2 soon where I’ll build on this and introduce the Turbo Native auth for iOS!

You can get the finished Source Code on GitHub or if you prefer, follow along below from scratch.

What we’ll build

Authentication Flow

This will be quite a long, technical post since there’s a lot to cover, but by the end of it I’ll have shown you how to introduce this authentication flow into a Rails app from scratch. Follow along and type in the code, or if you prefer you can clone the Source Code from GitHub.

The Rails app

First of all we need a simple Rails app that will provide the web and API clients (coming in part 2). I’m assuming you already have Rails installed on your machine, if not visit the guides and come back when you’re ready.

Let’s create our app and then cd into the working directory:

rails new rails_passwordless_auth --css tailwind
cd rails_passwordless_auth

From here we should be able to start up the server

bin/dev

and see the default landing page at http://localhost:3000

Rails Default Page

Models

For the purpose of this demo, we’re going to need User and AccessToken models so lets create them and also run the migrations that will create the tables in our database:

bin/rails generate model User email:string:uniq otp_secret:string
bin/rails generate model AccessToken user:references token:string:uniq
bin/rails db:migrate

The User is about as simple as possible, and has just two attributes “email” and “otp_secret”. The first obviously is the users email address. “otp_secret” is a generated code that we will create and store alongside the user, and is used as another factor in the validation flow. Note “otp” here is an acronym for “one time password”.

Let’s add a validation just to make sure emails are unique so we can’t create two accounts with one email address and a has_many association to AccessToken since a user can have many of them for different sessions and devices for example. Open up app/models/user.rb and change the code to this:

class User < ApplicationRecord
  has_many :access_tokens, dependent: :destroy

  validates :email, presence: true, uniqueness: true
end

The AccessToken is equally simple, and holds a reference to the user it belongs to, and a “token” with unique index. We have a before_create that calls set_token just before an AccessToken is created to set a 32 digit hash as the token. Something like “c2cc31d0418817ac5d66fe45666264edcf5b95754e9b36c3f91ed3b3aa7e0448”. This is what we store in a client cookie to identify users in our app once they’ve signed in. Open up app/models/access_token.rb and replace the code with:

class AccessToken < ApplicationRecord
  belongs_to :user
  before_create :set_token
  validates :token, uniqueness: { case_sensitive: false }

  private

  def set_token
    begin
      self.token = SecureRandom.hex(32)
    end while self.class.exists?(token: token)
  end
end

Now we have our domain models of User and AccessToken, lets move on to the actual Authentication and Authentication Verification implementation. The former being how the user submits their email address and we send the verification code, and the latter being how the user submits their verification code and to be validated, or not.

Lets set up the controllers next:

Home Controller

First we’ll create the home page. The first thing a visitor to our site sees, and in the context of this example, just a log in button. Super simple. We can generate it as follows:

bin/rails generate controller Home index

Lets update the routes file to set this new Home index page to be our default root path. Open up config/routes.rb and replace the contents with:

Rails.application.routes.draw do
  get "up" => "rails/health#show", :as => :rails_health_check

  root "home#index"
end

We also need as simple template to show for this page, so open up the newly generate app/views/home/index.html.erb and replace the contents with this erb template:

<div>
  <div>
    <h2>Welcome to the Password-less Demo</h2>
    <p>
      This is a demo of signing in to a Rails app using a 6
      digit validation code that will be sent to your email.
    </p>
  </div>
  <div>
    <%= link_to "Sign In", auth_path %>
  </div>
</div>

One other bit of housekeeping - we’ll be using the Rails flash to show messages after signing in etc, so we need to render these out if present in the application layout. Open the app/views/layouts/application.html.erb file and replace it with the following markup:

<!DOCTYPE html>
<html>
  <head>
    <title>Rpa</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>
    <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>

    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
    <%= javascript_importmap_tags %>
  </head>
  <body>
    <header>
      <% if notice.present? %>
        <p id="notice"><%= notice %></p>
      <% end %>
    </header>
    <main class="container mx-auto mt-28 px-5 flex">
      <%= yield %>
    </main>
  </body>
</html>

Auth Controller

Run the following in the console in your app directory:

bin/rails generate controller Auth show create destroy

The generator has created the files and routes we need but lets tidy up the routes. Open config/routes.rb and replace the code with the following:

Rails.application.routes.draw do
  get "up" => "rails/health#show", :as => :rails_health_check

  root "home#index"

  resource :auth, only: %i[show create destroy], controller: :auth
end

Authentication Form

Next, lets get the Authentication Form implemented. This is where the user will type in the email they want to sign up/in with. The generator will have stubbed out the files we need. For the form, the controller is already set up, so now open up the form view app/views/auth/show.html.erb and replace the contents with the following markup:

<div>
  <div>
    <h2>Lets Get Started</h2>
    <p>
      To keep your account secure, we need to confirm your email. We'll
      send a verification code to the email address you enter here.
    </p>
  </div>
  <div>
    <div>
      <%= form_with(url: auth_path) do |f| %>
        <div>
          <div>
          <%= f.email_field :email, value: params[:email],
              placeholder: "Email address", required: true %>
          </div>
        </div>
        <div>
          <button name="button" type="submit">Send Code</button>
        </div>
      <% end %>
    </div>
  </div>
</div>

Now if you reload the page at http://localhost:3000 you should see our Home Page, and if you click the “Sign In” button, a simple page titled “Lets Get Started” with an input box for an email and a “Send Code” button.

Auth Controller Action

In the Authentication Form above, notice the form helper we’re using form_with with the parameter url: auth_path. This will create an HTML form that POSTs to our application_controller#create action, along with a hidden authentication_token which Rails uses to protect against cross site request forgery. That simple form helper does all this for us. Very nice.

Note: This is just showing the HTML Rails outputs. You don’t need to add this code snippet anywhere!

<form accept-charset="UTF-8" action="/auth" method="post">
  <input
    name="authenticity_token"
    type="hidden"
    value="m_HdmvsuI7MgX5An1buR4rYba_8DmTcxK2ZiMnVZFDA2rmd" />
  ... form content here ...
</form>

So now we need to implement the create action in our controller that will do the following:

  1. find or create the user based on the submitted email address
  2. generate a 6 digit verification code
  3. send the verification code via email to the email submitted
  4. save the email in the session and redirect to the verification form

Open up up the app/controllers/auth_controller.rb file and replace the contents with the following:

class AuthController < ApplicationController
  skip_before_action :verify_authenticity_token, if: -> { request.format.json? }

  def show
  end

  def create
    email = params[:email].downcase.strip
    user = User.find_or_create_by(email: email)

    AuthMailer.auth_code(user, user.auth_code).deliver_now

    session[:email] = email

    respond_to do |format|
      format.html { redirect_to auth_verification_path }
      format.json { render json: { msg: "verification-email-sent" } }
    end
  end

  def destroy
    cookies.delete :access_token

    respond_to do |format|
      format.html { redirect_to root_path, notice: "You're now signed out" }
      format.json { render json: { msg: "signed-out" } }
    end
  end
end

There’s quite a bit to explain here, and an introduction to Mailers too, so lets break this down into the 4 steps we defined above.

1. find or create the user based on the submitted email address

This is pretty straightforward. We grab the email address from the params submitted from the form, then downcase and strip it to normalize the input to lowercase and without any extraneous spaces either end. Then we use Rails find_or_create_by to either find an existing user with this email address, or create a new one if if is not found. This means creating an account or signing back in are the same operation for a user.

2. generate a 6 digit verification code

Next we want to generate a 6 digit code securely that we can somehow verify on the next screen. We’ll use the tried and trusted Ruby One Time Password library to create this, so lets install the gem: (** Note you will need to restart your local server after adding this with bin/dev)

bundle add rotp

Now we’ll add some methods to User to generate and validate authentication codes. Open up app/models/user.rb and replace the contends with the following

class User < ApplicationRecord
  before_create :generate_otp_secret
  has_many :access_tokens, dependent: :destroy

  validates :email, presence: true, uniqueness: true

  def auth_code
    totp.now
  end

  def valid_auth_code?(code)
    totp.verify(code, drift_behind: 300).present?
  end

  private

  def generate_otp_secret
    self.otp_secret = ROTP::Base32.random(16)
  end

  def totp
    ROTP::TOTP.new(otp_secret, issuer: "Your App Name")
  end
end

Here we’ve added a before_create :generate_otp_secret callback that generates a 16 digit random string like “QIUM2PUWGDODDFGIRGZKKEWYNI” that is saved to the database User record. This is used as the seed to create the random one time password we send to the user, and ensures it is entirely random and importantly we can regenerate it within a certain time frame

The other private method totp creates a Timed One Time Password for us based on the otp_secret and a string we passed in. Change “Your App Name” to something unique that identifies your service.

We give access to this totp object via the auth_code method, and finally the valid_auth_code? method will validate if the passed in code matches. By default, ROTP::TOTP codes are only valid for 30 seconds. We’ve added the drift_behind: 300 argument that will allow users 5 minutes to enter their code before needing a new one.

Phew! That was a lot to explain. We’re nearly there… the last thing to introduce is the Mailer to send this code to the user when they submit their email. There’s plenty of resources online about ActionMailer - for example in the Ruby Guides so I’ll just present the code here. If you need more info, read the aforementioned article, or drop me a line!

3. send the verification code via email to the email submitted

First lets generate the Auth mailer

bin/rails generate mailer Auth

Then open up the generated app/mailers/auth_mailer.rb and replace it with the following code:

class AuthMailer < ApplicationMailer
  def auth_code(user, auth_code)
    @user = user
    @auth_code = auth_code

    mail(to: @user.email, subject: "#{@auth_code} is your verification code")
  end
end

Next we’ll add the template for the email that will be sent out. ActionMailer lets us create HTML and text based content. We’ll just do HTML here for brevity, so create a file named app/views/auth_mailer/auth_code.html.erb and add the following code:

<!DOCTYPE html>
<html>
  <head>
    <meta content='text/html; charset=UTF-8' http-equiv='Content-Type' />
  </head>
  <body>
    <h1>Welcome!</h1>
    <p>
      Enter this code in the next 5 minutes to sign in: <%= @auth_code %>
    </p>
    <p>
      If you didn't request this code, you can safely ignore this email.
      Someone else might have typed your email address by mistake.
    </p>
  </body>
</html>

In development and for the purposes of this demo, we’ll see the text of the email output in the server logs. To actually send email you will need to configure an SMTP server. Read more here

4. save the email in the session and redirect to the verification form

The last part of our AuthController action is very straightforward. We save the email that the user submitted in the the session as session[:email] and then redirect to the AuthVerificationController which is the last step!

The eagle-eyed amongst you may have noticed I added support for JSON responses in the respond_to block. We’ll make use of this in Part 2 of this series when we implement native auth for iOS with Turbo Native.

Auth Verification Controller

Run the following in the console in your app directory:

bin/rails generate controller AuthVerification create show

The generator has created the files and routes we need but lets tidy up the routes. Open config/routes.rb and replace the code with the following.

Rails.application.routes.draw do
  get "up" => "rails/health#show", :as => :rails_health_check

  root "home#index"

  resource :auth, only: %i[show create destroy], controller: :auth
  resource :auth_verification, only: [:show, :create], controller: :auth_verification
end

Auth Verification Form

As we did for the Authentication Form previously, we need to add a form so the user can submit the 6 digit verification code they received via email and get signed in.

The generator will have stubbed out the files we need. To show the form, the controller is already set up, so now open up the form view app/views/auth_verification/show.html.erb and replace the contents with the following markup:

<div>
  <h2>Check Your Email</h2>
  <p>
    Please enter the verification code sent to your email
    address <%=session[:email] %>.
  </p>
  <div>
    <%= form_with(url: auth_verification_path) do |f| %>
      <%= f.hidden_field :email, value: @email %>
      <%= f.text_field :verification_code,
          placeholder: "Verification Code", maxlength: 6 %>
      <%= f.button do %>
        Verify
      <% end %>
    <% end %>
  </div>
</div>

Auth Verification Controller Action

Now we need to implement the create action in our verification controller that will do the following:

  1. find the user we sent the code to based on the email address in the session
  2. check the verification code submitted is valid
  3. if the verification code is valid, generate an auth token for the user and set it in an encrypted cookie
  4. if the verification code is NOT valid, redirect back to the verification form and tell the user to try again

Open up up the app/controllers/auth_verification_controller.rb file and replace the contents with the following

class AuthVerificationController < ApplicationController
  skip_before_action :verify_authenticity_token, if: -> { request.format.json? }

  def create
    user = User.find_by(email: session[:email])

    if user.present? && user.valid_auth_code?(params[:verification_code])
      access_token = user.access_tokens.create!
      cookies.permanent.encrypted[:access_token] = access_token.token

      session.delete(:email)

      respond_to do |format|
        format.html { redirect_to root_path, notice: "You are now signed in" }
        format.json { render json: { token: access_token.token } }
      end
    else
      respond_to do |format|
        format.html do
          redirect_to auth_verification_path, notice: "Please check your verification code and try again."
        end
        format.json do
          render json: { msg: "invalid-verification-code." }, status: :unauthorized
        end
      end
    end
  end

  def show
  end
end

Lets explain what’s going on here in the 4 steps:

1. find the user we sent the code to based on the email address in the session

Easy. We saved the email the user submitted in the session in the auth controller. Here we just grab that back from the session and find the user User.find_by(email: session[:email])

2. check the verification code submitted is valid

After checking we found the user, we call the previously defined valid_auth_code? method on the user instance to validate the code they entered in the form and submitted is valid.

3. if the verification code is valid

If the verification code is valid, we generate an access_token for the user and set it in an encrypted cookie. This access_token can be used throughout your app to verify a user is signed in, and who they are. We then redirect the user to the home page

4. if the verification code is NOT valid

If the verification code is NOT valid, we redirect the user back to the verification form and tell them we could not validate the code and they should try again.

Logged In

Now we’ve added password-less authentication, we need to add a few helpers we can use to determine if a user is logged in or logged out. Open up app/controllers/application_controller.rb and replace the contents with the following code:

class ApplicationController < ActionController::Base
  helper_method :logged_in?, :current_user

  def current_user
    @current_user ||= lookup_user_by_cookie
  end

  def logged_in?
    !!current_user
  end

  private

  def lookup_user_by_cookie
    if cookies.permanent.encrypted[:access_token]
      User.joins(:access_tokens).find_by(
        access_tokens: {
          token: cookies.permanent.encrypted[:access_token]
        }
      )
    end
  end
end

And then lets update the home index view to show a “sign out” button when the user is logged in and “sign in” button when they are logged out. We’ll used the logged_in? helper method we added above to accomplish this. Open up app/views/home/index.html.erb again and replace everything with the following code:

<div>
  <div>
    <h2>Welcome the the Password-less Demo</h2>
    <p>
      This is a demo of signing in to a Rails app using a 6
      digit validation code that will be sent to your email.
    </p>
  </div>
  <div>
    <% if logged_in? %>
      <p>Logged in as <%= current_user.email %></p>
      <%= button_to "Sign Out", auth_path, method: :delete %>
    <% else %>
      <%= link_to "Sign In", auth_path %>
    <% end %>
  </div>
</div>

Try it out!

Thats about it! We’ve implemented secure authentication, without passwords, and you will end up with an access_token cookie that you can inspect in your app.

Run the app locally, and visit http://localhost:3000, click “Sign In” then enter your email and click the “Send Code” button. As mentioned above, we’re not actually sending an email (unless you configured your SMTP server) but you can see the email text output in the rails development log. Fish out the verification code from there there to enter in the second form, the text in the log will be: “Enter this code in the next 5 minutes to sign in: “ with the token you need to use on the end.

Backfill existing users

A kind reader pointed out to me that they were having an issue when implementing this feature. Thanks Werner! It took a minute to realise but I was implementing this on a new service and had neglected to consider existing users. When there are pre-existing users in your app, our migration sets otp_secret to nil but the before_create action in the User model is not triggered so the otp_secret is never set.

We could handle this a few ways. Perhaps check when accessing otp_secret in the model and set it if we find it’s nil? We could also backfill the data with a migration? My preference is to create a rake task to backfill the data so lets do that here.

First, lets create a new file lib/tasks/backfill_otp_secret.rake for the rake task and open it up. Then paste in the code below:

namespace :users do
  desc "Backfill otp_secret for existing users"
  task backfill_otp_secret: :environment do
    User.where(otp_secret: nil).find_each do |user|
      user.update(otp_secret: ROTP::Base32.random(16))
    end
    puts "Done."
  end
end

Then run the rake task from your root directory with:

rails users:backfill_otp_secret

What we’re doing here is loading up all existing Users where otp_secret is nil and then, updating each user with a newly generated secret. Note we’re using find_each here which is more efficient on large datasets. For an existing system, do be careful, test this out in your staging environment and watch out for any other lifecycle callbacks you may have.

Wrapping up

I hope you enjoyed this article. Don’t forget you can get the full Source Code from my GitHub repo, where you’ll also see some tests I didn’t want to over complicate this post with.

Some exercises for the reader on areas to improve this might be to extract some of the logic from the controllers to a UserService, or add a “Log Out” flow, and certainly add some error handling as I only really covered the “happy path” in the example.

Part 2

Stay Tuned for part 2 where I’ll show how to integrate this into a Swift Turbo Native iOS app!

As always get in touch and drop me a note with feedback or questions.