Browse Source

Merge pull request #62 from monfresh/fix/51-encrypt-otp-secret

Add support for OTP secret key encryption
master
Dmitrii Golub 9 years ago
parent
commit
b4a589d194
  1. 9
      Gemfile
  2. 197
      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. 63
      spec/features/two_factor_authenticatable_spec.rb
  10. 36
      spec/generators/active_record/two_factor_authentication_generator_spec.rb
  11. 220
      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

197
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
Set config values, if desired: #### Manual initial setup
If you prefer to set up the model and migration manually, add the
`: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,22 +145,95 @@ 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
user.save!
puts "Rake[:update_users_with_otp_secret_key] => OTP secret key set to '#{key}' for User '#{user.email}'"
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
```
users.each do |user| Open your migration file (it will be in the `db/migrate` directory and will be
key = ROTP::Base32.random_base32 named something like `20151230163930_add_encryption_fields_to_users.rb`), and
user.update_attributes(:otp_secret_key => key) add `unique: true` to the `add_index` line so that it looks like this:
user.save
puts "Rake[:update_users_with_otp_secret_key] => User '#{user.email}' OTP secret key set to '#{key}'" ```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
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
In some cases, you might want to perform some action right after the user signs In some cases, you might want to perform some action right after the user signs
@ -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
end
def down add_index :<%= table_name %>, :encrypted_otp_secret_key, unique: true
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(
::Devise::Models.config(self, :max_login_attempts, :allowed_otp_drift_seconds, :otp_length, :remember_otp_session_for_seconds) 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 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
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

63
spec/features/two_factor_authenticatable_spec.rb

@ -1,22 +1,24 @@
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|
scenario "must be logged in" do before do
visit user_two_factor_authentication_path if type == 'encrypted'
allow(User).to receive(:has_one_time_password).with(encrypted: true)
expect(page).to have_content("Welcome Home") end
expect(page).to have_content("You are signed out")
end end
scenario "sends two factor authentication code after sign in" do it 'does not send an SMS before the user has signed in' do
expect(SMSProvider.messages).to be_empty expect(SMSProvider.messages).to be_empty
end
it 'sends code via SMS after sign in' 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 your personal code'
expect(SMSProvider.messages.size).to eq(1) expect(SMSProvider.messages.size).to eq(1)
message = SMSProvider.last_message message = SMSProvider.last_message
@ -24,24 +26,39 @@ feature "User of two factor authentication" do
expect(message.body).to eq(user.otp_code) expect(message.body).to eq(user.otp_code)
end end
context "when logged in" do it 'authenticates a valid OTP code' do
visit new_user_session_path
complete_sign_in_form_for(user)
background do expect(page).to have_content('You are signed in as Marissa')
login_as user
fill_in 'code', with: user.otp_code
click_button 'Submit'
within('.flash.notice') do
expect(page).to have_content('Two factor authentication successful.')
end end
scenario "can fill in TFA code" do expect(current_path).to eq root_path
visit user_two_factor_authentication_path end
end
expect(page).to have_content("You are signed in as Marissa") it_behaves_like 'sends and authenticates code', create_user('not_encrypted')
expect(page).to have_content("Enter your personal code") it_behaves_like 'sends and authenticates code', create_user, 'encrypted'
end
fill_in "code", with: user.otp_code scenario "must be logged in" do
click_button "Submit" visit user_two_factor_authentication_path
within(".flash.notice") do expect(page).to have_content("Welcome Home")
expect(page).to have_content("Two factor authentication successful.") expect(page).to have_content("You are signed out")
end end
context "when logged in" do
let(:user) { create_user }
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

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

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

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