Rails 6.x Auth with MagicLink using SecureRandom Token

Passwordless Authentication is very convenient for users and generally as secure as passwords (according to many articles as long as the email access-links are short-lived - as email is not very secure).

Therefore, after some reading, it seems like a good approach is to make a short-lived link, and then transfer the security to a session.

I found that there seems to be three simple approaches:

1. Do it yourself: with a Signed-GlobalID from Rails (self-times out & no migration)
2. Do it yourself: with a Stored-Token (adapts to any framework)
3. Other Options: Devise Plugin (when using devise) or other Gems

This article will focus on using Secure Random - since it can work with any Framework (in Rails however, I prefer to use SignedGlobalIDs - see: https://btihen.me/ , since it simplifies the user model and the expiration logic)

Overview

1. User enters their email-address in a simple form
2. If account is found - a link with a token is generated and email is sent
3. User is notified that the link is on its way (even if the account is not found and no email is sent)
4. When the user follows the link in the email, a session is generated
5. Session valid until the session expires or the user logs out (deleting the session).

NOTE: I will be assuming that the account must exist, but you could also just create a new account (consider this option carefully and some limits on account creation per IP address or per hour, etc. As you could otherwise be flooded with useless, malicious emails!)

Do it yourself

This is relatively easy to do with built-in Rails security - and I like not being dependent on external code, I’ll show a way to do this. In this case, assume that the accounts are already created (or not).

If you want to do user registration, confirmation, etc – then I think it is best to use Devise or some other gem!

Getting Started

Code repo is posted at: https://github.com/btihen/magic_token

Create a Rails Project:

bin/rails new magic_token
cd magic_token
bin/rails db:create


now start rails and be sure you get the welcome page at: http://localhost:3000

Assuming all works well:

git add .
git commit -m "initial commit"


Create a Landing Page

We will now make a landing page (it will need to be always available):

bin/rails g controller landing index --helper false


now lets point the root page to that too - make the routes page look like:

# config/routes.rb
Rails.application.routes.draw do
get '/landing', to: 'landing#index', as: :landing
root to: "landing#index"
end


Now lets check all is well with the routes:

bin/rails routes | grep landing
# should show
landing   GET   /landing(:format)   landing#index
root   GET   /                   landing#index


(quite likely it will be all spread out)

Start up rails and be sure we can access these pages:

• http://localhost:3000/
• http://localhost:3000/landing

Feel free to make them look nicer!

assuming all works well:

git add .
git commit -m "add landing page"


Create Users Management Page

User-Controller to manage users:

bin/rails g scaffold User email:string token:string token_expires_at:datetime --helper false
bin/rails db:migrate


Lets make a few accounts in the seed file (or enter in the console bin/rails c):

# db/seeds.rb
User.create(email: '[email protected]')
User.create(email: '[email protected]')


now run the seed file:

bin/rails db:seed


Lets start Rails

bin/rails s


Go to: http://localhost:3000/users

Now you should see the users & be able to create a few more users.

Feel free to make the GUI nicer!

Assuming all is good:

git add .
git commit -m "user management page"


Add auth restrictions to Application Controller

This will allow us to control access to all urls within our app (we will also allow exceptions for a landing page)

The application controller ensures only authenticated users (with a session) can access pages - with the following code (especially the users_only, but current_user is also very helpful generally)

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :users_only

def current_user
# dig and find_by avoid raising an exception w/o a session
user_id = session.dig(:user_id)
@current_user ||= User.find_by(id: user_id)
end

private

# code to ensure only logged in users have access to users pages
def users_only
if current_user.blank?
# send to login page to get an access link
redirect_back(fallback_location: landing_path,
# # uncomment to send people access link page (when built)
end
end
end


Now we should NOT be able to reach our previous pages

http://localhost:3000/ http://localhost:3000/users http://localhost:3000/landing

Now lets allow access to the landing page again - we need to add:

skip_before_action :users_only

to app/controllers/landing_controller.rb in order to allow unathenticated access.

# app/controllers/landing_controller.rb
class LandingController < ApplicationController
skip_before_action :users_only
def index
end
end


Assuming that works:

git add .
git commit -m "restrict access w/exception"


Create a Users Homepage

Now that we have a public home / default page - lets make an authenticed (user) homepage - where we auto-redirect people on login.

bin/rails g controller home index --helper false


update the routes page with the following (I’m not a fan of including the index in the url)

# config/routes.rb
Rails.application.routes.draw do
get '/home',    to: 'home#index',     as: :home
resources :users
get '/landing', to: 'landing#index',  as: :landing
root to: "landing#index"
end



Now lets check all is well with the routes:

bin/rails routes | grep home
# should show
home  GET   /home(:format)    home#index


Assuming the routes are correct when we try to go to:

http://localhost:3000/home

we should end up at (be redirected to):

http://localhost:3000/landing

assuming this works well:

git add .
git commit -m "restricted user home page"


Optional - setup a mail trap (MailHog)

I like to view the emails in a browser to check the look as well as the content, for this quick blog - just viewing the info in the logs is good enough. However, in case you are interested a quick mini MailHog tutorial (for a Mac):

Install mailhog:

brew install mailhog
mailhog


now open config/environments/development.rb

and add the following mail settings (for development):

# config/environments/development.rb
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
# ...
# mailhog config
config.action_mailer.perform_deliveries = true
config.action_mailer.smtp_settings = { address: 'localhost', port: 1025 }
# ...
end


at this point you will be able to go to: http://localhost:8025/ and you should see a webpage that looks like a simple mailreader. In the future, when you send an email from rails it should be available here.

We need a way to send the login link - so we will create a login mailer with:

bin/rails generate mailer Login send_link --helper false


Configure our emailer for our needs to send the login link:

# app/mailer/login_mailer.rb
class LoginMailer < ApplicationMailer
@user = user
host = Rails.application.config.hosts.first

mail(to: @user.email, subject: "Access-Link for #{host}")
end
end


Now we need to create the mailer views to send the url with the access token

The HTML view:

# app/views/login_mailer/send_link.html.erb
<h1>Hi <%= @user.email %>,</h1>

<p><a href="<%= @login_url %>">Access-Link for <%= @host %></a></p>

<p> <%= @login_url %> </p>

<p>Link is valid for about an hour from <%= DateTime.now %></p>


The text view:

# app/views/login_mailer/send_link.text.erb
Hi <%= @user.email %>,

Access-Link for <%= @host %> is:

Link is valid for about an hour from <%= DateTime.now %>.


Again feel free to make these pages more beautiful with CSS (Bulma or Tailwind are my favorites)

Lets test our mailer with the rail console:

bin/rails c

# for testing we don't care much which user we pick
user = User.first
# we will just send a 'fake url' - we are just testing our mail sending
url  = "http://localhost:3000/landing"

# we should should now be able to send the mail with:


Now you should be able to see that the mail was send from the console output or by going to: http://localhost:8025/ if you are running mailhog and see the email sent.

Assuming that work:

git add .
git commit -m "Login URL mailer"


Create an Session Authorization Controller

For the session controller we don’t need views or anything else so we can just create the controller file directly.

touch app/controllers/sessions_controller.rb

class SessionsController < ApplicationController
skip_before_action :users_only, only: :create

def create
token = params[:token].to_s
# find the user with a matching token and with current-time < token_expired_at
user = User.where(token: token)
.where('users.token_expires_at > (?)', DateTime.now)
.first
if user
# create the session id for current_user to access
session[:user_id] = user.id
# send the user to their homepage (or where erver you prefer)
redirect_to(home_path, notice: "Welcome back #{user.name}")
else
flash[:alert] = 'Oops - a valid login link is required'
redirect_to(landing_path)
# when the login request page is built it might make sense to redirect to:
end
end

# allow a user to logout / destroy session if desired
def destroy
user = current_user
if user
session[:user_id] = nil
flash[:notice] = "logout successful"
else
falsh[:alert] = "Oops, there was a problem"
end
redirect_to(landing_path)
end

end


Add to routes (I am using a get for the create instead of a post verb - since I don’t know of a way to make a text url embed a post verb) - so we will add:

  get '/sessions/:token', to: 'sessions#create',  as: :create_session
resources :sessions,    only: [:destroy]


to the routes file:

Now the routes should look something like:

# config/routes.rb
Rails.application.routes.draw do
# use get to create since I don't think a text url can create a post
get '/sessions/:token', to: 'sessions#create',  as: :create_session
resources :sessions,    only: [:destroy]
get '/home',    to: 'home#index',     as: :home
resources :users
get '/landing', to: 'landing#index',  as: :landing
root to: "landing#index"
end


now if we check the session routes we should see something like:

bin/rails routes | grep session
create_session    GET     /sessions/:token(.:format)   sessions#create
session    DELETE  /sessions/:id(.:format)      sessions#destroy


OPTIONAL: by default rails sessions have no expiration, thus are deleted when the browser closes. To change this default behavior, we need to create the file.

touch config/initializers/session_store.rb


Now you can set the session length (time until a new login is required) with the setting:

# config/initializers/session_store.rb
Rails.application.config.session_store :cookie_store, expire_after: 14.days


you might want to use 2 weeks or 4 weeks - whatever you and your users are comfortable with before forcing a new login (if unsure - go with a shorter time-frame)

Let’s setup a user with a known valid token and test our new session controller:

bin/rails c

user = User.first
# the token length isn't so important but should be enough to make guessing very hard
user.token = SecureRandom.hex(50)  # be sure to use a url-safe random-generator
# expiration time should be relatively short - email is generally not encrypted
user.token_expires_at = DateTime.now + 1.hour
user.save

# generate the URL for the session path
# (we need to give the full rails path to the url_helpers since we don't have the controller loaded)
url = Rails.application.routes.url_helpers.create_session_url(token: user.token, host: 'localhost:3000')

# copy the above url into the browser


Now when we enter the url generated in the email (or click on the link in mailhog), we should be redirected to the “home” page http://localhost:3000/home

Assumeing that works:

git add .
git commit -m "session controller (login)"


We will need to allow the user to request an access-link.

Now lets create a user login controller:

bin/rails g controller Logins new create --helper false


Now we need the Logins Controller login AND be sure to add

skip_before_action :users_only

Also note the code is similar to what we entered previously in the console.

# app/controllers/users/logins_controller.rb
class LoginsController < ApplicationController
# we need to skip the users only check so this pages can be accessed
skip_before_action :users_only

def new
user = User.new
render :new, locals: {user: user}
end

def create
email = user_params[:email]

# we may or may not find a user
user = User.find_by(email: email)

# always take the time to calculate token info (discourages email fishing)
token = SecureRandom.hex(50)
# besure to use NOW and not NEW!
token_expires_at = DateTime.now + 1.hour
token_params = {token: token, token_expires_at: token_expires_at}

# if we have a user and the update is successful
if user && user.update(token_params)
access_url = create_session_url(token: user.token)
end

# # uncomment to add noise to discourage response time monitoring
# # in order to mine user emails
# mini_wait = Random.new.rand(10..20) / 1000
# wait(mini_wait)

# true or not we state we have sent an access link and redirect to the landing page
# also prevent email fishing by always returning the same answer
redirect_to(landing_path, notice: "Access-Link has been sent")
end

private
# Only allow a list of trusted parameters through.
def user_params
params.require(:user).permit(:email)
end
end


NOTE: In real projects I tend to put all my business logic in a command or service class – I like skinny models and skinny controllers)

We need to add the route to the users login_controller with:

# config/routes.rb
Rails.application.routes.draw do
resources :logins,      only: [:new, :create]
# use get to create since I don't think a text url can create a post
get '/sessions/:token', to: 'sessions#create',  as: :create_session
resources :sessions,    only: [:destroy]
get '/home',    to: 'home#index',     as: :home
resources :users
get '/landing', to: 'landing#index',  as: :landing
root to: "landing#index"
end


now check the routes:

bin/rails routes | grep logins
# should return


Lets go to the new page http://localhost:3000/logins/new and be sure that we can get access that page.

Now login into the console and check the user attributes.

bin/rails c
user = User.find_by(email: 'test1.test.ch') # or whatever email you used
user.token # be sure it updated with the same key as in the logs
user.token_expires_at # should be an hour into the future
# if the date is the year 0000 - then you used .new (with an e) instead of .now


Assuming this works:

git add .
git commit -m "login controller"


Login email form - we only need app/views/logins/new.html.erb

We can delete app/views/logins/create.html.erb as that just posts to an action and then redirects to our user’s home_path

# app/views/ogins/new.html.erb
<%= form_for(user, local: true,
url: logins_path, # NEW MUST BE PLURAL for POST
id: "login-form", class: "user" ) do |form|  %>

<div class="field">
<label class="label">Email for Access-Link</label>
<div class="control">
<%= form.email_field :email,
placeholder: "Email",
class: 'input' %>
</div>
<p class="help"></p>
</div>

<div class="control">
<%= form.submit("Get Access-Link", class: "button is-success") %>
</div>

<% end %>


PS - I dislike using instance variables (and often use ‘input’ classes) with my forms - this is why this form looks a little different than standard rails.

Note I often use Bulma - so here is how I like to format my forms (without Bulma installed the form will be ugly).

Let’s test this code:

First:

• go to: http:localhost:3000/home
• hopefully your are redirected to: http:localhost:3000/landing

Now:

• go to: http:localhost:3000/logins/new
• enter a user’s email address
• find the login url generated in the email
• enter that login_url in the browser - (ideally click on the link in mailhog - much like a ‘real user’ would do)
• hopefully you are now redirected to http:localhost:3000/home

Assuming this works:

git add .
git commit -m "login form and create action with redirect"


Resources

s Code Repository is at:

Rails GlobalID

The nice thing about these is that the auto expire - simplifying the code and the usermodel.

Token using SecureRandom

With these you need to create your own expiration and lookup system (more code add a migration), but will work with any framework.

Devise Options

Other Options

Sessions

Bill Tihen
Developer, Data Enthusiast, Educator and Nature’s Friend

very curious – known to explore knownledge and nature