Devise is currently the most popular web authentication library for Rails. As a leading library in the web authentication category, Devise has plenty of third-party companion libraries that allow you to enhance the functionality of Devise.
One of these libraries is devise-two-factor which adds support for One Time Password (OTP) based two factor authentication.
devise-two-factor deliberately leaves the issue of how the UI/UX should work up to the implementer:
Devise-Two-Factor only worries about the backend, leaving the details of the integration up to you. This means that you're responsible for building the UI that drives the gem
If you follow the README for devise-two-factor or look at most publicly available examples you're likely going to end up with a login form that looks a little like this:
This isn't a great user experience in my opinion, as the user is prompted for an OTP code regardless of whether the user has enabled two-factor authentication or not.
I wanted to build a two-stage/two-screen workflow where the user is first asked for their email and password before being prompted for the OTP.
devise-two-factor has a closed issue – #14: Two factor as second login step? – where various members of the community are asking for and volunteering help on how to achieve this.
This is how I have implemented OTP two-factor as a second login step, based on the community contributions in the GitHub issue.
Implementation
My solution is implemented in a Rails 6 app, following a standard devise and devise-two-factor setup. As devise and devise-two-factor are well documented I will briefly recap the setup steps without providing an in-depth explanation which can be gained by reading the documentation/READMEs of each library.
In the examples that follow you'll notice that I'm also using a few basic gems like Twitter Bootstrap and Simple Form. In the interest of focusing on devise and devise-two-factor, I will sign post you to the documentation for these libraries, if you're not familiar with them already:
Step 1: Install devise
Add Devise to your Gemfile
:
gem "devise"
Install Devise and create/augment your User
model with Devise attributes:
rails generate devise:install
rails generate devise User
Update your application controller to call :authenticate_user!
as a before_action
to ensure that any of your application controllers use Devise to ensure that your user is actually logged in whenever they access your application:
class ApplicationController < ActionController::Base
before_action :authenticate_user!
end
Run rake db:migrate
to apply any necessary migrations following the setup of devise.
At this point Devise is now installed and you should be able to sign up a new account and use your sign up details to login.
I also put together a basic screen that will show me who I am logged in as:
This also has an "Account Settings" link that takes me to the devise registrations#edit
action which will let the user change their password:
Step 2: Install devise-two-factor
Assuming you can now login and logout of your application using email and password authentication we can look to add OTP-based two-factor authentication using devise-two-factor.
Add devise-two-factor to your gem file, and rqrcode
for displaying the OTP secret as a QR code. We will also use simple_form
for some of our forms:
gem 'devise-two-factor'
gem 'rqrcode'
gem 'simple_form'
Run bundle install
Run the devise-two-factor generator to augment the User model
:
rails generate devise_two_factor User OTP_SECRET_KEY
OTP_SECRET_KEY
is an environment variable that must be set when your application runs.
Optional: Support Backup Codes
In my implementation I wanted to support backup codes, so I generated a migration to add support for storing the codes:
rails g migration AddDeviseTwoFactorBackupableToUsers
class AddDeviseTwoFactorBackupableToUsers < ActiveRecord::Migration[6.0]
def change
# Change type from :string to :text if using MySQL database
add_column :users, :otp_backup_codes, :string, array: true
end
end
The User
model also needs updating to include :two_factor_backupable
as a devise capability. You can also configure the number and length of backup codes:
devise :two_factor_authenticatable, :two_factor_backupable,
otp_backup_code_length: 10, otp_number_of_backup_codes: 10,
:otp_secret_encryption_key => ENV['OTP_SECRET_KEY']
Step 3: Enabling/Disabling OTP-based two-factor authentication
Before we update the implementation of our login screen, we will first need to allow the user to enable and disable two-factor authentication.
The User
model need to be enhanced by adding a series of two-factor methods that will allow:
- An OTP secret to be generate
- Two-factor OTP to be enabled
- Two-factor OTP to be disabled
- A QR code to be generated representing the issuer and OTP secret
- A method for determining if backup codes have been generated or not
class User < ApplicationRecord
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :registerable,
:recoverable, :rememberable, :validatable
devise :two_factor_authenticatable, :two_factor_backupable,
otp_backup_code_length: 10, otp_number_of_backup_codes: 10,
:otp_secret_encryption_key => ENV['OTP_SECRET_KEY']
# Ensure that backup codes can be serialized
serialize :otp_backup_codes, JSON
attr_accessor :otp_plain_backup_codes
# Generate an OTP secret it it does not already exist
def generate_two_factor_secret_if_missing!
return unless otp_secret.nil?
update!(otp_secret: User.generate_otp_secret)
end
# Ensure that the user is prompted for their OTP when they login
def enable_two_factor!
update!(otp_required_for_login: true)
end
# Disable the use of OTP-based two-factor.
def disable_two_factor!
update!(
otp_required_for_login: false,
otp_secret: nil,
otp_backup_codes: nil)
end
# URI for OTP two-factor QR code
def two_factor_qr_code_uri
issuer = ENV['OTP_2FA_ISSUER_NAME']
label = [issuer, email].join(':')
otp_provisioning_uri(label, issuer: issuer)
end
# Determine if backup codes have been generated
def two_factor_backup_codes_generated?
otp_backup_codes.present?
end
end
We can now use these methods to put together a controller and a workflow that will allow the user to enable or disable OTP-based two-factor authentication.
We will now create a new controller – TwoFactorSettingsController
– that will let the user enable and disable two-factor authentication.
This is a straightforward RESTful resource where the
new
andcreate
routes are used to enable two-factor authentication. Once enabled the user is redirected to:edit
edit
shows the backup codesdestroy
disabled two-factor authentication
class TwoFactorSettingsController < ApplicationController
before_action :authenticate_user!
def new
if current_user.otp_required_for_login
flash[:alert] = 'Two Factor Authentication is already enabled.'
return redirect_to edit_user_registration_path
end
current_user.generate_two_factor_secret_if_missing!
end
def create
unless current_user.valid_password?(enable_2fa_params[:password])
flash.now[:alert] = 'Incorrect password'
return render :new
end
if current_user.validate_and_consume_otp!(enable_2fa_params[:code])
current_user.enable_two_factor!
flash[:notice] = 'Successfully enabled two factor authentication, please make note of your backup codes.'
redirect_to edit_two_factor_settings_path
else
flash.now[:alert] = 'Incorrect Code'
render :new
end
end
def edit
unless current_user.otp_required_for_login
flash[:alert] = 'Please enable two factor authentication first.'
return redirect_to new_two_factor_settings_path
end
if current_user.two_factor_backup_codes_generated?
flash[:alert] = 'You have already seen your backup codes.'
return redirect_to edit_user_registration_path
end
@backup_codes = current_user.generate_otp_backup_codes!
current_user.save!
end
def destroy
if current_user.disable_two_factor!
flash[:notice] = 'Successfully disabled two factor authentication.'
redirect_to edit_user_registration_path
else
flash[:alert] = 'Could not disable two factor authentication.'
redirect_back fallback_location: root_path
end
end
private
def enable_2fa_params
params.require(:two_fa).permit(:code, :password)
end
end
And the controller should be added to the routes file:
resource :two_factor_settings, except: [:index, :show]
We will also need to define the associated new.html.erb
and edit.html.erb
views.
Let's start with the new
view:
qr_code_as_svg
is a helper method that we'll need:
Which should look a little like this:
The edit
view will show the user the backup codes:
Our two-factor controller will need to be accessible from somewhere. I've opted to make this accessible by modifying the app/views/devise/registrations/edit.html.erb
view:
If you're using Devise with the built in view you can run a generator to generate the set of view files that you can then edit:
rails generate devise:views
The final thing left to do here is to update the way that the login screen works. The update will involve adding an otp_attempt
field to the login form, so that the view looks as follows:
The sessions controller will also need to be updated to support the otp_attempt
parameter.
Create a new SessionsController
that will override the built-in SessionsController
that Devise ships with:
Using a custom controller involves updating our devise_for
entry in our config/routes.rb
:
devise_for :users, controllers: {
sessions: 'sessions'
}
We're now at the point where a user can enable/disable OTP-based two-factor authentication, and login. But the user experience is clunky, as there will always be an OTP field visible regardless of whether the user has enabled two-factor or not. This can be very confusing for an end user.
In our final step we will modify the SessionsController
so that the OTP code can be collected via a second login screen.
Step 4: Custom SessionsController and AuthenticateWithOtpTwoFactor concern
Devise handles logins on the create
action. One way of modifying the login process is to add a prepend_before_action
when the create
action is called for a user who has two factor authentication enabled:
At a high level prepend_before_action
will apply the following workflow:
- If a valid password has been submitted but no
otp_attempt
is present render a prompt requesting the OTP code. This form will submit to thecreate
action, causing theprepend_before_action
to execute again - If an
otp_attempt
has been provided, validate that the OTP is correct. If it is, sign the user in, if it is not, prompt for the OTP code again
We can implement this by modifying the SessionsController
and implementing an AuthenticateWithOtpTwoFactor
concern. The concern will keep the two-factor complexities outside of the controller:
Finally, remove the otp_attempt
from the login screen and create a devise/sessions/two_factor.html.erb
view:
Conclusion
This implementation will result in two-stage login process where the user is first asked for their email and password, before being prompted for their OTP code:
The code for this example is available on GitHub — jamesridgway/devise-otp-second-step.
The example is complete with RSpec feature tests that can be run using
bundle exec rspec
Resources:
- Original GitHub issue which inspired and influenced this post: Issue #14: Two factor as second login step?
- Full example available on GitHub: jamesridgway/devise-otp-second-step