Browse Source

Add support for directly delivered OTP codes

Direct OTP codes are ones that are delivered directly to
the user (e.g. SMS) via send_two_factor_authentication_code.
These are randomly generated, short lived, and stored
directly in the database.

TOTP (and the rotp gem) is now only enabled for those user
that have a shared secret (user.create_otp_secret).
master
Sam Clegg 9 years ago
parent
commit
eed1bf62a1
  1. 56
      README.md
  2. 3
      app/controllers/devise/two_factor_authentication_controller.rb
  3. 14
      app/views/devise/two_factor_authentication/show.html.erb
  4. 2
      lib/generators/active_record/templates/migration.rb
  5. 6
      lib/two_factor_authentication.rb
  6. 2
      lib/two_factor_authentication/hooks/two_factor_authenticatable.rb
  7. 64
      lib/two_factor_authentication/models/two_factor_authenticatable.rb
  8. 8
      lib/two_factor_authentication/schema.rb
  9. 4
      spec/controllers/two_factor_authentication_controller_spec.rb
  10. 38
      spec/features/two_factor_authenticatable_spec.rb
  11. 177
      spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb
  12. 9
      spec/rails_app/app/models/guest_user.rb
  13. 4
      spec/rails_app/app/models/user.rb
  14. 2
      spec/support/authenticated_model_helper.rb
  15. 11
      spec/support/totp_helper.rb

56
README.md

@ -7,12 +7,14 @@
## Features ## Features
* configurable OTP code digit length * Support for 2 types of OTP codes
* configurable max login attempts 1. Codes delivered directly to the user
* customizable logic to determine if a user needs two factor authentication 2. TOTP (Google Authenticator) codes based on a shared secret (HMAC)
* customizable logic for sending the OTP code to the user * Configurable OTP code digit length
* configurable period where users won't be asked for 2FA again * Configurable max login attempts
* option to encrypt the OTP secret key in the database, with iv and salt * Customizable logic to determine if a user needs two factor authentication
* Configurable period where users won't be asked for 2FA again
* Option to encrypt the TOTP secret in the database, with iv and salt
## Configuration ## Configuration
@ -44,6 +46,8 @@ migration in `db/migrate/`, which will add the following columns to your table:
- `:encrypted_otp_secret_key` - `:encrypted_otp_secret_key`
- `:encrypted_otp_secret_key_iv` - `:encrypted_otp_secret_key_iv`
- `:encrypted_otp_secret_key_salt` - `:encrypted_otp_secret_key_salt`
- `:direct_otp`
- `:direct_otp_sent_at`
#### Manual initial setup #### Manual initial setup
If you prefer to set up the model and migration manually, add the If you prefer to set up the model and migration manually, add the
@ -57,7 +61,7 @@ devise :database_authenticatable, :registerable, :recoverable, :rememberable,
Then create your migration file using the Rails generator, such as: Then create your migration file using the Rails generator, such as:
``` ```
rails g migration AddTwoFactorFieldsToUsers second_factor_attempts_count:integer encrypted_otp_secret_key:string:index encrypted_otp_secret_key_iv:string encrypted_otp_secret_key_salt:string rails g migration AddTwoFactorFieldsToUsers second_factor_attempts_count:integer encrypted_otp_secret_key:string:index encrypted_otp_secret_key_iv:string encrypted_otp_secret_key_salt:string direct_otp:string direct_otp_sent_at:datetime
``` ```
Open your migration file (it will be in the `db/migrate` directory and will be Open your migration file (it will be in the `db/migrate` directory and will be
@ -82,21 +86,24 @@ Set config values in `config/initializers/devise.rb`:
```ruby ```ruby
config.max_login_attempts = 3 # Maximum second factor attempts count. config.max_login_attempts = 3 # Maximum second factor attempts count.
config.allowed_otp_drift_seconds = 30 # Allowed time drift between client and server. config.allowed_otp_drift_seconds = 30 # Allowed TOTP time drift between client and server.
config.otp_length = 6 # OTP code length config.otp_length = 6 # TOTP code length
config.remember_otp_session_for_seconds = 30.days # Time before browser has to enter OTP code again. Default is 0. config.direct_otp_valid_for = 5.minutes # Time before direct OTP becomes invalid
config.direct_otp_length = 6 # Direct OTP code length
config.remember_otp_session_for_seconds = 30.days # Time before browser has to perform 2fA again. Default is 0.
config.otp_secret_encryption_key = ENV['OTP_SECRET_ENCRYPTION_KEY'] config.otp_secret_encryption_key = ENV['OTP_SECRET_ENCRYPTION_KEY']
``` ```
The `otp_secret_encryption_key` must be a random key that is not stored in the The `otp_secret_encryption_key` must be a random key that is not stored in the
DB, and is not checked in to your repo. It is recommended to store it in an DB, and is not checked in to your repo. It is recommended to store it in an
environment variable, and you can generate it with `bundle exec rake secret`. environment variable, and you can generate it with `bundle exec rake secret`.
Override the method to send one-time passwords in your model. This is Override the method in your model in order to send direct OTP codes. This is
automatically called when a user logs in: automatically called when a user logs in unless they have TOTP enabled (see
below):
```ruby ```ruby
def send_two_factor_authentication_code def send_two_factor_authentication_code(code)
# use Model#otp_code and send via SMS, etc. # Send code via SMS, etc.
end end
``` ```
@ -115,7 +122,14 @@ In the example above, two factor authentication will not be required for local
users. users.
This gem is compatible with [Google Authenticator](https://support.google.com/accounts/answer/1066447?hl=en). This gem is compatible with [Google Authenticator](https://support.google.com/accounts/answer/1066447?hl=en).
You can generate provisioning uris by invoking the following method on your model: To enable this a shared secret must be generated by invoking the following
method on your model:
```ruby
user.generate_totp_secret
```
This can then be shared via a provisioning uri:
```ruby ```ruby
user.provisioning_uri # This assumes a user model with an email attribute user.provisioning_uri # This assumes a user model with an email attribute
@ -123,9 +137,7 @@ user.provisioning_uri # This assumes a user model with an email attribute
This provisioning uri can then be turned in to a QR code if desired so that This provisioning uri can then be turned in to a QR code if desired so that
users may add the app to Google Authenticator easily. Once this is done, they users may add the app to Google Authenticator easily. Once this is done, they
may retrieve a one-time password directly from the Google Authenticator app as may retrieve a one-time password directly from the Google Authenticator app.
well as through whatever method you define in
`send_two_factor_authentication_code`.
#### Overriding the view #### Overriding the view
@ -147,16 +159,16 @@ Below is an example using ERB:
``` ```
#### Updating existing users with OTP secret key #### Enable TOTP support for existing users
If you have existing users that need to be provided with a OTP secret key, so If you have existing users that need to be provided with a OTP secret key, so
they can use two factor authentication, create a rake task. It could look like this one below: they can use TOTP, create a rake task. It could look like this one below:
```ruby ```ruby
desc 'rake task to update users with otp secret key' desc 'rake task to update users with otp secret key'
task :update_users_with_otp_secret_key => :environment do task :update_users_with_otp_secret_key => :environment do
User.find_each do |user| User.find_each do |user|
user.otp_secret_key = ROTP::Base32.random_base32 user.generate_totp_secret
user.save! user.save!
puts "Rake[:update_users_with_otp_secret_key] => OTP secret key set to '#{key}' for User '#{user.email}'" puts "Rake[:update_users_with_otp_secret_key] => OTP secret key set to '#{key}' for User '#{user.email}'"
end end
@ -164,7 +176,7 @@ end
``` ```
Then run the task with `bundle exec rake update_users_with_otp_secret_key` Then run the task with `bundle exec rake update_users_with_otp_secret_key`
#### Adding the OTP encryption option to an existing app #### Adding the TOTP encryption option to an existing app
If you've already been using this gem, and want to start encrypting the OTP If you've already been using this gem, and want to start encrypting the OTP
secret key in the database (recommended), you'll need to perform the following secret key in the database (recommended), you'll need to perform the following

3
app/controllers/devise/two_factor_authentication_controller.rb

@ -16,7 +16,7 @@ class Devise::TwoFactorAuthenticationController < DeviseController
end end
def resend_code def resend_code
resource.send_two_factor_authentication_code resource.send_new_otp
redirect_to user_two_factor_authentication_path, notice: I18n.t('devise.two_factor_authentication.code_has_been_sent') redirect_to user_two_factor_authentication_path, notice: I18n.t('devise.two_factor_authentication.code_has_been_sent')
end end
@ -52,7 +52,6 @@ class Devise::TwoFactorAuthenticationController < DeviseController
if resource.max_login_attempts? if resource.max_login_attempts?
sign_out(resource) sign_out(resource)
render :max_login_attempts_reached render :max_login_attempts_reached
else else
render :show render :show
end end

14
app/views/devise/two_factor_authentication/show.html.erb

@ -1,4 +1,8 @@
<h2>Enter your personal code</h2> <% if current_user.direct_otp %>
<h2>Enter the code that was sent to you</h2>
<% else %>
<h2>Enter the code from your authenticator app</h2>
<% end %>
<p><%= flash[:notice] %></p> <p><%= flash[:notice] %></p>
@ -7,5 +11,9 @@
<%= submit_tag "Submit" %> <%= submit_tag "Submit" %>
<% end %> <% end %>
<%= link_to 'Resend Code', resend_code_user_two_factor_authentication_path, action: :get %> <% if current_user.direct_otp %>
<%= link_to "Sign out", destroy_user_session_path, :method => :delete %> <%= link_to "Resend Code", resend_code_user_two_factor_authentication_path, action: :get %>
<% else %>
<%= link_to "Send me a code instead", resend_code_user_two_factor_authentication_path, action: :get %>
<% end %>
<%= link_to "Sign out", destroy_user_session_path, :method => :delete %>

2
lib/generators/active_record/templates/migration.rb

@ -4,6 +4,8 @@ class TwoFactorAuthenticationAddTo<%= table_name.camelize %> < ActiveRecord::Mig
add_column :<%= table_name %>, :encrypted_otp_secret_key, :string add_column :<%= table_name %>, :encrypted_otp_secret_key, :string
add_column :<%= table_name %>, :encrypted_otp_secret_key_iv, :string add_column :<%= table_name %>, :encrypted_otp_secret_key_iv, :string
add_column :<%= table_name %>, :encrypted_otp_secret_key_salt, :string add_column :<%= table_name %>, :encrypted_otp_secret_key_salt, :string
add_column :<%= table_name %>, :direct_otp, :string
add_column :<%= table_name %>, :direct_otp_sent_at, :datetime
add_index :<%= table_name %>, :encrypted_otp_secret_key, unique: true add_index :<%= table_name %>, :encrypted_otp_secret_key, unique: true
end end

6
lib/two_factor_authentication.rb

@ -16,6 +16,12 @@ module Devise
mattr_accessor :otp_length mattr_accessor :otp_length
@@otp_length = 6 @@otp_length = 6
mattr_accessor :direct_otp_length
@@direct_otp_length = 6
mattr_accessor :direct_otp_valid_for
@@direct_otp_valid_for = 5.minutes
mattr_accessor :remember_otp_session_for_seconds mattr_accessor :remember_otp_session_for_seconds
@@remember_otp_session_for_seconds = 0 @@remember_otp_session_for_seconds = 0

2
lib/two_factor_authentication/hooks/two_factor_authenticatable.rb

@ -11,7 +11,7 @@ Warden::Manager.after_authentication do |user, auth, options|
if user.respond_to?(:need_two_factor_authentication?) && !bypass_by_cookie if user.respond_to?(:need_two_factor_authentication?) && !bypass_by_cookie
if auth.session(options[:scope])[TwoFactorAuthentication::NEED_AUTHENTICATION] = user.need_two_factor_authentication?(auth.request) if auth.session(options[:scope])[TwoFactorAuthentication::NEED_AUTHENTICATION] = user.need_two_factor_authentication?(auth.request)
user.send_two_factor_authentication_code user.send_new_otp unless user.totp_enabled?
end end
end end
end end

64
lib/two_factor_authentication/models/two_factor_authenticatable.rb

@ -11,17 +11,29 @@ module Devise
def has_one_time_password(options = {}) def has_one_time_password(options = {})
include InstanceMethodsOnActivation include InstanceMethodsOnActivation
include EncryptionInstanceMethods if options[:encrypted] == true include EncryptionInstanceMethods if options[:encrypted] == true
before_create { populate_otp_column }
end end
::Devise::Models.config( ::Devise::Models.config(
self, :max_login_attempts, :allowed_otp_drift_seconds, :otp_length, self, :max_login_attempts, :allowed_otp_drift_seconds, :otp_length,
:remember_otp_session_for_seconds, :otp_secret_encryption_key) :remember_otp_session_for_seconds, :otp_secret_encryption_key,
:direct_otp_length, :direct_otp_valid_for)
end end
module InstanceMethodsOnActivation module InstanceMethodsOnActivation
def authenticate_otp(code, options = {}) def authenticate_otp(code, options = {})
return true if direct_otp && authenticate_direct_otp(code)
return true if totp_enabled? && authenticate_totp(code, options)
false
end
def authenticate_direct_otp(code)
return false if direct_otp.nil? || direct_otp != code || direct_otp_expired?
clear_direct_otp
true
end
def authenticate_totp(code, options = {})
raise "authenticate_totp called with no otp_secret_key set" if otp_secret_key.nil?
totp = ROTP::TOTP.new( totp = ROTP::TOTP.new(
otp_secret_key, digits: options[:otp_length] || self.class.otp_length otp_secret_key, digits: options[:otp_length] || self.class.otp_length
) )
@ -30,15 +42,9 @@ module Devise
totp.verify_with_drift(code, drift) totp.verify_with_drift(code, drift)
end end
def otp_code(time = Time.now, options = {})
ROTP::TOTP.new(
otp_secret_key,
digits: options[:otp_length] || self.class.otp_length
).at(time, true)
end
def provisioning_uri(account = nil, options = {}) def provisioning_uri(account = nil, options = {})
account ||= self.email if self.respond_to?(:email) raise "provisioning_uri called with no otp_secret_key set" if otp_secret_key.nil?
account ||= email if respond_to?(:email)
ROTP::TOTP.new(otp_secret_key, options).provisioning_uri(account) ROTP::TOTP.new(otp_secret_key, options).provisioning_uri(account)
end end
@ -46,7 +52,12 @@ module Devise
true true
end end
def send_two_factor_authentication_code def send_new_otp(options = {})
create_direct_otp options
send_two_factor_authentication_code(direct_otp)
end
def send_two_factor_authentication_code(code)
raise NotImplementedError.new("No default implementation - please define in your class.") raise NotImplementedError.new("No default implementation - please define in your class.")
end end
@ -58,9 +69,36 @@ module Devise
self.class.max_login_attempts self.class.max_login_attempts
end end
def populate_otp_column def totp_enabled?
respond_to?(:otp_secret_key) && !otp_secret_key.nil?
end
def generate_totp_secret
self.otp_secret_key = ROTP::Base32.random_base32 self.otp_secret_key = ROTP::Base32.random_base32
end end
def create_direct_otp(options = {})
# Create a new random OTP and store it in the database
digits = options[:length] || self.class.direct_otp_length || 6
update_attributes(
direct_otp: random_base10(digits),
direct_otp_sent_at: Time.now.utc
)
end
private
def random_base10(digits)
SecureRandom.random_number(10**digits).to_s.rjust(digits, '0')
end
def direct_otp_expired?
Time.now.utc > direct_otp_sent_at + self.class.direct_otp_valid_for
end
def clear_direct_otp
update_attributes(direct_otp: nil, direct_otp_sent_at: nil)
end
end end
module EncryptionInstanceMethods module EncryptionInstanceMethods

8
lib/two_factor_authentication/schema.rb

@ -15,5 +15,13 @@ module TwoFactorAuthentication
def encrypted_otp_secret_key_salt def encrypted_otp_secret_key_salt
apply_devise_schema :encrypted_otp_secret_key_salt, String apply_devise_schema :encrypted_otp_secret_key_salt, String
end end
def direct_otp
apply_devise_schema :direct_otp, String
end
def direct_otp_sent_at
apply_devise_schema :direct_otp_sent_at, DateTime
end
end end
end end

4
spec/controllers/two_factor_authentication_controller_spec.rb

@ -8,8 +8,8 @@ describe Devise::TwoFactorAuthenticationController, type: :controller do
context 'after user enters valid OTP code' do context 'after user enters valid OTP code' do
it 'returns true' do it 'returns true' do
post :update, code: controller.current_user.otp_code controller.current_user.send_new_otp
post :update, code: controller.current_user.direct_otp
expect(subject.is_fully_authenticated?).to eq true expect(subject.is_fully_authenticated?).to eq true
end end
end end

38
spec/features/two_factor_authenticatable_spec.rb

@ -5,6 +5,7 @@ feature "User of two factor authentication" do
context 'sending two factor authentication code via SMS' do context 'sending two factor authentication code via SMS' do
shared_examples 'sends and authenticates code' do |user, type| shared_examples 'sends and authenticates code' do |user, type|
before do before do
user.reload
if type == 'encrypted' if type == 'encrypted'
allow(User).to receive(:has_one_time_password).with(encrypted: true) allow(User).to receive(:has_one_time_password).with(encrypted: true)
end end
@ -18,12 +19,12 @@ feature "User of two factor authentication" do
visit new_user_session_path visit new_user_session_path
complete_sign_in_form_for(user) complete_sign_in_form_for(user)
expect(page).to have_content 'Enter your personal code' expect(page).to have_content 'Enter the code that was sent to you'
expect(SMSProvider.messages.size).to eq(1) expect(SMSProvider.messages.size).to eq(1)
message = SMSProvider.last_message message = SMSProvider.last_message
expect(message.to).to eq(user.phone_number) expect(message.to).to eq(user.phone_number)
expect(message.body).to eq(user.otp_code) expect(message.body).to eq(user.reload.direct_otp)
end end
it 'authenticates a valid OTP code' do it 'authenticates a valid OTP code' do
@ -32,7 +33,7 @@ feature "User of two factor authentication" do
expect(page).to have_content('You are signed in as Marissa') expect(page).to have_content('You are signed in as Marissa')
fill_in 'code', with: user.otp_code fill_in 'code', with: SMSProvider.last_message.body
click_button 'Submit' click_button 'Submit'
within('.flash.notice') do within('.flash.notice') do
@ -66,7 +67,7 @@ feature "User of two factor authentication" do
expect(page).to_not have_content("Your Personal Dashboard") expect(page).to_not have_content("Your Personal Dashboard")
fill_in "code", with: user.otp_code fill_in "code", with: SMSProvider.last_message.body
click_button "Submit" click_button "Submit"
expect(page).to have_content("Your Personal Dashboard") expect(page).to have_content("Your Personal Dashboard")
@ -113,9 +114,7 @@ feature "User of two factor authentication" do
end end
scenario "doesn't require TFA code again within 30 days" do scenario "doesn't require TFA code again within 30 days" do
visit user_two_factor_authentication_path sms_sign_in
fill_in "code", with: user.otp_code
click_button "Submit"
logout logout
@ -126,9 +125,7 @@ feature "User of two factor authentication" do
end end
scenario "requires TFA code again after 30 days" do scenario "requires TFA code again after 30 days" do
visit user_two_factor_authentication_path sms_sign_in
fill_in "code", with: user.otp_code
click_button "Submit"
logout logout
@ -136,13 +133,11 @@ feature "User of two factor authentication" do
login_as user login_as user
visit dashboard_path visit dashboard_path
expect(page).to have_content("You are signed in as Marissa") expect(page).to have_content("You are signed in as Marissa")
expect(page).to have_content("Enter your personal code") expect(page).to have_content("Enter the code that was sent to you")
end end
scenario 'TFA should be different for different users' do scenario 'TFA should be different for different users' do
visit user_two_factor_authentication_path sms_sign_in
fill_in 'code', with: user.otp_code
click_button 'Submit'
tfa_cookie1 = get_tfa_cookie() tfa_cookie1 = get_tfa_cookie()
@ -151,19 +146,22 @@ feature "User of two factor authentication" do
user2 = create_user() user2 = create_user()
login_as(user2) login_as(user2)
visit user_two_factor_authentication_path sms_sign_in
fill_in 'code', with: user2.otp_code
click_button 'Submit'
tfa_cookie2 = get_tfa_cookie() tfa_cookie2 = get_tfa_cookie()
expect(tfa_cookie1).not_to eq tfa_cookie2 expect(tfa_cookie1).not_to eq tfa_cookie2
end end
scenario 'TFA should be unique for specific user' do def sms_sign_in
SMSProvider.messages.clear()
visit user_two_factor_authentication_path visit user_two_factor_authentication_path
fill_in 'code', with: user.otp_code fill_in 'code', with: SMSProvider.last_message.body
click_button 'Submit' click_button 'Submit'
end
scenario 'TFA should be unique for specific user' do
sms_sign_in
tfa_cookie1 = get_tfa_cookie() tfa_cookie1 = get_tfa_cookie()
@ -174,7 +172,7 @@ feature "User of two factor authentication" do
set_tfa_cookie(tfa_cookie1) set_tfa_cookie(tfa_cookie1)
login_as(user2) login_as(user2)
visit dashboard_path visit dashboard_path
expect(page).to have_content('Enter your personal code') expect(page).to have_content("Enter the code that was sent to you")
end end
end end

177
spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb

@ -2,121 +2,150 @@ require 'spec_helper'
include AuthenticatedModelHelper include AuthenticatedModelHelper
describe Devise::Models::TwoFactorAuthenticatable do describe Devise::Models::TwoFactorAuthenticatable do
describe '#otp_code' do describe '#create_direct_otp' do
shared_examples 'otp_code' do |instance| let(:instance) { build_guest_user }
subject { instance.otp_code(time) }
let(:time) { 1_392_852_456 } it 'set direct_otp field' do
expect(instance.direct_otp).to be_nil
instance.create_direct_otp
expect(instance.direct_otp).not_to be_nil
end
it 'returns an error if no secret is set' do it 'set direct_otp_send_at field to current time' do
expect { subject }.to raise_error Exception Timecop.freeze() do
instance.create_direct_otp
expect(instance.direct_otp_sent_at).to eq(Time.now)
end end
end
context 'secret is set' do it 'honors .direct_otp_length' do
before :each do expect(instance.class).to receive(:direct_otp_length).and_return(10)
instance.otp_secret_key = '2z6hxkdwi3uvrnpn' instance.create_direct_otp
end expect(instance.direct_otp.length).to equal(10)
it 'does not return an error' do expect(instance.class).to receive(:direct_otp_length).and_return(6)
subject instance.create_direct_otp
end expect(instance.direct_otp.length).to equal(6)
end
it 'matches Devise configured length' do it "honors 'direct_otp_length' in options paramater" do
expect(subject.length).to eq(Devise.otp_length) instance.create_direct_otp(length: 8)
end expect(instance.direct_otp.length).to equal(8)
instance.create_direct_otp(length: 10)
expect(instance.direct_otp.length).to equal(10)
end
end
context 'with a known time' do describe '#authenticate_direct_otp' do
let(:time) { 1_392_852_756 } let(:instance) { build_guest_user }
it 'fails if no direct_otp has been set' do
expect(instance.authenticate_direct_otp('12345')).to eq(false)
end
it 'returns a known result' do context 'after generating an OTP' do
expect(subject). before :each do
to eq('0000000524562202'.split(//).last(Devise.otp_length).join) instance.create_direct_otp
end end
end
context 'with a known time yielding a result with less than 6 digits' do it 'accepts correct OTP' do
let(:time) { 1_393_065_856 } Timecop.freeze(Time.now + instance.class.direct_otp_valid_for - 1.second)
expect(instance.authenticate_direct_otp(instance.direct_otp)).to eq(true)
end
it 'returns a known result padded with zeroes' do it 'rejects invalid OTP' do
expect(subject). Timecop.freeze(Time.now + instance.class.direct_otp_valid_for - 1.second)
to eq('0000001608007672'.split(//).last(Devise.otp_length).join) expect(instance.authenticate_direct_otp('12340')).to eq(false)
end
end
end end
end
it_behaves_like 'otp_code', GuestUser.new it 'rejects expired OTP' do
it_behaves_like 'otp_code', EncryptedUser.new Timecop.freeze(Time.now + instance.class.direct_otp_valid_for + 1.second)
expect(instance.authenticate_direct_otp(instance.direct_otp)).to eq(false)
end
it 'prevents code re-use' do
expect(instance.authenticate_direct_otp(instance.direct_otp)).to eq(true)
expect(instance.authenticate_direct_otp(instance.direct_otp)).to eq(false)
end
end
end end
describe '#authenticate_otp' do describe '#authenticate_totp' do
shared_examples 'authenticate_otp' do |instance|
shared_examples 'authenticate_totp' do |instance|
before :each do before :each do
instance.otp_secret_key = '2z6hxkdwi3uvrnpn' instance.otp_secret_key = '2z6hxkdwi3uvrnpn'
@totp_helper = TotpHelper.new(instance.otp_secret_key, instance.class.otp_length)
end end
def do_invoke(code, user) def do_invoke(code, user)
user.authenticate_otp(code) user.authenticate_totp(code)
end end
it 'authenticates a recently created code' do it 'authenticates a recently created code' do
code = instance.otp_code code = @totp_helper.totp_code
expect(do_invoke(code, instance)).to eq(true) expect(do_invoke(code, instance)).to eq(true)
end end
it 'does not authenticate an old code' do it 'does not authenticate an old code' do
code = instance.otp_code(1.minutes.ago.to_i) code = @totp_helper.totp_code(1.minutes.ago.to_i)
expect(do_invoke(code, instance)).to eq(false) expect(do_invoke(code, instance)).to eq(false)
end end
end end
it_behaves_like 'authenticate_otp', GuestUser.new it_behaves_like 'authenticate_totp', GuestUser.new
it_behaves_like 'authenticate_otp', EncryptedUser.new it_behaves_like 'authenticate_totp', EncryptedUser.new
end end
describe '#send_two_factor_authentication_code' do describe '#send_two_factor_authentication_code' do
let(:instance) { build_guest_user } let(:instance) { build_guest_user }
it 'raises an error by default' do it 'raises an error by default' do
expect { instance.send_two_factor_authentication_code }. expect { instance.send_two_factor_authentication_code(123) }.
to raise_error(NotImplementedError) to raise_error(NotImplementedError)
end end
it 'is overrideable' do it 'is overrideable' do
def instance.send_two_factor_authentication_code def instance.send_two_factor_authentication_code(code)
'Code sent' 'Code sent'
end end
expect(instance.send_two_factor_authentication_code).to eq('Code sent') expect(instance.send_two_factor_authentication_code(123)).to eq('Code sent')
end end
end end
describe '#provisioning_uri' do describe '#provisioning_uri' do
shared_examples 'provisioning_uri' do |instance| shared_examples 'provisioning_uri' do |instance|
before do it 'fails until generate_totp_secret is called' do
instance.email = 'houdini@example.com' expect { instance.provisioning_uri }.to raise_error(Exception)
instance.run_callbacks :create
end end
it "returns uri with user's email" do describe 'with secret set' do
expect(instance.provisioning_uri). before do
to match(%r{otpauth://totp/houdini@example.com\?secret=\w{16}}) instance.email = 'houdini@example.com'
end instance.generate_totp_secret
end
it 'returns uri with issuer option' do it "returns uri with user's email" do
expect(instance.provisioning_uri('houdini')). expect(instance.provisioning_uri).
to match(%r{otpauth://totp/houdini\?secret=\w{16}$}) to match(%r{otpauth://totp/houdini@example.com\?secret=\w{16}})
end end
it 'returns uri with issuer option' do it 'returns uri with issuer option' do
require 'cgi' expect(instance.provisioning_uri('houdini')).
to match(%r{otpauth://totp/houdini\?secret=\w{16}$})
end
uri = URI.parse(instance.provisioning_uri('houdini', issuer: 'Magic')) it 'returns uri with issuer option' do
params = CGI.parse(uri.query) require 'cgi'
uri = URI.parse(instance.provisioning_uri('houdini', issuer: 'Magic'))
params = CGI.parse(uri.query)
expect(uri.scheme).to eq('otpauth') expect(uri.scheme).to eq('otpauth')
expect(uri.host).to eq('totp') expect(uri.host).to eq('totp')
expect(uri.path).to eq('/Magic:houdini') expect(uri.path).to eq('/Magic:houdini')
expect(params['issuer'].shift).to eq('Magic') expect(params['issuer'].shift).to eq('Magic')
expect(params['secret'].shift).to match(/\w{16}/) expect(params['secret'].shift).to match(/\w{16}/)
end
end end
end end
@ -124,32 +153,22 @@ describe Devise::Models::TwoFactorAuthenticatable do
it_behaves_like 'provisioning_uri', EncryptedUser.new it_behaves_like 'provisioning_uri', EncryptedUser.new
end end
describe '#populate_otp_column' do describe '#generate_totp_secret' do
shared_examples 'populate_otp_column' do |klass| shared_examples 'generate_totp_secret' do |klass|
let(:instance) { klass.new } let(:instance) { klass.new }
it 'populates otp_column on create' do it 'populates otp_secret_key column' do
expect(instance.otp_secret_key).to be_nil
# populate_otp_column called via before_create
instance.run_callbacks :create
expect(instance.otp_secret_key).to match(/\w{16}/)
end
it 'repopulates otp_column' do
instance.run_callbacks :create
original_key = instance.otp_secret_key original_key = instance.otp_secret_key
instance.populate_otp_column instance.generate_totp_secret
expect(instance.otp_secret_key).to match(/\w{16}/) expect(instance.otp_secret_key).to match(/\w{16}/)
expect(instance.otp_secret_key).to_not eq(original_key) expect(instance.otp_secret_key).to_not eq(original_key)
end end
end end
it_behaves_like 'populate_otp_column', GuestUser it_behaves_like 'generate_totp_secret', GuestUser
it_behaves_like 'populate_otp_column', EncryptedUser it_behaves_like 'generate_totp_secret', EncryptedUser
end end
describe '#max_login_attempts' do describe '#max_login_attempts' do

9
spec/rails_app/app/models/guest_user.rb

@ -4,7 +4,14 @@ class GuestUser
include Devise::Models::TwoFactorAuthenticatable include Devise::Models::TwoFactorAuthenticatable
define_model_callbacks :create define_model_callbacks :create
attr_accessor :otp_secret_key, :email, :second_factor_attempts_count attr_accessor :direct_otp, :direct_otp_sent_at, :otp_secret_key, :email,
:second_factor_attempts_count
def update_attributes(attrs)
attrs.each do |key, value|
send(key.to_s + '=', value)
end
end
has_one_time_password has_one_time_password
end end

4
spec/rails_app/app/models/user.rb

@ -4,8 +4,8 @@ class User < ActiveRecord::Base
has_one_time_password has_one_time_password
def send_two_factor_authentication_code def send_two_factor_authentication_code(code)
SMSProvider.send_message(to: phone_number, body: otp_code) SMSProvider.send_message(to: phone_number, body: code)
end end
def phone_number def phone_number

2
spec/support/authenticated_model_helper.rb

@ -47,6 +47,8 @@ module AuthenticatedModelHelper
t.integer 'second_factor_attempts_count', default: 0 t.integer 'second_factor_attempts_count', default: 0
t.string 'nickname', limit: 64 t.string 'nickname', limit: 64
t.string 'otp_secret_key' t.string 'otp_secret_key'
t.string 'direct_otp'
t.datetime 'direct_otp_sent_at'
end end
end end
end end

11
spec/support/totp_helper.rb

@ -0,0 +1,11 @@
# Helper class to simulate a user generating TOTP codes from a secret key
class TotpHelper
def initialize(secret_key, otp_length)
@secret_key = secret_key
@otp_length = otp_length
end
def totp_code(time = Time.now)
ROTP::TOTP.new(@secret_key, digits: @otp_length).at(time, true)
end
end
Loading…
Cancel
Save