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
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
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:
- find or create the user based on the submitted email address
- generate a 6 digit verification code
- send the verification code via email to the email submitted
- 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:
- find the user we sent the code to based on the email address in the session
- check the verification code submitted is valid
- if the verification code is valid, generate an auth token for the user and set it in an encrypted cookie
- 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.