two_factor_authentication 2.1.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +5 -5
  2. data/.github/workflows/ci.yml +77 -0
  3. data/.gitignore +3 -0
  4. data/CHANGELOG.md +37 -2
  5. data/Gemfile +29 -9
  6. data/MIGRATION_GUIDE.md +143 -0
  7. data/README.md +217 -28
  8. data/app/controllers/devise/two_factor_authentication_controller.rb +3 -2
  9. data/app/views/devise/two_factor_authentication/show.html.erb +4 -4
  10. data/config/locales/de.yml +8 -0
  11. data/lib/generators/active_record/templates/migration.rb +9 -1
  12. data/lib/generators/active_record/two_factor_authentication_generator.rb +18 -0
  13. data/lib/generators/two_factor_authentication/two_factor_authentication_generator.rb +1 -1
  14. data/lib/two_factor_authentication/controllers/helpers.rb +8 -4
  15. data/lib/two_factor_authentication/models/two_factor_authenticatable.rb +16 -6
  16. data/lib/two_factor_authentication/orm/active_record.rb +2 -0
  17. data/lib/two_factor_authentication/routes.rb +1 -1
  18. data/lib/two_factor_authentication/schema.rb +7 -7
  19. data/lib/two_factor_authentication/version.rb +1 -1
  20. data/lib/two_factor_authentication.rb +1 -2
  21. data/spec/controllers/two_factor_authentication_controller_spec.rb +87 -2
  22. data/spec/features/two_factor_authenticatable_spec.rb +119 -14
  23. data/spec/generators/active_record/two_factor_authentication_generator_spec.rb +52 -4
  24. data/spec/generators/two_factor_authentication/two_factor_authentication_generator_spec.rb +37 -0
  25. data/spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb +97 -10
  26. data/spec/lib/two_factor_authentication/schema_spec.rb +41 -0
  27. data/spec/rails_app/app/assets/config/manifest.js +2 -0
  28. data/spec/rails_app/app/controllers/home_controller.rb +6 -1
  29. data/spec/rails_app/app/models/guest_user.rb +1 -1
  30. data/spec/rails_app/app/models/secure_user.rb +13 -0
  31. data/spec/rails_app/config/application.rb +1 -1
  32. data/spec/rails_app/config/environments/test.rb +3 -8
  33. data/spec/rails_app/config/initializers/inflections.rb +4 -0
  34. data/spec/rails_app/config/routes.rb +2 -0
  35. data/spec/rails_app/db/migrate/20140403184646_devise_create_users.rb +1 -1
  36. data/spec/rails_app/db/migrate/20140407172619_two_factor_authentication_add_to_users.rb +1 -1
  37. data/spec/rails_app/db/migrate/20140407215513_add_nickanme_to_users.rb +1 -1
  38. data/spec/rails_app/db/migrate/20151224171231_add_encrypted_columns_to_user.rb +1 -1
  39. data/spec/rails_app/db/migrate/20151224180310_populate_otp_column.rb +1 -1
  40. data/spec/rails_app/db/migrate/20151228230340_remove_otp_secret_key_from_user.rb +1 -1
  41. data/spec/rails_app/db/migrate/20160209032439_devise_create_admins.rb +1 -1
  42. data/spec/rails_app/db/migrate/20260929000000_add_otp_columns_to_users.rb +8 -0
  43. data/spec/rails_app/db/migrate/20260929000001_devise_create_secure_users.rb +22 -0
  44. data/spec/rails_app/db/schema.rb +51 -36
  45. data/spec/rails_app/lib/sms_provider.rb +2 -4
  46. data/spec/requests/two_factor_authentication_helpers_spec.rb +48 -0
  47. data/spec/spec_helper.rb +1 -0
  48. data/spec/support/authenticated_model_helper.rb +5 -30
  49. data/spec/support/controller_helper.rb +5 -4
  50. data/spec/support/features_spec_helper.rb +1 -0
  51. data/spec/support/totp_helper.rb +1 -1
  52. data/two_factor_authentication.gemspec +5 -6
  53. metadata +45 -28
  54. data/.travis.yml +0 -29
  55. data/spec/rails_app/config/initializers/secret_token.rb +0 -7
@@ -7,13 +7,13 @@
7
7
  <p><%= flash[:notice] %></p>
8
8
 
9
9
  <%= form_tag([resource_name, :two_factor_authentication], :method => :put) do %>
10
- <%= text_field_tag :code %>
10
+ <%= text_field_tag :code, '', autofocus: true %>
11
11
  <%= submit_tag "Submit" %>
12
12
  <% end %>
13
13
 
14
14
  <% if resource.direct_otp %>
15
- <%= link_to "Resend Code", send("resend_code_#{resource_name}_two_factor_authentication_path"), action: :get %>
15
+ <%= link_to "Resend Code", send("resend_code_#{resource_name}_two_factor_authentication_path") %>
16
16
  <% else %>
17
- <%= link_to "Send me a code instead", send("resend_code_#{resource_name}_two_factor_authentication_path"), action: :get %>
17
+ <%= link_to "Send me a code instead", send("resend_code_#{resource_name}_two_factor_authentication_path") %>
18
18
  <% end %>
19
- <%= link_to "Sign out", send("destroy_#{resource_name}_session_path"), :method => :delete %>
19
+ <%= button_to "Sign out", send("destroy_#{resource_name}_session_path"), :method => Array(Devise.sign_out_via).first %>
@@ -0,0 +1,8 @@
1
+ de:
2
+ devise:
3
+ two_factor_authentication:
4
+ success: "Ihre Zwei-Faktor-Authentifizierung war erfolgreich."
5
+ attempt_failed: "Authentifizierungsversuch fehlgeschlagen."
6
+ max_login_attempts_reached: "Ihr Zugang wurde ganz verweigert, da Sie Ihr Versuchslimit erreicht haben."
7
+ contact_administrator: "Kontaktieren Sie bitte einen Ihrer Administratoren."
8
+ code_has_been_sent: "Ihr Einmal-Passwort wurde verschickt."
@@ -1,4 +1,8 @@
1
- class TwoFactorAuthenticationAddTo<%= table_name.camelize %> < ActiveRecord::Migration
1
+ class TwoFactorAuthenticationAddTo<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_version %>
2
+ <% if postgresql? -%>
3
+ disable_ddl_transaction!
4
+
5
+ <% end -%>
2
6
  def change
3
7
  add_column :<%= table_name %>, :second_factor_attempts_count, :integer, default: 0
4
8
  add_column :<%= table_name %>, :encrypted_otp_secret_key, :string
@@ -8,6 +12,10 @@ class TwoFactorAuthenticationAddTo<%= table_name.camelize %> < ActiveRecord::Mig
8
12
  add_column :<%= table_name %>, :direct_otp_sent_at, :datetime
9
13
  add_column :<%= table_name %>, :totp_timestamp, :timestamp
10
14
 
15
+ <% if postgresql? -%>
16
+ add_index :<%= table_name %>, :encrypted_otp_secret_key, unique: true, algorithm: :concurrently
17
+ <% else -%>
11
18
  add_index :<%= table_name %>, :encrypted_otp_secret_key, unique: true
19
+ <% end -%>
12
20
  end
13
21
  end
@@ -9,6 +9,24 @@ module ActiveRecord
9
9
  migration_template "migration.rb", "db/migrate/two_factor_authentication_add_to_#{table_name}.rb"
10
10
  end
11
11
 
12
+ private
13
+
14
+ def migration_version
15
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
16
+ end
17
+
18
+ # CREATE INDEX CONCURRENTLY is PostgreSQL-only; other adapters reject the option.
19
+ def postgresql?
20
+ config = if ActiveRecord::Base.respond_to?(:connection_db_config)
21
+ ActiveRecord::Base.connection_db_config
22
+ else
23
+ ActiveRecord::Base.connection_config
24
+ end
25
+ adapter = config.respond_to?(:adapter) ? config.adapter : config[:adapter]
26
+ adapter.to_s.include?("postg")
27
+ rescue StandardError
28
+ false
29
+ end
12
30
  end
13
31
  end
14
32
  end
@@ -7,7 +7,7 @@ module TwoFactorAuthenticatable
7
7
 
8
8
  def inject_two_factor_authentication_content
9
9
  path = File.join("app", "models", "#{file_path}.rb")
10
- inject_into_file(path, "two_factor_authenticatable, :", :after => "devise :") if File.exists?(path)
10
+ inject_into_file(path, "two_factor_authenticatable, :", :after => "devise :") if File.exist?(File.join(destination_root, path))
11
11
  end
12
12
 
13
13
  hook_for :orm
@@ -20,11 +20,14 @@ module TwoFactorAuthentication
20
20
  end
21
21
 
22
22
  def handle_failed_second_factor(scope)
23
- if request.format.present? and request.format.html?
23
+ if request.format&.html?
24
24
  session["#{scope}_return_to"] = request.original_fullpath if request.get?
25
25
  redirect_to two_factor_authentication_path_for(scope)
26
+ elsif request.format&.json?
27
+ session["#{scope}_return_to"] = root_path(format: :html)
28
+ render json: { redirect_to: two_factor_authentication_path_for(scope) }, status: :unauthorized
26
29
  else
27
- render nothing: true, status: :unauthorized
30
+ head :unauthorized
28
31
  end
29
32
  end
30
33
 
@@ -41,8 +44,9 @@ end
41
44
  module Devise
42
45
  module Controllers
43
46
  module Helpers
44
- def is_fully_authenticated?
45
- !session["warden.user.user.session"].try(:[], TwoFactorAuthentication::NEED_AUTHENTICATION)
47
+ def is_fully_authenticated?(scope = nil)
48
+ scope ||= devise_controller? ? resource_name : Devise.default_scope
49
+ !session["warden.user.#{scope}.session"].try(:[], TwoFactorAuthentication::NEED_AUTHENTICATION)
46
50
  end
47
51
  end
48
52
  end
@@ -16,7 +16,7 @@ module Devise
16
16
  ::Devise::Models.config(
17
17
  self, :max_login_attempts, :allowed_otp_drift_seconds, :otp_length,
18
18
  :remember_otp_session_for_seconds, :otp_secret_encryption_key,
19
- :direct_otp_length, :direct_otp_valid_for, :totp_timestamp, :delete_cookie_on_logout
19
+ :direct_otp_length, :direct_otp_valid_for, :delete_cookie_on_logout
20
20
  )
21
21
  end
22
22
 
@@ -39,9 +39,12 @@ module Devise
39
39
  drift = options[:drift] || self.class.allowed_otp_drift_seconds
40
40
  raise "authenticate_totp called with no otp_secret_key set" if totp_secret.nil?
41
41
  totp = ROTP::TOTP.new(totp_secret, digits: digits)
42
- new_timestamp = totp.verify_with_drift_and_prior(code, drift, totp_timestamp)
42
+ new_timestamp = totp.verify(
43
+ without_spaces(code),
44
+ drift_ahead: drift, drift_behind: drift, after: totp_timestamp
45
+ )
43
46
  return false unless new_timestamp
44
- self.totp_timestamp = new_timestamp
47
+ self.totp_timestamp = Time.at(new_timestamp).utc
45
48
  true
46
49
  end
47
50
 
@@ -89,13 +92,16 @@ module Devise
89
92
  end
90
93
 
91
94
  def generate_totp_secret
92
- ROTP::Base32.random_base32
95
+ # ROTP gem since version 5 to version 5.1
96
+ # at version 5.1 ROTP gem reinstates.
97
+ # Details: https://github.com/mdp/rotp/blob/master/CHANGELOG.md#510
98
+ ROTP::Base32.try(:random) || ROTP::Base32.random_base32
93
99
  end
94
100
 
95
101
  def create_direct_otp(options = {})
96
102
  # Create a new random OTP and store it in the database
97
103
  digits = options[:length] || self.class.direct_otp_length || 6
98
- update_attributes(
104
+ update(
99
105
  direct_otp: random_base10(digits),
100
106
  direct_otp_sent_at: Time.now.utc
101
107
  )
@@ -103,6 +109,10 @@ module Devise
103
109
 
104
110
  private
105
111
 
112
+ def without_spaces(code)
113
+ code.gsub(/\s/, '')
114
+ end
115
+
106
116
  def random_base10(digits)
107
117
  SecureRandom.random_number(10**digits).to_s.rjust(digits, '0')
108
118
  end
@@ -112,7 +122,7 @@ module Devise
112
122
  end
113
123
 
114
124
  def clear_direct_otp
115
- update_attributes(direct_otp: nil, direct_otp_sent_at: nil)
125
+ update(direct_otp: nil, direct_otp_sent_at: nil)
116
126
  end
117
127
  end
118
128
 
@@ -1,3 +1,5 @@
1
+ require "active_record"
2
+
1
3
  module TwoFactorAuthentication
2
4
  module Orm
3
5
  module ActiveRecord
@@ -3,7 +3,7 @@ module ActionDispatch::Routing
3
3
  protected
4
4
 
5
5
  def devise_two_factor_authentication(mapping, controllers)
6
- resource :two_factor_authentication, :only => [:show, :update, :resend_code], :path => mapping.path_names[:two_factor_authentication], :controller => controllers[:two_factor_authentication] do
6
+ resource :two_factor_authentication, :only => [:show, :update], :path => mapping.path_names[:two_factor_authentication], :controller => controllers[:two_factor_authentication] do
7
7
  collection { get "resend_code" }
8
8
  end
9
9
  end
@@ -1,31 +1,31 @@
1
1
  module TwoFactorAuthentication
2
2
  module Schema
3
3
  def second_factor_attempts_count
4
- apply_devise_schema :second_factor_attempts_count, Integer, :default => 0
4
+ integer :second_factor_attempts_count, :default => 0
5
5
  end
6
6
 
7
7
  def encrypted_otp_secret_key
8
- apply_devise_schema :encrypted_otp_secret_key, String
8
+ string :encrypted_otp_secret_key
9
9
  end
10
10
 
11
11
  def encrypted_otp_secret_key_iv
12
- apply_devise_schema :encrypted_otp_secret_key_iv, String
12
+ string :encrypted_otp_secret_key_iv
13
13
  end
14
14
 
15
15
  def encrypted_otp_secret_key_salt
16
- apply_devise_schema :encrypted_otp_secret_key_salt, String
16
+ string :encrypted_otp_secret_key_salt
17
17
  end
18
18
 
19
19
  def direct_otp
20
- apply_devise_schema :direct_otp, String
20
+ string :direct_otp
21
21
  end
22
22
 
23
23
  def direct_otp_sent_at
24
- apply_devise_schema :direct_otp_sent_at, DateTime
24
+ datetime :direct_otp_sent_at
25
25
  end
26
26
 
27
27
  def totp_timestamp
28
- apply_devise_schema :totp_timestamp, Timestamp
28
+ timestamp :totp_timestamp
29
29
  end
30
30
  end
31
31
  end
@@ -1,3 +1,3 @@
1
1
  module TwoFactorAuthentication
2
- VERSION = "2.1.1".freeze
2
+ VERSION = "3.0.0".freeze
3
3
  end
@@ -2,7 +2,6 @@ require 'two_factor_authentication/version'
2
2
  require 'devise'
3
3
  require 'active_support/concern'
4
4
  require "active_model"
5
- require "active_record"
6
5
  require "active_support/core_ext/class/attribute_accessors"
7
6
  require "cgi"
8
7
 
@@ -47,7 +46,7 @@ end
47
46
 
48
47
  Devise.add_module :two_factor_authenticatable, :model => 'two_factor_authentication/models/two_factor_authenticatable', :controller => :two_factor_authentication, :route => :two_factor_authentication
49
48
 
50
- require 'two_factor_authentication/orm/active_record'
49
+ require 'two_factor_authentication/orm/active_record' if defined?(ActiveRecord::Base)
51
50
  require 'two_factor_authentication/routes'
52
51
  require 'two_factor_authentication/models/two_factor_authenticatable'
53
52
  require 'two_factor_authentication/rails'
@@ -1,6 +1,14 @@
1
1
  require 'spec_helper'
2
2
 
3
3
  describe Devise::TwoFactorAuthenticationController, type: :controller do
4
+ def post_code(code)
5
+ if Rails::VERSION::MAJOR >= 5
6
+ post :update, params: { code: code }
7
+ else
8
+ post :update, code: code
9
+ end
10
+ end
11
+
4
12
  describe 'is_fully_authenticated? helper' do
5
13
  before do
6
14
  sign_in
@@ -9,7 +17,7 @@ describe Devise::TwoFactorAuthenticationController, type: :controller do
9
17
  context 'after user enters valid OTP code' do
10
18
  it 'returns true' do
11
19
  controller.current_user.send_new_otp
12
- post :update, code: controller.current_user.direct_otp
20
+ post_code controller.current_user.direct_otp
13
21
  expect(subject.is_fully_authenticated?).to eq true
14
22
  end
15
23
  end
@@ -24,10 +32,87 @@ describe Devise::TwoFactorAuthenticationController, type: :controller do
24
32
 
25
33
  context 'when user enters an invalid OTP' do
26
34
  it 'returns false' do
27
- post :update, code: '12345'
35
+ post_code '12345'
28
36
 
29
37
  expect(subject.is_fully_authenticated?).to eq false
30
38
  end
31
39
  end
32
40
  end
41
+
42
+ describe 'PUT update' do
43
+ render_views
44
+
45
+ let(:user) { create_user }
46
+
47
+ before do
48
+ sign_in user
49
+ user.send_new_otp
50
+ end
51
+
52
+ it 'responds with 422 and re-renders the form for an invalid code' do
53
+ post_code '00000000'
54
+
55
+ expect(response).to have_http_status(422)
56
+ expect(response.body).to include('Enter the code that was sent to you')
57
+ expect(user.reload.second_factor_attempts_count).to eq(1)
58
+ end
59
+
60
+ it 'responds with 422 and signs out when the last attempt fails' do
61
+ user.update!(second_factor_attempts_count: User.max_login_attempts - 1)
62
+
63
+ post_code '00000000'
64
+
65
+ expect(response).to have_http_status(422)
66
+ expect(response.body).to include('Access completely denied')
67
+ end
68
+
69
+ it 'resets the failed attempt counter after a valid code' do
70
+ user.update!(second_factor_attempts_count: 1)
71
+
72
+ post_code user.reload.direct_otp
73
+
74
+ expect(response).to redirect_to(root_path)
75
+ expect(user.reload.second_factor_attempts_count).to eq(0)
76
+ end
77
+ end
78
+
79
+ describe 'GET resend_code' do
80
+ let(:user) { create_user }
81
+
82
+ before { sign_in user }
83
+
84
+ it 'sends a new code and redirects back to the form' do
85
+ get :resend_code
86
+
87
+ expect(SMSProvider.messages.size).to eq(1)
88
+ expect(SMSProvider.last_message.body).to eq(user.reload.direct_otp)
89
+ expect(response).to redirect_to(user_two_factor_authentication_path)
90
+ expect(flash[:notice]).to eq('Your authentication code has been sent.')
91
+ end
92
+ end
93
+
94
+ context 'with a second devise scope' do
95
+ let(:secure_user) { create_secure_user }
96
+
97
+ before do
98
+ @request.env['devise.mapping'] = Devise.mappings[:secure_user]
99
+ sign_in secure_user, scope: :secure_user
100
+ end
101
+
102
+ it 'reports the scope of the current resource as not fully authenticated' do
103
+ get :show
104
+
105
+ expect(response).to have_http_status(200)
106
+ expect(subject.is_fully_authenticated?).to eq false
107
+ expect(subject.is_fully_authenticated?(:secure_user)).to eq false
108
+ end
109
+
110
+ it 'reports the scope as fully authenticated after a valid code' do
111
+ secure_user.send_new_otp
112
+ post_code secure_user.reload.direct_otp
113
+
114
+ expect(subject.is_fully_authenticated?).to eq true
115
+ expect(subject.is_fully_authenticated?(:secure_user)).to eq true
116
+ end
117
+ end
33
118
  end
@@ -3,20 +3,14 @@ include AuthenticatedModelHelper
3
3
 
4
4
  feature "User of two factor authentication" do
5
5
  context 'sending two factor authentication code via SMS' do
6
- shared_examples 'sends and authenticates code' do |user, type|
7
- before do
8
- user.reload
9
- if type == 'encrypted'
10
- allow(User).to receive(:has_one_time_password).with(encrypted: true)
11
- end
12
- end
13
-
6
+ shared_examples 'sends and authenticates code' do |scope|
14
7
  it 'does not send an SMS before the user has signed in' do
8
+ user
15
9
  expect(SMSProvider.messages).to be_empty
16
10
  end
17
11
 
18
12
  it 'sends code via SMS after sign in' do
19
- visit new_user_session_path
13
+ visit send("new_#{scope}_session_path")
20
14
  complete_sign_in_form_for(user)
21
15
 
22
16
  expect(page).to have_content 'Enter the code that was sent to you'
@@ -28,11 +22,9 @@ feature "User of two factor authentication" do
28
22
  end
29
23
 
30
24
  it 'authenticates a valid OTP code' do
31
- visit new_user_session_path
25
+ visit send("new_#{scope}_session_path")
32
26
  complete_sign_in_form_for(user)
33
27
 
34
- expect(page).to have_content('You are signed in as Marissa')
35
-
36
28
  fill_in 'code', with: SMSProvider.last_message.body
37
29
  click_button 'Submit'
38
30
 
@@ -42,10 +34,101 @@ feature "User of two factor authentication" do
42
34
 
43
35
  expect(current_path).to eq root_path
44
36
  end
37
+
38
+ it 'sends a new code when the user asks to resend it' do
39
+ visit send("new_#{scope}_session_path")
40
+ complete_sign_in_form_for(user)
41
+
42
+ click_link 'Resend Code'
43
+
44
+ expect(page).to have_content('Your authentication code has been sent.')
45
+ expect(SMSProvider.messages.size).to eq(2)
46
+
47
+ fill_in 'code', with: SMSProvider.last_message.body
48
+ click_button 'Submit'
49
+
50
+ expect(page).to have_content('Two factor authentication successful.')
51
+ end
52
+ end
53
+
54
+ context 'for a user with a plain OTP secret' do
55
+ let(:user) { create_user }
56
+
57
+ it_behaves_like 'sends and authenticates code', :user
58
+ end
59
+
60
+ context 'for a second devise scope with an encrypted OTP secret' do
61
+ let(:user) { create_secure_user }
62
+
63
+ it_behaves_like 'sends and authenticates code', :secure_user
64
+ end
65
+ end
66
+
67
+ context 'authenticating with an authenticator app (TOTP)' do
68
+ shared_examples 'authenticates a TOTP code' do |scope|
69
+ let(:secret) { user.generate_totp_secret }
70
+
71
+ before do
72
+ user.update!(otp_secret_key: secret)
73
+ visit send("new_#{scope}_session_path")
74
+ complete_sign_in_form_for(user)
75
+ end
76
+
77
+ it 'asks for the authenticator code without sending an SMS' do
78
+ expect(page).to have_content('Enter the code from your authenticator app')
79
+ expect(SMSProvider.messages).to be_empty
80
+ end
81
+
82
+ it 'accepts a valid TOTP code' do
83
+ fill_in 'code', with: ROTP::TOTP.new(secret).now
84
+ click_button 'Submit'
85
+
86
+ expect(page).to have_content('Two factor authentication successful.')
87
+ expect(current_path).to eq root_path
88
+ end
89
+
90
+ it 'rejects an invalid TOTP code' do
91
+ fill_in 'code', with: 'incorrect'
92
+ click_button 'Submit'
93
+
94
+ expect(page).to have_content('Attempt failed')
95
+ expect(page).to have_content('Enter the code from your authenticator app')
96
+ end
97
+
98
+ it 'sends a code by SMS on request instead' do
99
+ click_link 'Send me a code instead'
100
+
101
+ expect(SMSProvider.messages.size).to eq(1)
102
+ fill_in 'code', with: SMSProvider.last_message.body
103
+ click_button 'Submit'
104
+
105
+ expect(page).to have_content('Two factor authentication successful.')
106
+ end
107
+ end
108
+
109
+ context 'for a user with a plain OTP secret' do
110
+ let(:user) { create_user }
111
+
112
+ it_behaves_like 'authenticates a TOTP code', :user
45
113
  end
46
114
 
47
- it_behaves_like 'sends and authenticates code', create_user('not_encrypted')
48
- it_behaves_like 'sends and authenticates code', create_user, 'encrypted'
115
+ context 'for a second devise scope with an encrypted OTP secret' do
116
+ let(:user) { create_secure_user }
117
+
118
+ it_behaves_like 'authenticates a TOTP code', :secure_user
119
+ end
120
+ end
121
+
122
+ scenario 'user who does not need two factor authentication goes straight in' do
123
+ allow_any_instance_of(User).to receive(:need_two_factor_authentication?).and_return(false)
124
+ user = create_user
125
+
126
+ visit new_user_session_path
127
+ complete_sign_in_form_for(user)
128
+ visit dashboard_path
129
+
130
+ expect(page).to have_content('Your Personal Dashboard')
131
+ expect(SMSProvider.messages).to be_empty
49
132
  end
50
133
 
51
134
  scenario "must be logged in" do
@@ -94,6 +177,28 @@ feature "User of two factor authentication" do
94
177
  expect(page).to have_content("You are signed out")
95
178
  end
96
179
 
180
+ scenario "failed attempts are reset after a successful code" do
181
+ visit user_two_factor_authentication_path
182
+
183
+ fill_in "code", with: "incorrect"
184
+ click_button "Submit"
185
+ expect(user.reload.second_factor_attempts_count).to eq(1)
186
+
187
+ fill_in "code", with: SMSProvider.last_message.body
188
+ click_button "Submit"
189
+
190
+ expect(page).to have_content("Two factor authentication successful.")
191
+ expect(user.reload.second_factor_attempts_count).to eq(0)
192
+ end
193
+
194
+ scenario "can sign out from the two factor page" do
195
+ visit user_two_factor_authentication_path
196
+
197
+ click_button "Sign out"
198
+
199
+ expect(page).to have_content("You are signed out")
200
+ end
201
+
97
202
  scenario "cannot retry authentication after max attempts" do
98
203
  user.update_attribute(:second_factor_attempts_count, User.max_login_attempts)
99
204
 
@@ -1,9 +1,10 @@
1
1
  require 'spec_helper'
2
+ require 'tmpdir'
2
3
 
3
4
  require 'generators/active_record/two_factor_authentication_generator'
4
5
 
5
6
  describe ActiveRecord::Generators::TwoFactorAuthenticationGenerator, type: :generator do
6
- destination File.expand_path('../../../../../tmp', __FILE__)
7
+ destination File.join(Dir.tmpdir, 'two_factor_authentication_generator')
7
8
 
8
9
  before do
9
10
  prepare_destination
@@ -21,16 +22,63 @@ describe ActiveRecord::Generators::TwoFactorAuthenticationGenerator, type: :gene
21
22
  end
22
23
 
23
24
  describe 'the migration' do
24
- subject { migration_file('db/migrate/two_factor_authentication_add_to_users.rb') }
25
+ subject { Pathname.new(migration_file('db/migrate/two_factor_authentication_add_to_users.rb')) }
25
26
 
26
- it { is_expected.to exist }
27
+ it { is_expected.to satisfy { |path| File.exist?(path) } }
27
28
  it { is_expected.to be_a_migration }
29
+ it { is_expected.to contain /< ActiveRecord::Migration\[#{ActiveRecord::VERSION::MAJOR}\.#{ActiveRecord::VERSION::MINOR}\]/ }
28
30
  it { is_expected.to contain /def change/ }
29
31
  it { is_expected.to contain /add_column :users, :second_factor_attempts_count, :integer, default: 0/ }
30
32
  it { is_expected.to contain /add_column :users, :encrypted_otp_secret_key, :string/ }
31
33
  it { is_expected.to contain /add_column :users, :encrypted_otp_secret_key_iv, :string/ }
32
34
  it { is_expected.to contain /add_column :users, :encrypted_otp_secret_key_salt, :string/ }
33
- it { is_expected.to contain /add_index :users, :encrypted_otp_secret_key, unique: true/ }
35
+ it { is_expected.to contain /add_column :users, :direct_otp, :string/ }
36
+ it { is_expected.to contain /add_column :users, :direct_otp_sent_at, :datetime/ }
37
+ it { is_expected.to contain /add_column :users, :totp_timestamp, :timestamp/ }
38
+ it { is_expected.to contain /add_index :users, :encrypted_otp_secret_key, unique: true$/ }
39
+ it { is_expected.not_to contain /concurrently/ }
40
+ it { is_expected.not_to contain /disable_ddl_transaction!/ }
41
+ end
42
+ end
43
+
44
+ describe 'on PostgreSQL' do
45
+ subject { Pathname.new(migration_file('db/migrate/two_factor_authentication_add_to_users.rb')) }
46
+
47
+ before do
48
+ gen = generator %w(users)
49
+ allow(gen).to receive(:postgresql?).and_return(true)
50
+ gen.invoke_all
51
+ end
52
+
53
+ it { is_expected.to contain /disable_ddl_transaction!/ }
54
+ it { is_expected.to contain /add_index :users, :encrypted_otp_secret_key, unique: true, algorithm: :concurrently/ }
55
+ end
56
+
57
+ describe 'running the generated migration' do
58
+ let(:connection) { ActiveRecord::Base.connection }
59
+
60
+ before do
61
+ connection.create_table(:accounts) { |t| t.string :email }
62
+ run_generator %w(accounts)
63
+ load migration_file('db/migrate/two_factor_authentication_add_to_accounts.rb')
64
+ end
65
+
66
+ after do
67
+ connection.drop_table(:accounts, if_exists: true)
68
+ Object.send(:remove_const, :TwoFactorAuthenticationAddToAccounts)
69
+ end
70
+
71
+ it 'adds the two factor columns and index' do
72
+ ActiveRecord::Migration.suppress_messages do
73
+ TwoFactorAuthenticationAddToAccounts.migrate(:up)
74
+ end
75
+
76
+ expect(connection.columns(:accounts).map(&:name)).to include(
77
+ 'second_factor_attempts_count', 'encrypted_otp_secret_key',
78
+ 'encrypted_otp_secret_key_iv', 'encrypted_otp_secret_key_salt',
79
+ 'direct_otp', 'direct_otp_sent_at', 'totp_timestamp'
80
+ )
81
+ expect(connection.index_exists?(:accounts, :encrypted_otp_secret_key, unique: true)).to eq(true)
34
82
  end
35
83
  end
36
84
  end
@@ -0,0 +1,37 @@
1
+ require 'spec_helper'
2
+
3
+ require 'generators/two_factor_authentication/two_factor_authentication_generator'
4
+
5
+ describe TwoFactorAuthenticatable::Generators::TwoFactorAuthenticationGenerator, type: :generator do
6
+ destination File.expand_path('../../../tmp/generators', __dir__)
7
+
8
+ let(:model_path) { File.join(destination_root, 'app/models/account.rb') }
9
+
10
+ before do
11
+ prepare_destination
12
+ FileUtils.mkdir_p(File.dirname(model_path))
13
+ File.write(model_path, <<~RUBY)
14
+ class Account < ApplicationRecord
15
+ devise :database_authenticatable, :validatable
16
+ end
17
+ RUBY
18
+ end
19
+
20
+ it 'adds :two_factor_authenticatable to the devise call in the model' do
21
+ run_generator %w(account --orm=active_record)
22
+
23
+ expect(File.read(model_path)).to include('devise :two_factor_authenticatable, :database_authenticatable, :validatable')
24
+ end
25
+
26
+ it 'generates the migration through the orm hook' do
27
+ run_generator %w(account --orm=active_record)
28
+
29
+ expect(Pathname.new(migration_file('db/migrate/two_factor_authentication_add_to_accounts.rb'))).to exist
30
+ end
31
+
32
+ it 'does not fail when the model file is missing' do
33
+ File.delete(model_path)
34
+
35
+ expect { run_generator %w(account --orm=active_record) }.not_to raise_error
36
+ end
37
+ end