Quo Vadis

Multifactor authentication for your Rails 6 app.

Designed in accordance with the OWASP Application Security Verification Standard and relevant OWASP Cheatsheets.

Simple to integrate into your application. The main task is customising the example views' markup to match your look-and-feel.

Features

General features

  • Works with any model, e.g. User or Person.
  • Works with any identifier, e.g. :username or :email.
  • Minimal footprint in your models and controllers.
  • Does not touch your existing database tables.
  • Secrets (password, TOTP secret, 2FA recovery codes) are encrypted at rest.

Authentication features

  • Authentication by password.
  • Two-factor authentication (2FA) by TOTP with recovery codes as a backup factor. Can be optional or mandatory.
  • Change password.
  • Reset password.
  • Account confirmation (optional).
  • Tokens (account confirmation, password reset), TOTPs, and recovery codes are all one-time-only.
  • Sessions expired after lifetime or idle time exceeded.
  • Session replaced after any privilege change.
  • View active sessions, log out of any of them.
  • Email-notifications of updates to authentication details.
  • Audit trail.

Installation

Add the gem to your Gemfile:

gem 'quo_vadis', '~> 2.0'

Then run bundle install.

Next, add the database tables:

rails quo_vadis:install:migrations && rails db:migrate

All the database tables are prefixed with qv_.

Finally, copy the example views across:

rails generate quo_vadis:install

Usage

Model

Your model must have an :email attribute. All authentication-related emails will be sent to this address.

Your model must have an identifier, e.g. :email (default) or :username, with a uniqueness validation.

All you need do is add a call to authenticates, somewhere after your identifier's uniqueness validation.

For example, let's say you have a User model and the identifier is :email:

class User < ApplicationRecord
  validates :email, uniqueness: {case_sensitive: false}
  authenticates
end

If instead you had a Person model with a :username identifier:

class Person < ApplicationRecord
  validates :username, uniqueness: {case_sensitive: false}
  authenticates identifier: :username
end

You can create and update your models as before. When you want to set a password for the first time, just include :password and, optionally, :password_confirmation in the attributes to #create or #update.

If you want to change an existing password, use the Change Password feature (see below). If you update a model (that already has a password) with a :password attribute, it will raise a QuoVadis::PasswordExistsError.

The minimum password length is configured by QuoVadis.password_minimum_length (12 by default).

Controllers

You can use these methods in your controllers.

require_password_authentication

Use this to restrict actions to password-authenticated users. It is aliased to :require_authentication for convenience.

class FoosController < ApplicationController
  before_action :require_password_authentication
end

require_two_factor_authentication

Use this to restrict actions to users authenticated with both a password and a second factor. (You do not need to use :require_password_authentication for these actions.)

class BarsController < ApplicationController
  before_action :require_two_factor_authentication
end

login(model, browser_session = true)

To log in a user who has authenticated with a password, call #login(model, browser_session = true). For the browser_session argument, pass true to log in for the duration of the browser session, or false to log in for QuoVadis.session_lifetime (which could be the browser session anyway).

request_confirmation(model)

This is used to sent an account confirmation email to the user. See the Account Confirmation feature below for details.

authenticated_model

Call this to get the authenticated user. Feel free to alias this to :current_user or set it into an ActiveSupport::CurrentAttributes class.

Available in controllers and views.

logged_in?

Call this to find out whether a user has authenticated with a password.

Available in controllers and views.

Views

You can use authenticated_model and logged_in? in your views. For example:

<% if logged_in? %>
  <%= link_to 'My profile', authenticated_model %>
<% end %>

In your own views, you must prefix QuoVadis's routes with quo_vadis.. For example:

link_to 'Log in', quo_vadis.

When you are customising QuoVadis's views, you must prefix your app's routes with main_app.. For example:

link_to 'Home', main_app.root_path

Features

The example views show the forms and fields you need. You should only need to adapt the markup to suit your app's appearance.

In the snippets below we assume a User model whose identifier is :email. You can of course use anything you like.

Sign up

Your new user sign-up form (example) must include:

  • a :password field;
  • optionally a :password_confirmation field;
  • a field for their identifier;
  • an :email field if the identifier is not their email.

In your controller, use the #login method to log in your new user. The optional second argument sets the length of the session (defaults to the browser session) - see the description above in the Controllers section).

After logging in the user, redirect them to qv.path_after_authentication which resolves to a route named :after_login, if you have one, or your root route.

class UsersController < ApplicationController
  def create
    @user = User.new user_params
    if @user.save
       @user
      redirect_to qv.path_after_authentication
    else
      # ...
    end
  end

  private

  def user_params
    params.require(:user).permit(:name, :email, :password, :password_confirmation)
  end
end
# config/routes.rb
get '/dashboard', as: 'after_login'

Sign up with account confirmation

Here's the workflow:

  1. [Sign up page] The user fills in their details.
  2. [Your controller] Your code tells QuoVadis to email the user a confirmation link. The link is valid for QuoVadis.account_confirmation_token_lifetime.
  3. [The email] The user clicks the link.
  4. [Account-confirmation confirmation page] The user clicks a button to confirm their account. (This step is to prevent any link prefetching in the user's mail client from confirming them unintentionally.)
  5. QuoVadis confirms the user's account and logs them in.

Your new user sign-up form (example) must include:

  • a :password field;
  • optionally a :password_confirmation field;
  • a field for their identifier;
  • an :email field if the identifier is not their email.

In your controller, call #request_confirmation:

class UsersController < ApplicationController
  def create
    @user = User.new user_params
    if @user.save
      request_confirmation @user
      redirect_to quo_vadis.confirmations_path  # a page where you advise the user to check their email
    else
      # ...
    end
  end

  private

  def user_params
    params.require(:user).permit(:name, :email, :password, :password_confirmation)
  end
end

QuoVadis will send the user an email with a link. Write the email view (example). It must be in app/views/quo_vadis/mailer/account_confirmation.{text,html}.erb and output the @url variable.

See the Configuration section below for how to set QuoVadis's emails' from addresses, headers, etc.

Now write the page to where the user is redirected while they wait for the email (example). It must be in app/views/quo_vadis/confirmations/index.html.:format.

On that page you can show the user the address the email was sent to, enable them to update their email address if they make a mistake on the sign-up form, and provide a button to resend another email directly. If the sign-up occurred in a different browser session, you can instead link to new_confirmation_path where the user can request another email if need be.

Next, write the page to which the link in the email points (example). It must be in app/views/quo_vadis/confirmations/edit.html.:format.

Next, write the page where the user can amend their email address if they made a mistake when signing up (example). It must be in app/views/quo_vadis/confirmations/edit_email.html.:format.

Finally, write the page where people can put in their identifier (not their email, unless the identifier is email) again to request another confirmation email (example). It must be in app/views/quo_vadis/confirmations/new.html.:format.

After the user has confirmed their account, they will be logged in and redirected to the first of these that exists:

  • a route named :after_login;
  • your root route.

So add whichever works best for you.

Login

Use before_action :require_password_authentication or before_action :require_authentication in your controllers.

Write the login view (example). Your login form must be in app/views/quo_vadis/sessions/new.html.:format. Note it must capture the user's identifier (not email, unless the identifier is email).

If you include a remember checkbox in your login form:

  • if the user checks it, they will be logged in for QuoVadis.session_lifetime;
  • if the user does not check it, they will be logged in for the browser session.

If you do not include a remember checkbox, the user will be logged in for QuoVadis.session_lifetime.

After authenticating the user will be redirected to the first of these that exists:

  • the page they tried to view before they were redirected to the login page;
  • a route named after_login, if any;
  • your root route.

Two-factor authentication (2FA) or Two-step verification (2SV)

If you do not want 2FA at all, set QuoVadis.two_factor_authentication_mandatory false in your configuration and skip the rest of this section.

If you do want 2FA, you can choose whether it is optional or mandatory for your users by setting QuoVadis.two_factor_authentication_mandatory <true|false> in your configuration.

Use before_action :require_two_factor_authentication in your controllers (which supersedes :require_password_authentication). This will require the user, after authenticating with their password, to authenticate with 2FA – when 2FA is mandatory, or when it is optional and the user has set up 2FA.

Here's the workflow for a user setting up optional 2FA:

  1. User visits their 2FA overview page.
  2. [2FA overview page] User clicks a link to set up 2FA (TOTP for now).
  3. [TOTP setup page] User scans the QR code with their authenticator and enters the 6-digit one-time password.
  4. QuoVadis verifies the one-time password, generates 5 backup recovery codes, and redirects the user to the recovery codes page (or back to step 3 if the OTP is invalid).
  5. [Recovery code page] User views and hopefully saves their 5 recovery codes.

When 2FA is mandatory the workflow starts automatically at step 3 after password authentication.

In your views, have a link where users can manage their 2FA:

link_to '2FA', quo_vadis.twofa_path

Write the 2FA overview page (example). It must be in app/views/quo_vadis/twofas/show.html.:format. This page allows the user to set up 2FA, deactivate or reset it, and generate new recovery codes.

Next, write the TOTP setup page (example). It must be in app/views/quo_vadis/totps/new.html.:format. This page shows the user a QR code (and the key as text) which they scan with their authenticator.

Next, write the recovery codes page (example). It must be in app/views/quo_vadis/recovery_codes/index.html.:format. This shows the recovery codes immediately after TOTP is setup, and immediately after generating fresh recovery codes, but not otherwise.

Next, write the TOTP challenge page where a user inputs their 6-digit TOTP (example). It must be in app/views/quo_vadis/totps/challenge.html.:format. It's a good idea to link to the recovery code page (challenge_recovery_codes_path) for any user who has lost their authenticator.

Finally, write the recovery code challenge page where a user inputs one of their recovery codes (example). It must be in app/views/quo_vadis/recovery_codes/challenge.html.:format. A recovery code can only be used once, and using one deactivates TOTP – so the user will have to set it up again next time.

Change password

To change their password, the user must provide their current one as well as the new one.

Write the change-password form (example). It must be in app/views/quo_vadis/passwords/edit.html.:format.

After the password has been changed, the user is redirected to the first of:

  • your route named :after_password_change, if any;
  • your root route.

A successful password change logs out any other sessions the user has (e.g. on other devices).

Reset password

The user can reset their password if they lose it. The flow is:

  1. [Request password-reset page] User enters their identifier (not their email unless the identifier is email).
  2. QuoVadis emails the user a link. The link is valid for QuoVadis.password_reset_token_lifetime.
  3. [The email] The user clicks the link.
  4. [Password-reset confirmation page] The user enters their new password and clicks a button.
  5. QuoVadis sets the user's password and logs them in.

First, write the page where the user requests a password-reset (example). It must be in app/views/quo_vadis/password_resets/new.html.:format. Note it must capture the user's identifier (not email, unless the identifier is email).

See the Configuration section below for how to set QuoVadis's emails' from addresses, headers, etc.

Now write the page to where the user is redirected while they wait for the email (example). It must be in app/views/quo_vadis/password_resets/index.html.:format.

It's a good idea for that page to link to new_password_reset_path where the user can request another email if need be.

Now write the email view (example). It must be in app/views/quo_vadis/mailer/reset_password.{text,html}.erb and output the @url variable.

Next, write the page to which the link in the email points (example). It must be in app/views/quo_vadis/password_resets/edit.html.:format.

After the user has reset their password, they will be logged in and redirected to the first of these that exists:

  • a route named :after_login;
  • your root route.

Sessions

A logged-in session lasts for either the browser session or QuoVadis.session_lifetime. As well as having a lifetime, a session will also expire after it has been inactive for QuoVadis.session_idle_timeout.

A user can view their active sessions and log out of any of them.

Write the view showing the sessions (example). It must be in app/views/quo_vadis/sessions/index.html.:format.

Audit trail

An audit trail is kept of authentication events. You can see the full list in the Log class.

Write the view showing the events (example). It must be in app/views/quo_vadis/logs/index.html.:format.

Notifications

QuoVadis notifies users by email whenever their authentication details are changed or something suspicious happens.

Write the corresponding mailer views:

They must be in app/views/quo_vadis/mailer/NAME.{text,html}.erb.

Configuration

This is QuoVadis' default configuration:

QuoVadis.configure do
  password_minimum_length               12
  mask_ips                              false
  cookie_name                           (Rails.env.production? ? '__Host-qv' : 'qv')
  session_lifetime                      :session
  session_lifetime_extend_to_end_of_day false
  session_idle_timeout                  :lifetime
  password_reset_token_lifetime         10.minutes
  accounts_require_confirmation         false
     10.minutes
  mail_headers                          ({ from: 'Example App <[email protected]>' })
  enqueue_transactional_emails          true
  app_name                              Rails.app_class.to_s.deconstantize  # for the TOTP QR code
  two_factor_authentication_mandatory   true
  mount_point                           '/'
end

You can override any of it with a similarly structured file in config/initializers/quo_vadis.rb.

Here are the options in detail:

password_minimum_length (integer)

The minimum number of characters for a password.

mask_ips (boolean)

Whether to mask the IP address in the sessions list and the audit trail.

Masking means setting the last octet (IPv4) or the last 80 bits (IPv6) to 0.

cookie_name (string)

The name of the cookie QuoVadis uses to store the session identifier. The __Host- prefix is recommended in an SSL environment (but cannot be used in a non-SSL environment).

session_lifetime (:session | ActiveSupport::Duration | integer)

The lifetime of a logged-in session. Use :session for the browser session, or a Duration or number of seconds.

session_lifetime_extend_to_end_of_day (boolean)

Whether to extend the session's lifetime to the end of the day it will expire on.

Set true to reduce the chance of a user being logged out while actively using your application.

session_idle_timeout (:lifetime | ActiveSupport::Duration | integer)

The logged-in session is expired if the user isn't seen for this Duration or number of seconds. Use :lifetime to set the idle timeout to the session's lifetime (i.e. to turn off the idle timeout).

password_reset_token_lifetime (ActiveSupport::Duration | integer)

The Duration or number of seconds for which a password-reset token is valid.

accounts_require_confirmation (boolean)

Whether new users must confirm their account before they can log in.

account_confirmation_token_lifetime (ActiveSupport::Duration | integer)

The Duration or number of seconds for which an account-confirmation token is valid.

mail_headers (hash)

Mail headers which QuoVadis' emails should have.

enqueue_transactional_emails (boolean)

Set true if account-confirmation and password-reset emails should be queued for later delivery (#deliver_later) or false if they should be sent inline (#deliver_now).

app_name (string)

Used in the provisioning URI for the TOTP QR code.

two_factor_authentication_mandatory (boolean)

Whether users must set up and use a second authentication factor.

mount_point (string)

The path prefix for QuoVadis's routes.

For example, the default login path is at /login. If you set mount_point to /auth, the login path would be /auth/login.

Rails configuration

Mailer URLs

You must also configure the mailer host so URLs are generated correctly in emails:

config.action_mailer.default_url_options: { host: 'example.com' }

Layouts

You can specify QuoVadis's controllers' layouts in a #to_prepare block in your application configuration. For example:

# config/application.rb
module YourApp
  class Application < Rails::Application
    config.to_prepare do
      QuoVadis::ConfirmationsController.layout 'your_layout'
    end
  end
end

Routes

You can set up your post-authentication and post-password-change routes. If you don't, you must have a root route. For example:

# config/routes.rb
get '/dashboard', to: 'dashboards#show', as: 'after_login'
get '/profile',   to: 'profiles#show',   as: 'after_password_change'

I18n

All QuoVadis' flash messages are set via i18n.

You can override any of the messages with your own locale file at config/locales/quo_vadis.en.yml.

If you don't want a specific flash message at all, give the key an empty value in your locale file.

Intellectual Property

Copyright 2011-2021 Andrew Stewart ([email protected]).

Released under the MIT licence.