Browse Source

Add support for OTP secret key encryption

**Why**:
To provide an additional layer of security.
The TOTP spec (RFC 6238) recommends encrypting the keys.
http://tools.ietf.org/html/rfc6238

**How**:
Borrow the encryption code from the `attr_encrypted` gem and use it to
encrypt and decrypt the `otp_secret_key` attribute.

Allow users to add encryption by passing in `encrypted: true` to
`has_one_time_password`. This provides backwards-compatibility for
existing users of the gem.

See the README updates for more detailed instructions for both new
and existing users.
master
Moncef Belyamani 9 years ago
parent
commit
d2b6d39d24
  1. 9
      Gemfile
  2. 201
      README.md
  3. 17
      lib/generators/active_record/templates/migration.rb
  4. 3
      lib/two_factor_authentication.rb
  5. 112
      lib/two_factor_authentication/models/two_factor_authenticatable.rb
  6. 16
      lib/two_factor_authentication/schema.rb
  7. 18
      spec/controllers/two_factor_auth_spec.rb
  8. 33
      spec/controllers/two_factor_authentication_controller_spec.rb
  9. 79
      spec/features/two_factor_authenticatable_spec.rb
  10. 36
      spec/generators/active_record/two_factor_authentication_generator_spec.rb
  11. 330
      spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb
  12. 14
      spec/rails_app/app/models/encrypted_user.rb
  13. 3
      spec/rails_app/app/models/user.rb
  14. 3
      spec/rails_app/config/environments/test.rb
  15. 2
      spec/rails_app/config/initializers/devise.rb
  16. 9
      spec/rails_app/db/migrate/20151224171231_add_encrypted_columns_to_user.rb
  17. 19
      spec/rails_app/db/migrate/20151224180310_populate_otp_column.rb
  18. 5
      spec/rails_app/db/migrate/20151228230340_remove_otp_secret_key_from_user.rb
  19. 30
      spec/rails_app/db/schema.rb
  20. 1
      spec/spec_helper.rb
  21. 28
      spec/support/authenticated_model_helper.rb
  22. 16
      spec/support/controller_helper.rb
  23. 8
      spec/support/features_spec_helper.rb
  24. 1
      two_factor_authentication.gemspec

9
Gemfile

@ -1,4 +1,4 @@
source "http://rubygems.org" source 'https://rubygems.org'
# Specify your gem's dependencies in devise_ip_filter.gemspec # Specify your gem's dependencies in devise_ip_filter.gemspec
gemspec gemspec
@ -20,6 +20,11 @@ if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('2.2.0')
gem "test-unit", "~> 3.0" gem "test-unit", "~> 3.0"
end end
group :test, :development do
gem 'sqlite3'
end
group :test do group :test do
gem "sqlite3" gem 'rack_session_access'
gem 'ammeter'
end end

201
README.md

@ -5,11 +5,12 @@
## Features ## Features
* control sms code pattern * configurable OTP code digit length
* configure max login attempts * configurable max login attempts
* per user level control if he really need two factor authentication * customizable logic to determine if a user needs two factor authentication
* your own sms logic * customizable logic for sending the OTP code to the user
* configurable period where users won't be asked for 2FA again * 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
## Configuration ## Configuration
@ -23,62 +24,71 @@ Once that's done, run:
bundle install bundle install
### Automatic installation ### Installation
In order to add two factor authentication to a model, run the command: #### Automatic initial setup
To set up the model and database migration file automatically, run the
following command:
bundle exec rails g two_factor_authentication MODEL bundle exec rails g two_factor_authentication MODEL
Where MODEL is your model name (e.g. User or Admin). This generator will add `:two_factor_authenticatable` to your model Where MODEL is your model name (e.g. User or Admin). This generator will add
and create a migration in `db/migrate/`, which will add `:otp_secret_key` and `:second_factor_attempts_count` to your table. `:two_factor_authenticatable` to your model's Devise options and create a
Finally, run the migration with: migration in `db/migrate/`, which will add the following columns to your table:
bundle exec rake db:migrate - `:second_factor_attempts_count`
- `:encrypted_otp_secret_key`
Add the following line to your model to fully enable two-factor auth: - `:encrypted_otp_secret_key_iv`
- `:encrypted_otp_secret_key_salt`
has_one_time_password #### Manual initial setup
If you prefer to set up the model and migration manually, add the
Set config values, if desired: `:two_factor_authentication` option to your existing devise options, such as:
```ruby ```ruby
config.max_login_attempts = 3 # Maximum second factor attempts count devise :database_authenticatable, :registerable, :recoverable, :rememberable,
config.allowed_otp_drift_seconds = 30 # Allowed time drift :trackable, :validatable, :two_factor_authenticatable
config.otp_length = 6 # OTP code length
config.remember_otp_session_for_seconds = 30.days # Time before browser has to enter OTP code again
``` ```
Override the method to send one-time passwords in your model, this is automatically called when a user logs in: Then create your migration file using the Rails generator, such as:
```ruby ```
def send_two_factor_authentication_code 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
# use Model#otp_code and send via SMS, etc.
end
``` ```
### Manual installation Open your migration file (it will be in the `db/migrate` directory and will be
named something like `20151230163930_add_two_factor_fields_to_users.rb`), and
To manually enable two factor authentication for the User model, you should add two_factor_authentication to your devise line, like: add `unique: true` to the `add_index` line so that it looks like this:
```ruby ```ruby
devise :database_authenticatable, :registerable, add_index :users, :encrypted_otp_secret_key, unique: true
:recoverable, :rememberable, :trackable, :validatable, :two_factor_authenticatable
``` ```
Save the file.
#### Complete the setup
Run the migration with:
bundle exec rake db:migrate
Add the following line to your model to fully enable two-factor auth: Add the following line to your model to fully enable two-factor auth:
has_one_time_password has_one_time_password(encrypted: true)
Set config values to devise.rb, if desired: 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 config.allowed_otp_drift_seconds = 30 # Allowed time drift between client and server.
config.otp_length = 6 # OTP code length config.otp_length = 6 # OTP code length
config.remember_otp_session_for_seconds = 30.days # Time before browser has to enter OTP code again config.remember_otp_session_for_seconds = 30.days # Time before browser has to enter OTP code 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 to send one-time passwords in your model. This is
automatically called when a user logs in:
```ruby ```ruby
def send_two_factor_authentication_code def send_two_factor_authentication_code
@ -86,10 +96,10 @@ def send_two_factor_authentication_code
end end
``` ```
### Customisation and Usage ### Customisation and Usage
By default second factor authentication enabled for each user, you can change it with this method in your User model: By default, second factor authentication is required for each user. You can
change that by overriding the following method in your model:
```ruby ```ruby
def need_two_factor_authentication?(request) def need_two_factor_authentication?(request)
@ -97,19 +107,29 @@ def need_two_factor_authentication?(request)
end end
``` ```
this will disable two factor authentication for local users 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: 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:
user.provisioning_uri #This assumes a user model with an email attributes ```ruby
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` 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`.
#### Overriding the view #### Overriding the view
The default view that shows the form can be overridden by first adding a folder named: "two_factor_authentication" inside "app/views/devise", in here you want to create a "show.html.erb" view. The default view that shows the form can be overridden by adding a
file named `show.html.erb` (or `show.html.haml` if you prefer HAML)
inside `app/views/devise/two_factor_authentication/` and customizing it.
Below is an example using ERB:
The full path should be "app/views/devise/two_factor_authentication/show.html.erb"
```html ```html
<h2>Hi, you received a code by email, please enter it below, thanks!</h2> <h2>Hi, you received a code by email, please enter it below, thanks!</h2>
@ -125,21 +145,94 @@ The full path should be "app/views/devise/two_factor_authentication/show.html.er
#### Updating existing users with OTP secret key #### Updating existing users with OTP secret key
If you have existing users that needs to be provided with a OTP secret key, so they can take benefit of the two factor authentication, create a rake. It could look like this one below: 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:
```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
users = User.all User.find_each do |user|
user.otp_secret_key = ROTP::Base32.random_base32
users.each do |user| user.save!
key = ROTP::Base32.random_base32 puts "Rake[:update_users_with_otp_secret_key] => OTP secret key set to '#{key}' for User '#{user.email}'"
user.update_attributes(:otp_secret_key => key) end
user.save
puts "Rake[:update_users_with_otp_secret_key] => User '#{user.email}' OTP secret key set to '#{key}'"
end
end end
``` ```
Then run the task with `bundle exec rake update_users_with_otp_secret_key`
#### Adding the OTP 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
steps:
1. Generate a migration to add the necessary columns to your model's table:
```
rails g migration AddEncryptionFieldsToUsers encrypted_otp_secret_key:string:index encrypted_otp_secret_key_iv:string encrypted_otp_secret_key_salt:string
```
Open your migration file (it will be in the `db/migrate` directory and will be
named something like `20151230163930_add_encryption_fields_to_users.rb`), and
add `unique: true` to the `add_index` line so that it looks like this:
```ruby
add_index :users, :encrypted_otp_secret_key, unique: true
```
Save the file.
2. Run the migration: `bundle exec rake db:migrate`
2. Update the gem: `bundle update two_factor_authentication`
3. Add `encrypted: true` to `has_one_time_password` in your model.
For example: `has_one_time_password(encrypted: true)`
4. Generate a migration to populate the new encryption fields:
```
rails g migration PopulateEncryptedOtpFields
```
Open the generated file, and replace its contents with the following:
```ruby
class PopulateEncryptedOtpFields < ActiveRecord::Migration
def up
User.reset_column_information
User.find_each do |user|
user.otp_secret_key = user.read_attribute('otp_secret_key')
user.save!
end
end
def down
User.reset_column_information
User.find_each do |user|
user.otp_secret_key = ROTP::Base32.random_base32
user.save!
end
end
end
```
5. Generate a migration to remove the `:otp_secret_key` column:
```
rails g migration RemoveOtpSecretKeyFromUsers otp_secret_key:string
```
6. Run the migrations: `bundle exec rake db:migrate`
If, for some reason, you want to switch back to the old non-encrypted version,
use these steps:
1. Remove `(encrypted: true)` from `has_one_time_password`
2. Roll back the last 3 migrations (assuming you haven't added any new ones
after them):
```
bundle exec rake db:rollback STEP=3
```
#### Executing some code after the user signs in and before they sign out #### Executing some code after the user signs in and before they sign out
@ -174,6 +267,6 @@ and you need different logic for each type of user, create a second class for
your admin user, such as `AdminOtpSender`, with its own logic for your admin user, such as `AdminOtpSender`, with its own logic for
`#reset_otp_state`. `#reset_otp_state`.
### Example ### Example App
[TwoFactorAuthenticationExample](https://github.com/Houdini/TwoFactorAuthenticationExample) [TwoFactorAuthenticationExample](https://github.com/Houdini/TwoFactorAuthenticationExample)

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

@ -1,15 +1,10 @@
class TwoFactorAuthenticationAddTo<%= table_name.camelize %> < ActiveRecord::Migration class TwoFactorAuthenticationAddTo<%= table_name.camelize %> < ActiveRecord::Migration
def up def change
change_table :<%= table_name %> do |t| add_column :<%= table_name %>, :second_factor_attempts_count, :integer, default: 0
t.string :otp_secret_key add_column :<%= table_name %>, :encrypted_otp_secret_key, :string
t.integer :second_factor_attempts_count, :default => 0 add_column :<%= table_name %>, :encrypted_otp_secret_key_iv, :string
end add_column :<%= table_name %>, :encrypted_otp_secret_key_salt, :string
add_index :<%= table_name %>, :otp_secret_key, :unique => true add_index :<%= table_name %>, :encrypted_otp_secret_key, unique: true
end
def down
remove_column :<%= table_name %>, :otp_secret_key
remove_column :<%= table_name %>, :second_factor_attempts_count
end end
end end

3
lib/two_factor_authentication.rb

@ -18,6 +18,9 @@ module Devise
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
mattr_accessor :otp_secret_encryption_key
@@otp_secret_encryption_key = ''
end end
module TwoFactorAuthentication module TwoFactorAuthentication

112
lib/two_factor_authentication/models/two_factor_authenticatable.rb

@ -1,5 +1,6 @@
require 'two_factor_authentication/hooks/two_factor_authenticatable' require 'two_factor_authentication/hooks/two_factor_authenticatable'
require 'rotp' require 'rotp'
require 'encryptor'
module Devise module Devise
module Models module Models
@ -8,46 +9,37 @@ module Devise
module ClassMethods module ClassMethods
def has_one_time_password(options = {}) def has_one_time_password(options = {})
cattr_accessor :otp_column_name
self.otp_column_name = "otp_secret_key"
include InstanceMethodsOnActivation include InstanceMethodsOnActivation
include EncryptionInstanceMethods if options[:encrypted] == true
before_create { populate_otp_column } before_create { populate_otp_column }
if respond_to?(:attributes_protected_by_default)
def self.attributes_protected_by_default #:nodoc:
super + [self.otp_column_name]
end
end
end end
::Devise::Models.config(self, :max_login_attempts, :allowed_otp_drift_seconds, :otp_length, :remember_otp_session_for_seconds)
::Devise::Models.config(
self, :max_login_attempts, :allowed_otp_drift_seconds, :otp_length,
:remember_otp_session_for_seconds, :otp_secret_encryption_key)
end end
module InstanceMethodsOnActivation module InstanceMethodsOnActivation
def authenticate_otp(code, options = {}) def authenticate_otp(code, options = {})
totp = ROTP::TOTP.new(self.otp_column, { digits: options[:otp_length] || self.class.otp_length }) totp = ROTP::TOTP.new(
otp_secret_key, digits: options[:otp_length] || self.class.otp_length
)
drift = options[:drift] || self.class.allowed_otp_drift_seconds drift = options[:drift] || self.class.allowed_otp_drift_seconds
totp.verify_with_drift(code, drift) totp.verify_with_drift(code, drift)
end end
def otp_code(time = Time.now, options = {}) def otp_code(time = Time.now, options = {})
ROTP::TOTP.new(self.otp_column, { digits: options[:otp_length] || self.class.otp_length }).at(time, true) ROTP::TOTP.new(
otp_secret_key,
digits: options[:otp_length] || self.class.otp_length
).at(time, true)
end end
def provisioning_uri(account = nil, options = {}) def provisioning_uri(account = nil, options = {})
account ||= self.email if self.respond_to?(:email) account ||= self.email if self.respond_to?(:email)
ROTP::TOTP.new(self.otp_column, options).provisioning_uri(account) ROTP::TOTP.new(otp_secret_key, options).provisioning_uri(account)
end
def otp_column
self.send(self.class.otp_column_name)
end
def otp_column=(attr)
self.send("#{self.class.otp_column_name}=", attr)
end end
def need_two_factor_authentication?(request) def need_two_factor_authentication?(request)
@ -67,9 +59,83 @@ module Devise
end end
def populate_otp_column def populate_otp_column
self.otp_column = ROTP::Base32.random_base32 self.otp_secret_key = ROTP::Base32.random_base32
end
end
module EncryptionInstanceMethods
def otp_secret_key
decrypt(encrypted_otp_secret_key)
end
def otp_secret_key=(value)
self.encrypted_otp_secret_key = encrypt(value)
end
private
def decrypt(encrypted_value)
return encrypted_value if encrypted_value.blank?
encrypted_value = encrypted_value.unpack('m').first
value = ::Encryptor.decrypt(encryption_options_for(encrypted_value))
if defined?(Encoding)
encoding = Encoding.default_internal || Encoding.default_external
value = value.force_encoding(encoding.name)
end
value
end
def encrypt(value)
return value if value.blank?
value = value.to_s
encrypted_value = ::Encryptor.encrypt(encryption_options_for(value))
encrypted_value = [encrypted_value].pack('m')
encrypted_value
end
def encryption_options_for(value)
{
value: value,
key: Devise.otp_secret_encryption_key,
iv: iv_for_attribute,
salt: salt_for_attribute
}
end
def iv_for_attribute(algorithm = 'aes-256-cbc')
iv = encrypted_otp_secret_key_iv
if iv.nil?
algo = OpenSSL::Cipher::Cipher.new(algorithm)
iv = [algo.random_iv].pack('m')
self.encrypted_otp_secret_key_iv = iv
end
iv.unpack('m').first if iv.present?
end
def salt_for_attribute
salt = encrypted_otp_secret_key_salt ||
self.encrypted_otp_secret_key_salt = generate_random_base64_encoded_salt
decode_salt_if_encoded(salt)
end end
def generate_random_base64_encoded_salt
prefix = '_'
prefix + [SecureRandom.random_bytes].pack('m')
end
def decode_salt_if_encoded(salt)
salt.slice(0).eql?('_') ? salt.slice(1..-1).unpack('m').first : salt
end
end end
end end
end end

16
lib/two_factor_authentication/schema.rb

@ -1,11 +1,19 @@
module TwoFactorAuthentication module TwoFactorAuthentication
module Schema module Schema
def otp_secret_key
apply_devise_schema :otp_secret_key, String
end
def second_factor_attempts_count def second_factor_attempts_count
apply_devise_schema :second_factor_attempts_count, Integer, :default => 0 apply_devise_schema :second_factor_attempts_count, Integer, :default => 0
end end
def encrypted_otp_secret_key
apply_devise_schema :encrypted_otp_secret_key, String
end
def encrypted_otp_secret_key_iv
apply_devise_schema :encrypted_otp_secret_key_iv, String
end
def encrypted_otp_secret_key_salt
apply_devise_schema :encrypted_otp_secret_key_salt, String
end
end end
end end

18
spec/controllers/two_factor_auth_spec.rb

@ -1,18 +0,0 @@
require 'spec_helper'
include Warden::Test::Helpers
describe HomeController, :type => :controller do
context "passed only 1st factor auth" do
let(:user) { create_user }
describe "is_fully_authenticated helper" do
it "should be true" do
login_as user, scope: :user
visit user_two_factor_authentication_path
expect(controller.is_fully_authenticated?).to be_truthy
end
end
end
end

33
spec/controllers/two_factor_authentication_controller_spec.rb

@ -0,0 +1,33 @@
require 'spec_helper'
describe Devise::TwoFactorAuthenticationController, type: :controller do
describe 'is_fully_authenticated? helper' do
before do
sign_in
end
context 'after user enters valid OTP code' do
it 'returns true' do
post :update, code: controller.current_user.otp_code
expect(subject.is_fully_authenticated?).to eq true
end
end
context 'when user has not entered any OTP yet' do
it 'returns false' do
get :show
expect(subject.is_fully_authenticated?).to eq false
end
end
context 'when user enters an invalid OTP' do
it 'returns false' do
post :update, code: '12345'
expect(subject.is_fully_authenticated?).to eq false
end
end
end
end

79
spec/features/two_factor_authenticatable_spec.rb

@ -1,47 +1,64 @@
require 'spec_helper' require 'spec_helper'
include AuthenticatedModelHelper
feature "User of two factor authentication" do feature "User of two factor authentication" do
let(:user) { create_user } context 'sending two factor authentication code via SMS' do
shared_examples 'sends and authenticates code' do |user, type|
before do
if type == 'encrypted'
allow(User).to receive(:has_one_time_password).with(encrypted: true)
end
end
scenario "must be logged in" do it 'does not send an SMS before the user has signed in' do
visit user_two_factor_authentication_path expect(SMSProvider.messages).to be_empty
end
expect(page).to have_content("Welcome Home") it 'sends code via SMS after sign in' do
expect(page).to have_content("You are signed out") visit new_user_session_path
end complete_sign_in_form_for(user)
scenario "sends two factor authentication code after sign in" do expect(page).to have_content 'Enter your personal code'
expect(SMSProvider.messages).to be_empty
visit new_user_session_path expect(SMSProvider.messages.size).to eq(1)
complete_sign_in_form_for(user) message = SMSProvider.last_message
expect(message.to).to eq(user.phone_number)
expect(message.body).to eq(user.otp_code)
end
expect(page).to have_content "Enter your personal code" it 'authenticates a valid OTP code' do
visit new_user_session_path
complete_sign_in_form_for(user)
expect(SMSProvider.messages.size).to eq(1) expect(page).to have_content('You are signed in as Marissa')
message = SMSProvider.last_message
expect(message.to).to eq(user.phone_number)
expect(message.body).to eq(user.otp_code)
end
context "when logged in" do fill_in 'code', with: user.otp_code
click_button 'Submit'
background do within('.flash.notice') do
login_as user expect(page).to have_content('Two factor authentication successful.')
end
expect(current_path).to eq root_path
end
end end
scenario "can fill in TFA code" do it_behaves_like 'sends and authenticates code', create_user('not_encrypted')
visit user_two_factor_authentication_path it_behaves_like 'sends and authenticates code', create_user, 'encrypted'
end
expect(page).to have_content("You are signed in as Marissa") scenario "must be logged in" do
expect(page).to have_content("Enter your personal code") visit user_two_factor_authentication_path
fill_in "code", with: user.otp_code expect(page).to have_content("Welcome Home")
click_button "Submit" expect(page).to have_content("You are signed out")
end
within(".flash.notice") do context "when logged in" do
expect(page).to have_content("Two factor authentication successful.") let(:user) { create_user }
end
background do
login_as user
end end
scenario "is redirected to TFA when path requires authentication" do scenario "is redirected to TFA when path requires authentication" do
@ -122,6 +139,12 @@ feature "User of two factor authentication" do
expect(page).to have_content("Enter your personal code") expect(page).to have_content("Enter your personal code")
end end
end end
it 'sets the warden session need_two_factor_authentication key to true' do
session_hash = { 'need_two_factor_authentication' => true }
expect(page.get_rack_session_key('warden.user.user.session')).to eq session_hash
end
end end
describe 'signing in' do describe 'signing in' do

36
spec/generators/active_record/two_factor_authentication_generator_spec.rb

@ -0,0 +1,36 @@
require 'spec_helper'
require 'generators/active_record/two_factor_authentication_generator'
describe ActiveRecord::Generators::TwoFactorAuthenticationGenerator, type: :generator do
destination File.expand_path('../../../../../tmp', __FILE__)
before do
prepare_destination
end
it 'runs all methods in the generator' do
gen = generator %w(users)
expect(gen).to receive(:copy_two_factor_authentication_migration)
gen.invoke_all
end
describe 'the generated files' do
before do
run_generator %w(users)
end
describe 'the migration' do
subject { migration_file('db/migrate/two_factor_authentication_add_to_users.rb') }
it { is_expected.to exist }
it { is_expected.to be_a_migration }
it { is_expected.to contain /def change/ }
it { is_expected.to contain /add_column :users, :second_factor_attempts_count, :integer, default: 0/ }
it { is_expected.to contain /add_column :users, :encrypted_otp_secret_key, :string/ }
it { is_expected.to contain /add_column :users, :encrypted_otp_secret_key_iv, :string/ }
it { is_expected.to contain /add_column :users, :encrypted_otp_secret_key_salt, :string/ }
it { is_expected.to contain /add_index :users, :encrypted_otp_secret_key, unique: true/ }
end
end
end

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

@ -1,168 +1,264 @@
require 'spec_helper' require 'spec_helper'
include AuthenticatedModelHelper include AuthenticatedModelHelper
describe Devise::Models::TwoFactorAuthenticatable, '#otp_code' do describe Devise::Models::TwoFactorAuthenticatable do
let(:instance) { build_guest_user } describe '#otp_code' do
subject { instance.otp_code(time) } shared_examples 'otp_code' do |instance|
let(:time) { 1392852456 } subject { instance.otp_code(time) }
let(:time) { 1_392_852_456 }
it "should return an error if no secret is set" do
expect { it 'returns an error if no secret is set' do
subject expect { subject }.to raise_error Exception
}.to raise_error Exception end
context 'secret is set' do
before :each do
instance.otp_secret_key = '2z6hxkdwi3uvrnpn'
end
it 'does not return an error' do
subject
end
it 'matches Devise configured length' do
expect(subject.length).to eq(Devise.otp_length)
end
context 'with a known time' do
let(:time) { 1_392_852_756 }
it 'returns a known result' do
expect(subject).
to eq('0000000524562202'.split(//).last(Devise.otp_length).join)
end
end
context 'with a known time yielding a result with less than 6 digits' do
let(:time) { 1_393_065_856 }
it 'returns a known result padded with zeroes' do
expect(subject).
to eq('0000001608007672'.split(//).last(Devise.otp_length).join)
end
end
end
end
it_behaves_like 'otp_code', GuestUser.new
it_behaves_like 'otp_code', EncryptedUser.new
end end
context "secret is set" do describe '#authenticate_otp' do
before :each do shared_examples 'authenticate_otp' do |instance|
instance.otp_secret_key = "2z6hxkdwi3uvrnpn" before :each do
instance.otp_secret_key = '2z6hxkdwi3uvrnpn'
end
def do_invoke(code, user)
user.authenticate_otp(code)
end
it 'authenticates a recently created code' do
code = instance.otp_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)
expect(do_invoke(code, instance)).to eq(false)
end
end end
it "should not return an error" do it_behaves_like 'authenticate_otp', GuestUser.new
subject it_behaves_like 'authenticate_otp', 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 }.
to raise_error(NotImplementedError)
end end
it "should be configured length" do it 'is overrideable' do
expect(subject.length).to eq(Devise.otp_length) def instance.send_two_factor_authentication_code
'Code sent'
end
expect(instance.send_two_factor_authentication_code).to eq('Code sent')
end end
end
context "with a known time" do describe '#provisioning_uri' do
let(:time) { 1392852756 } shared_examples 'provisioning_uri' do |instance|
before do
instance.email = 'houdini@example.com'
instance.run_callbacks :create
end
it "should return a known result" do it "returns uri with user's email" do
expect(subject).to eq("0000000524562202".split(//).last(Devise.otp_length).join) expect(instance.provisioning_uri).
to match(%r{otpauth://totp/houdini@example.com\?secret=\w{16}})
end
it 'returns uri with issuer option' do
expect(instance.provisioning_uri('houdini')).
to match(%r{otpauth://totp/houdini\?secret=\w{16}$})
end end
end
context "with a known time yielding a result with less than 6 digits" do it 'returns uri with issuer option' do
let(:time) { 1393065856 } require 'cgi'
it "should return a known result padded with zeroes" do uri = URI.parse(instance.provisioning_uri('houdini', issuer: 'Magic'))
expect(subject).to eq("0000001608007672".split(//).last(Devise.otp_length).join) params = CGI.parse(uri.query)
expect(uri.scheme).to eq('otpauth')
expect(uri.host).to eq('totp')
expect(uri.path).to eq('/houdini')
expect(params['issuer'].shift).to eq('Magic')
expect(params['secret'].shift).to match(/\w{16}/)
end end
end end
it_behaves_like 'provisioning_uri', GuestUser.new
it_behaves_like 'provisioning_uri', EncryptedUser.new
end end
end
describe Devise::Models::TwoFactorAuthenticatable, '#authenticate_otp' do describe '#populate_otp_column' do
let(:instance) { build_guest_user } shared_examples 'populate_otp_column' do |klass|
let(:instance) { klass.new }
before :each do it 'populates otp_column on create' do
instance.otp_secret_key = "2z6hxkdwi3uvrnpn" expect(instance.otp_secret_key).to be_nil
end
def do_invoke code, options = {} # populate_otp_column called via before_create
instance.authenticate_otp(code, options) instance.run_callbacks :create
end
it "should be able to authenticate a recently created code" do expect(instance.otp_secret_key).to match(/\w{16}/)
code = instance.otp_code end
expect(do_invoke(code)).to eq(true)
end
it "should not authenticate an old code" do it 'repopulates otp_column' do
code = instance.otp_code(1.minutes.ago.to_i) instance.run_callbacks :create
expect(do_invoke(code)).to eq(false) original_key = instance.otp_secret_key
end
end
describe Devise::Models::TwoFactorAuthenticatable, '#send_two_factor_authentication_code' do instance.populate_otp_column
let(:instance) { build_guest_user }
it "should raise an error by default" do expect(instance.otp_secret_key).to match(/\w{16}/)
expect { expect(instance.otp_secret_key).to_not eq(original_key)
instance.send_two_factor_authentication_code end
}.to raise_error(NotImplementedError) end
it_behaves_like 'populate_otp_column', GuestUser
it_behaves_like 'populate_otp_column', EncryptedUser
end end
it "should be overrideable" do describe '#max_login_attempts' do
def instance.send_two_factor_authentication_code let(:instance) { build_guest_user }
"Code sent"
before do
@original_max_login_attempts = GuestUser.max_login_attempts
GuestUser.max_login_attempts = 3
end end
expect(instance.send_two_factor_authentication_code).to eq("Code sent")
end
end
describe Devise::Models::TwoFactorAuthenticatable, '#provisioning_uri' do after { GuestUser.max_login_attempts = @original_max_login_attempts }
let(:instance) { build_guest_user }
before do it 'returns class setting' do
instance.email = "houdini@example.com" expect(instance.max_login_attempts).to eq(3)
instance.run_callbacks :create end
end
it "should return uri with user's email" do it 'returns false as boolean' do
expect(instance.provisioning_uri).to match(%r{otpauth://totp/houdini@example.com\?secret=\w{16}}) instance.second_factor_attempts_count = nil
end expect(instance.max_login_attempts?).to be_falsey
instance.second_factor_attempts_count = 0
expect(instance.max_login_attempts?).to be_falsey
instance.second_factor_attempts_count = 1
expect(instance.max_login_attempts?).to be_falsey
instance.second_factor_attempts_count = 2
expect(instance.max_login_attempts?).to be_falsey
end
it "should return uri with issuer option" do it 'returns true as boolean after too many attempts' do
expect(instance.provisioning_uri("houdini")).to match(%r{otpauth://totp/houdini\?secret=\w{16}$}) instance.second_factor_attempts_count = 3
expect(instance.max_login_attempts?).to be_truthy
instance.second_factor_attempts_count = 4
expect(instance.max_login_attempts?).to be_truthy
end
end end
it "should return uri with issuer option" do describe '.has_one_time_password' do
require 'cgi' context 'when encrypted: true option is passed' do
let(:instance) { EncryptedUser.new }
uri = URI.parse(instance.provisioning_uri("houdini", issuer: 'Magic')) it 'encrypts otp_secret_key with iv, salt, and encoding' do
params = CGI::parse(uri.query) instance.otp_secret_key = '2z6hxkdwi3uvrnpn'
expect(uri.scheme).to eq("otpauth") expect(instance.encrypted_otp_secret_key).to match(/.{44}/)
expect(uri.host).to eq("totp")
expect(uri.path).to eq("/houdini") expect(instance.encrypted_otp_secret_key_iv).to match(/.{24}/)
expect(params['issuer'].shift).to eq('Magic')
expect(params['secret'].shift).to match(%r{\w{16}})
end
end
describe Devise::Models::TwoFactorAuthenticatable, '#populate_otp_column' do expect(instance.encrypted_otp_secret_key_salt).to match(/.{25}/)
let(:instance) { build_guest_user } end
it "populates otp_column on create" do it 'does not encrypt a nil otp_secret_key' do
expect(instance.otp_secret_key).to be_nil instance.otp_secret_key = nil
instance.run_callbacks :create # populate_otp_column called via before_create expect(instance.encrypted_otp_secret_key).to be_nil
expect(instance.otp_secret_key).to match(%r{\w{16}}) expect(instance.encrypted_otp_secret_key_iv).to be_nil
end
it "repopulates otp_column" do expect(instance.encrypted_otp_secret_key_salt).to be_nil
instance.run_callbacks :create end
original_key = instance.otp_secret_key
instance.populate_otp_column it 'does not encrypt an empty otp_secret_key' do
instance.otp_secret_key = ''
expect(instance.otp_secret_key).to match(%r{\w{16}}) expect(instance.encrypted_otp_secret_key).to eq ''
expect(instance.otp_secret_key).to_not eq(original_key)
end
end
describe Devise::Models::TwoFactorAuthenticatable, '#max_login_attempts' do expect(instance.encrypted_otp_secret_key_iv).to be_nil
let(:instance) { build_guest_user }
before do expect(instance.encrypted_otp_secret_key_salt).to be_nil
@original_max_login_attempts = GuestUser.max_login_attempts end
GuestUser.max_login_attempts = 3
end
after { GuestUser.max_login_attempts = @original_max_login_attempts } it 'raises an error when Devise.otp_secret_encryption_key is not set' do
allow(Devise).to receive(:otp_secret_encryption_key).and_return nil
it "returns class setting" do # This error is raised by the encryptor gem
expect(instance.max_login_attempts).to eq(3) expect { instance.otp_secret_key = '2z6hxkdwi3uvrnpn' }.
end to raise_error ArgumentError, 'must specify a :key'
end
it "returns false as boolean" do it 'passes in the correct options to Encryptor' do
instance.second_factor_attempts_count = nil instance.otp_secret_key = 'testing'
expect(instance.max_login_attempts?).to be_falsey iv = instance.encrypted_otp_secret_key_iv
instance.second_factor_attempts_count = 0 salt = instance.encrypted_otp_secret_key_salt
expect(instance.max_login_attempts?).to be_falsey
instance.second_factor_attempts_count = 1 encrypted = Encryptor.encrypt(
expect(instance.max_login_attempts?).to be_falsey value: 'testing',
instance.second_factor_attempts_count = 2 key: Devise.otp_secret_encryption_key,
expect(instance.max_login_attempts?).to be_falsey iv: iv.unpack('m').first,
end salt: salt.unpack('m').first
)
it "returns true as boolean after too many attempts" do expect(instance.encrypted_otp_secret_key).to eq [encrypted].pack('m')
instance.second_factor_attempts_count = 3 end
expect(instance.max_login_attempts?).to be_truthy
instance.second_factor_attempts_count = 4 it 'varies the iv per instance' do
expect(instance.max_login_attempts?).to be_truthy instance.otp_secret_key = 'testing'
user2 = EncryptedUser.new
user2.otp_secret_key = 'testing'
expect(user2.encrypted_otp_secret_key_iv).
to_not eq instance.encrypted_otp_secret_key_iv
end
it 'varies the salt per instance' do
instance.otp_secret_key = 'testing'
user2 = EncryptedUser.new
user2.otp_secret_key = 'testing'
expect(user2.encrypted_otp_secret_key_salt).
to_not eq instance.encrypted_otp_secret_key_salt
end
end
end end
end end

14
spec/rails_app/app/models/encrypted_user.rb

@ -0,0 +1,14 @@
class EncryptedUser
extend ActiveModel::Callbacks
include ActiveModel::Validations
include Devise::Models::TwoFactorAuthenticatable
define_model_callbacks :create
attr_accessor :encrypted_otp_secret_key,
:encrypted_otp_secret_key_iv,
:encrypted_otp_secret_key_salt,
:email,
:second_factor_attempts_count
has_one_time_password(encrypted: true)
end

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

@ -1,7 +1,6 @@
class User < ActiveRecord::Base class User < ActiveRecord::Base
devise :two_factor_authenticatable, :database_authenticatable, :registerable, devise :two_factor_authenticatable, :database_authenticatable, :registerable,
:recoverable, :rememberable, :trackable, :validatable, :recoverable, :rememberable, :trackable, :validatable
:two_factor_authenticatable
has_one_time_password has_one_time_password

3
spec/rails_app/config/environments/test.rb

@ -35,4 +35,7 @@ Dummy::Application.configure do
# Print deprecation notices to the stderr # Print deprecation notices to the stderr
config.active_support.deprecation = :stderr config.active_support.deprecation = :stderr
# For testing session variables in Capybara specs
config.middleware.use RackSessionAccess::Middleware
end end

2
spec/rails_app/config/initializers/devise.rb

@ -253,4 +253,6 @@ Devise.setup do |config|
# When using omniauth, Devise cannot automatically set Omniauth path, # When using omniauth, Devise cannot automatically set Omniauth path,
# so you need to do it manually. For the users scope, it would be: # so you need to do it manually. For the users scope, it would be:
# config.omniauth_path_prefix = '/my_engine/users/auth' # config.omniauth_path_prefix = '/my_engine/users/auth'
config.otp_secret_encryption_key = '0a8283fba984da1de24e4df1e93046cb53c5787944ef037b2dbf3e61d20fe11f25e25a855cec605fdf65b162329890d7230afdf64f681b4c32020281054e73ec'
end end

9
spec/rails_app/db/migrate/20151224171231_add_encrypted_columns_to_user.rb

@ -0,0 +1,9 @@
class AddEncryptedColumnsToUser < ActiveRecord::Migration
def change
add_column :users, :encrypted_otp_secret_key, :string
add_column :users, :encrypted_otp_secret_key_iv, :string
add_column :users, :encrypted_otp_secret_key_salt, :string
add_index :users, :encrypted_otp_secret_key, unique: true
end
end

19
spec/rails_app/db/migrate/20151224180310_populate_otp_column.rb

@ -0,0 +1,19 @@
class PopulateOtpColumn < ActiveRecord::Migration
def up
User.reset_column_information
User.find_each do |user|
user.otp_secret_key = user.read_attribute('otp_secret_key')
user.save!
end
end
def down
User.reset_column_information
User.find_each do |user|
user.otp_secret_key = ROTP::Base32.random_base32
user.save!
end
end
end

5
spec/rails_app/db/migrate/20151228230340_remove_otp_secret_key_from_user.rb

@ -0,0 +1,5 @@
class RemoveOtpSecretKeyFromUser < ActiveRecord::Migration
def change
remove_column :users, :otp_secret_key, :string
end
end

30
spec/rails_app/db/schema.rb

@ -9,30 +9,32 @@
# from scratch. The latter is a flawed and unsustainable approach (the more migrations # from scratch. The latter is a flawed and unsustainable approach (the more migrations
# you'll amass, the slower it'll run and the greater likelihood for issues). # you'll amass, the slower it'll run and the greater likelihood for issues).
# #
# It's strongly recommended to check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(:version => 20140407215513) do ActiveRecord::Schema.define(version: 20151228230340) do
create_table "users", :force => true do |t| create_table "users", force: :cascade do |t|
t.string "email", :default => "", :null => false t.string "email", default: "", null: false
t.string "encrypted_password", :default => "", :null => false t.string "encrypted_password", default: "", null: false
t.string "reset_password_token" t.string "reset_password_token"
t.datetime "reset_password_sent_at" t.datetime "reset_password_sent_at"
t.datetime "remember_created_at" t.datetime "remember_created_at"
t.integer "sign_in_count", :default => 0, :null => false t.integer "sign_in_count", default: 0, null: false
t.datetime "current_sign_in_at" t.datetime "current_sign_in_at"
t.datetime "last_sign_in_at" t.datetime "last_sign_in_at"
t.string "current_sign_in_ip" t.string "current_sign_in_ip"
t.string "last_sign_in_ip" t.string "last_sign_in_ip"
t.datetime "created_at", :null => false t.datetime "created_at", null: false
t.datetime "updated_at", :null => false t.datetime "updated_at", null: false
t.string "otp_secret_key" 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 "encrypted_otp_secret_key"
t.string "encrypted_otp_secret_key_iv"
t.string "encrypted_otp_secret_key_salt"
end end
add_index "users", ["email"], :name => "index_users_on_email", :unique => true add_index "users", ["email"], name: "index_users_on_email", unique: true
add_index "users", ["otp_secret_key"], :name => "index_users_on_otp_secret_key", :unique => true add_index "users", ["encrypted_otp_secret_key"], name: "index_users_on_encrypted_otp_secret_key", unique: true
add_index "users", ["reset_password_token"], :name => "index_users_on_reset_password_token", :unique => true add_index "users", ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end end

1
spec/spec_helper.rb

@ -3,6 +3,7 @@ require File.expand_path("../rails_app/config/environment.rb", __FILE__)
require 'rspec/rails' require 'rspec/rails'
require 'timecop' require 'timecop'
require 'rack_session_access/capybara'
# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
RSpec.configure do |config| RSpec.configure do |config|

28
spec/support/authenticated_model_helper.rb

@ -1,10 +1,11 @@
module AuthenticatedModelHelper module AuthenticatedModelHelper
def build_guest_user def build_guest_user
GuestUser.new GuestUser.new
end end
def create_user(attributes={}) def create_user(type = 'encrypted', attributes = {})
create_table_for_nonencrypted_user if type == 'not_encrypted'
User.create!(valid_attributes(attributes)) User.create!(valid_attributes(attributes))
end end
@ -23,6 +24,29 @@ module AuthenticatedModelHelper
"user#{@@email_count}@example.com" "user#{@@email_count}@example.com"
end end
def create_table_for_nonencrypted_user
silence_stream(STDOUT) do
ActiveRecord::Schema.define(version: 1) do
create_table 'users', force: :cascade do |t|
t.string 'email', default: '', null: false
t.string 'encrypted_password', default: '', null: false
t.string 'reset_password_token'
t.datetime 'reset_password_sent_at'
t.datetime 'remember_created_at'
t.integer 'sign_in_count', default: 0, null: false
t.datetime 'current_sign_in_at'
t.datetime 'last_sign_in_at'
t.string 'current_sign_in_ip'
t.string 'last_sign_in_ip'
t.datetime 'created_at', null: false
t.datetime 'updated_at', null: false
t.integer 'second_factor_attempts_count', default: 0
t.string 'nickname', limit: 64
t.string 'otp_secret_key'
end
end
end
end
end end
RSpec.configuration.send(:include, AuthenticatedModelHelper) RSpec.configuration.send(:include, AuthenticatedModelHelper)

16
spec/support/controller_helper.rb

@ -0,0 +1,16 @@
module ControllerHelper
def sign_in(user = create_user('not_encrypted'))
allow(warden).to receive(:authenticated?).with(:user).and_return(true)
allow(controller).to receive(:current_user).and_return(user)
warden.session(:user)[TwoFactorAuthentication::NEED_AUTHENTICATION] = true
end
end
RSpec.configure do |config|
config.include Devise::TestHelpers, type: :controller
config.include ControllerHelper, type: :controller
config.before(:example, type: :controller) do
@request.env['devise.mapping'] = Devise.mappings[:user]
end
end

8
spec/support/features_spec_helper.rb

@ -15,5 +15,13 @@ end
RSpec.configure do |config| RSpec.configure do |config|
config.include Warden::Test::Helpers, type: :feature config.include Warden::Test::Helpers, type: :feature
config.include FeaturesSpecHelper, type: :feature config.include FeaturesSpecHelper, type: :feature
config.before(:each) do
Warden.test_mode!
end
config.after(:each) do
Warden.test_reset!
end
end end

1
two_factor_authentication.gemspec

@ -28,6 +28,7 @@ Gem::Specification.new do |s|
s.add_runtime_dependency 'devise' s.add_runtime_dependency 'devise'
s.add_runtime_dependency 'randexp' s.add_runtime_dependency 'randexp'
s.add_runtime_dependency 'rotp' s.add_runtime_dependency 'rotp'
s.add_runtime_dependency 'encryptor'
s.add_development_dependency 'bundler' s.add_development_dependency 'bundler'
s.add_development_dependency 'rake' s.add_development_dependency 'rake'

Loading…
Cancel
Save