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:
- Do it yourself: with a Signed-GlobalID from Rails (self-times out & no migration)
- Do it yourself: with a Stored-Token (adapts to any framework)
- 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
- User enters their email-address in a simple form
- If account is found - a link with a token is generated and email is sent
- User is notified that the link is on its way (even if the account is not found and no email is sent)
- When the user follows the link in the email, a session is generated
- 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,
                    :alert => "Login Required")
      # # uncomment to send people access link page (when built)
      # redirect_back(fallback_location: new_login_path,
      #               :alert => "Login Required")
    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.
Create an emailer to send Access-Links
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
  def send_link(user, login_url)
    @user = user
    @login_url  = login_url
    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:
<%= @login_url %>
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:
LoginMailer.send_link(user, url).deliver_later
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:
      # redirect_to(new_login_path)
    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
user.reload
# 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)"
Access-Link Generation
We will need to allow the user to request an access-link.
Login Controller
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
so this page is always available!
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)
      LoginMailer.send_link(user, access_url).deliver_later
    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
     logins   POST  /logins(.:format)       logins#create
  new_login   GET   /logins/new(.:format)   logins#new
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 Form
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.
- (using uuids) - https://oozou.com/blog/how-to-implement-passwordless-authentication-in-ruby-on-rails-154
- (using SecureRandom) - https://abevoelker.com/skipping-the-database-with-stateless-tokens-a-hidden-rails-gem-and-a-useful-web-technique/
Devise Options
- Devise Plugin - https://github.com/abevoelker/devise-passwordless
- Do it Yourself Devise - https://dev.to/matiascarpintini/magic-links-with-ruby-on-rails-and-devise-4e3o
- Do it yourself Devise - https://www.mintbit.com/blog/passwordless-authentication-in-ruby-on-rails-with-devise
Other Options
- 
passwordless gem - https://github.com/mikker/passwordless#token-and-session-expiry 
- 
magic-link gem - https://github.com/dvanderbeek/magic-link 
- 
Using Sorcery - https://fullstackheroes.com/rails/sorcery-passwordless-authentication/ 
- 
Using Sourcery - https://www.sitepoint.com/magical-authentication-sorcery/ 
- 
(using JWTs) - https://blog.kiprosh.com/implement-passwordless-authentication-via-magic-link-in-rails-api/ Passwordless Security Overview 
Sessions
