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
* configurable OTP code digit length
* configurable max login attempts
* customizable logic to determine if a user needs two factor authentication
* customizable logic for sending the OTP code to the user
* configurable period where users won't be asked for 2FA again
* option to encrypt the OTP secret key in the database, with iv and salt
* Support for 2 types of OTP codes
1. Codes delivered directly to the user
2. TOTP (Google Authenticator) codes based on a shared secret (HMAC)
* Configurable OTP code digit length
* Configurable max login attempts
* 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
@ -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_iv`
- `:encrypted_otp_secret_key_salt`
- `:direct_otp`
- `:direct_otp_sent_at`
#### Manual initial setup
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:
```
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
@ -82,21 +86,24 @@ Set config values in `config/initializers/devise.rb`:
```ruby
config.max_login_attempts = 3 # Maximum second factor attempts count.
config.allowed_otp_drift_seconds = 30 # Allowed time drift between client and server.
config.otp_length = 6 # OTP code length
config.remember_otp_session_for_seconds = 30.days # Time before browser has to enter OTP code again. Default is 0.
config.allowed_otp_drift_seconds = 30 # Allowed TOTP time drift between client and server.
config.otp_length = 6 # TOTP code length
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']
```
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
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
automatically called when a user logs in:
Override the method in your model in order to send direct OTP codes. This is
automatically called when a user logs in unless they have TOTP enabled (see
below):
```ruby
def send_two_factor_authentication_code
# use Model#otp_code and send via SMS, etc.
def send_two_factor_authentication_code(code)
# Send code via SMS, etc.
end
```
@ -115,7 +122,14 @@ In the example above, two factor authentication will not be required for local
users.
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
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
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
well as through whatever method you define in
`send_two_factor_authentication_code`.
may retrieve a one-time password directly from the Google Authenticator app.
#### 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
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
desc 'rake task to update users with otp secret key'
task :update_users_with_otp_secret_key => :environment do
User.find_each do |user|
user.otp_secret_key = ROTP::Base32.random_base32
user.generate_totp_secret
user.save!
puts "Rake[:update_users_with_otp_secret_key] => OTP secret key set to '#{key}' for User '#{user.email}'"
end
@ -164,7 +176,7 @@ end
```
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
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
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')
end
@ -52,7 +52,6 @@ class Devise::TwoFactorAuthenticationController < DeviseController
if resource.max_login_attempts?
sign_out(resource)
render :max_login_attempts_reached
else
render :show
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>
@ -7,5 +11,9 @@
<%= submit_tag "Submit" %>
<% end %>
<%= link_to 'Resend Code', resend_code_user_two_factor_authentication_path, action: :get %>
<%= link_to "Sign out", destroy_user_session_path, :method => :delete %>
<% if current_user.direct_otp %>
<%= 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_iv, :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
end

6
lib/two_factor_authentication.rb

@ -16,6 +16,12 @@ module Devise
mattr_accessor :otp_length
@@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
@@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 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

64
lib/two_factor_authentication/models/two_factor_authenticatable.rb

@ -11,17 +11,29 @@ module Devise
def has_one_time_password(options = {})
include InstanceMethodsOnActivation
include EncryptionInstanceMethods if options[:encrypted] == true
before_create { populate_otp_column }
end
::Devise::Models.config(
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
module InstanceMethodsOnActivation
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(
otp_secret_key, digits: options[:otp_length] || self.class.otp_length
)
@ -30,15 +42,9 @@ module Devise
totp.verify_with_drift(code, drift)
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 = {})
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)
end
@ -46,7 +52,12 @@ module Devise
true
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.")
end
@ -58,9 +69,36 @@ module Devise
self.class.max_login_attempts
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
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
module EncryptionInstanceMethods

8
lib/two_factor_authentication/schema.rb

@ -15,5 +15,13 @@ module TwoFactorAuthentication
def encrypted_otp_secret_key_salt
apply_devise_schema :encrypted_otp_secret_key_salt, String
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

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
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
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
shared_examples 'sends and authenticates code' do |user, type|
before do
user.reload
if type == 'encrypted'
allow(User).to receive(:has_one_time_password).with(encrypted: true)
end
@ -18,12 +19,12 @@ feature "User of two factor authentication" do
visit new_user_session_path
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)
message = SMSProvider.last_message
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
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')
fill_in 'code', with: user.otp_code
fill_in 'code', with: SMSProvider.last_message.body
click_button 'Submit'
within('.flash.notice') do
@ -66,7 +67,7 @@ feature "User of two factor authentication" do
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"
expect(page).to have_content("Your Personal Dashboard")
@ -113,9 +114,7 @@ feature "User of two factor authentication" do
end
scenario "doesn't require TFA code again within 30 days" do
visit user_two_factor_authentication_path
fill_in "code", with: user.otp_code
click_button "Submit"
sms_sign_in
logout
@ -126,9 +125,7 @@ feature "User of two factor authentication" do
end
scenario "requires TFA code again after 30 days" do
visit user_two_factor_authentication_path
fill_in "code", with: user.otp_code
click_button "Submit"
sms_sign_in
logout
@ -136,13 +133,11 @@ feature "User of two factor authentication" do
login_as user
visit dashboard_path
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
scenario 'TFA should be different for different users' do
visit user_two_factor_authentication_path
fill_in 'code', with: user.otp_code
click_button 'Submit'
sms_sign_in
tfa_cookie1 = get_tfa_cookie()
@ -151,19 +146,22 @@ feature "User of two factor authentication" do
user2 = create_user()
login_as(user2)
visit user_two_factor_authentication_path
fill_in 'code', with: user2.otp_code
click_button 'Submit'
sms_sign_in
tfa_cookie2 = get_tfa_cookie()
expect(tfa_cookie1).not_to eq tfa_cookie2
end
scenario 'TFA should be unique for specific user' do
def sms_sign_in
SMSProvider.messages.clear()
visit user_two_factor_authentication_path
fill_in 'code', with: user.otp_code
fill_in 'code', with: SMSProvider.last_message.body
click_button 'Submit'
end
scenario 'TFA should be unique for specific user' do
sms_sign_in
tfa_cookie1 = get_tfa_cookie()
@ -174,7 +172,7 @@ feature "User of two factor authentication" do
set_tfa_cookie(tfa_cookie1)
login_as(user2)
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

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

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

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

@ -4,7 +4,14 @@ class GuestUser
include Devise::Models::TwoFactorAuthenticatable
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
end

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

@ -4,8 +4,8 @@ class User < ActiveRecord::Base
has_one_time_password
def send_two_factor_authentication_code
SMSProvider.send_message(to: phone_number, body: otp_code)
def send_two_factor_authentication_code(code)
SMSProvider.send_message(to: phone_number, body: code)
end
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.string 'nickname', limit: 64
t.string 'otp_secret_key'
t.string 'direct_otp'
t.datetime 'direct_otp_sent_at'
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