devise-multi-factor 3.1.5

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 (101) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +21 -0
  3. data/.github/workflows/gem-push.yml +42 -0
  4. data/.gitignore +23 -0
  5. data/.rubocop.yml +295 -0
  6. data/.travis.yml +28 -0
  7. data/CHANGELOG.md +119 -0
  8. data/Gemfile +32 -0
  9. data/LICENSE +19 -0
  10. data/README.md +322 -0
  11. data/Rakefile +12 -0
  12. data/app/controllers/devise/totp_controller.rb +79 -0
  13. data/app/controllers/devise/two_factor_authentication_controller.rb +84 -0
  14. data/app/views/devise/two_factor_authentication/max_login_attempts_reached.html.erb +3 -0
  15. data/app/views/devise/two_factor_authentication/new.html.erb +14 -0
  16. data/app/views/devise/two_factor_authentication/show.html.erb +19 -0
  17. data/config/locales/de.yml +8 -0
  18. data/config/locales/en.yml +8 -0
  19. data/config/locales/es.yml +8 -0
  20. data/config/locales/fr.yml +8 -0
  21. data/config/locales/ru.yml +8 -0
  22. data/devise-multi-factor.gemspec +40 -0
  23. data/lib/devise-multi-factor.rb +1 -0
  24. data/lib/devise_multi_factor.rb +56 -0
  25. data/lib/devise_multi_factor/controllers/helpers.rb +57 -0
  26. data/lib/devise_multi_factor/hooks/two_factor_authenticatable.rb +17 -0
  27. data/lib/devise_multi_factor/models/totp_enrollable.rb +7 -0
  28. data/lib/devise_multi_factor/models/two_factor_authenticatable.rb +142 -0
  29. data/lib/devise_multi_factor/orm/active_record.rb +14 -0
  30. data/lib/devise_multi_factor/rails.rb +7 -0
  31. data/lib/devise_multi_factor/routes.rb +15 -0
  32. data/lib/devise_multi_factor/schema.rb +23 -0
  33. data/lib/devise_multi_factor/version.rb +3 -0
  34. data/lib/generators/active_record/devise_multi_factor_generator.rb +13 -0
  35. data/lib/generators/active_record/templates/migration.rb +11 -0
  36. data/lib/generators/devise_multi_factor/devise_multi_factor_generator.rb +17 -0
  37. data/spec/controllers/two_factor_authentication_controller_spec.rb +41 -0
  38. data/spec/features/two_factor_authenticatable_spec.rb +237 -0
  39. data/spec/generators/active_record/devise_multi_factor_generator_spec.rb +34 -0
  40. data/spec/lib/devise_multi_factor/models/two_factor_authenticatable_spec.rb +282 -0
  41. data/spec/rails_app/.gitignore +3 -0
  42. data/spec/rails_app/README.md +3 -0
  43. data/spec/rails_app/Rakefile +7 -0
  44. data/spec/rails_app/app/assets/config/manifest.js +2 -0
  45. data/spec/rails_app/app/assets/javascripts/application.js +1 -0
  46. data/spec/rails_app/app/assets/stylesheets/application.css +4 -0
  47. data/spec/rails_app/app/controllers/application_controller.rb +3 -0
  48. data/spec/rails_app/app/controllers/home_controller.rb +10 -0
  49. data/spec/rails_app/app/helpers/application_helper.rb +8 -0
  50. data/spec/rails_app/app/mailers/.gitkeep +0 -0
  51. data/spec/rails_app/app/models/.gitkeep +0 -0
  52. data/spec/rails_app/app/models/admin.rb +6 -0
  53. data/spec/rails_app/app/models/encrypted_user.rb +7 -0
  54. data/spec/rails_app/app/models/guest_user.rb +7 -0
  55. data/spec/rails_app/app/models/test_user.rb +38 -0
  56. data/spec/rails_app/app/models/user.rb +18 -0
  57. data/spec/rails_app/app/views/home/dashboard.html.erb +11 -0
  58. data/spec/rails_app/app/views/home/index.html.erb +3 -0
  59. data/spec/rails_app/app/views/layouts/application.html.erb +20 -0
  60. data/spec/rails_app/config.ru +4 -0
  61. data/spec/rails_app/config/application.rb +61 -0
  62. data/spec/rails_app/config/boot.rb +10 -0
  63. data/spec/rails_app/config/database.yml +19 -0
  64. data/spec/rails_app/config/environment.rb +5 -0
  65. data/spec/rails_app/config/environments/development.rb +28 -0
  66. data/spec/rails_app/config/environments/production.rb +68 -0
  67. data/spec/rails_app/config/environments/test.rb +41 -0
  68. data/spec/rails_app/config/initializers/backtrace_silencers.rb +7 -0
  69. data/spec/rails_app/config/initializers/cookies_serializer.rb +3 -0
  70. data/spec/rails_app/config/initializers/devise.rb +258 -0
  71. data/spec/rails_app/config/initializers/inflections.rb +15 -0
  72. data/spec/rails_app/config/initializers/mime_types.rb +5 -0
  73. data/spec/rails_app/config/initializers/secret_token.rb +7 -0
  74. data/spec/rails_app/config/initializers/session_store.rb +8 -0
  75. data/spec/rails_app/config/initializers/wrap_parameters.rb +14 -0
  76. data/spec/rails_app/config/locales/devise.en.yml +59 -0
  77. data/spec/rails_app/config/locales/en.yml +5 -0
  78. data/spec/rails_app/config/routes.rb +65 -0
  79. data/spec/rails_app/db/migrate/20140403184646_devise_create_users.rb +42 -0
  80. data/spec/rails_app/db/migrate/20140407172619_two_factor_authentication_add_to_users.rb +17 -0
  81. data/spec/rails_app/db/migrate/20140407215513_add_nickanme_to_users.rb +7 -0
  82. data/spec/rails_app/db/migrate/20151224171231_add_encrypted_columns_to_user.rb +7 -0
  83. data/spec/rails_app/db/migrate/20151224180310_populate_otp_column.rb +19 -0
  84. data/spec/rails_app/db/migrate/20151228230340_remove_otp_secret_key_from_user.rb +5 -0
  85. data/spec/rails_app/db/migrate/20160209032439_devise_create_admins.rb +42 -0
  86. data/spec/rails_app/db/schema.rb +55 -0
  87. data/spec/rails_app/lib/assets/.gitkeep +0 -0
  88. data/spec/rails_app/lib/sms_provider.rb +17 -0
  89. data/spec/rails_app/public/404.html +26 -0
  90. data/spec/rails_app/public/422.html +26 -0
  91. data/spec/rails_app/public/500.html +25 -0
  92. data/spec/rails_app/public/favicon.ico +0 -0
  93. data/spec/rails_app/script/rails +6 -0
  94. data/spec/spec_helper.rb +26 -0
  95. data/spec/support/authenticated_model_helper.rb +29 -0
  96. data/spec/support/capybara.rb +3 -0
  97. data/spec/support/controller_helper.rb +16 -0
  98. data/spec/support/features_spec_helper.rb +42 -0
  99. data/spec/support/sms_provider.rb +5 -0
  100. data/spec/support/totp_helper.rb +11 -0
  101. metadata +315 -0
@@ -0,0 +1,14 @@
1
+ require "active_record"
2
+
3
+ module DeviseMultiFactor
4
+ module Orm
5
+ module ActiveRecord
6
+ module Schema
7
+ include DeviseMultiFactor::Schema
8
+ end
9
+ end
10
+ end
11
+ end
12
+
13
+ ActiveRecord::ConnectionAdapters::Table.send :include, DeviseMultiFactor::Orm::ActiveRecord::Schema
14
+ ActiveRecord::ConnectionAdapters::TableDefinition.send :include, DeviseMultiFactor::Orm::ActiveRecord::Schema
@@ -0,0 +1,7 @@
1
+ module DeviseMultiFactor
2
+ class Engine < ::Rails::Engine
3
+ ActiveSupport.on_load(:action_controller) do
4
+ include DeviseMultiFactor::Controllers::Helpers
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,15 @@
1
+ module ActionDispatch::Routing
2
+ class Mapper
3
+ protected
4
+
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
7
+ collection { get 'resend_code' }
8
+ end
9
+ end
10
+
11
+ def devise_totp(mapping, controllers)
12
+ resource :totp, only: [:new, :create, :show, :destroy], path: mapping.path_names[:totp], controller: controllers[:totp]
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,23 @@
1
+ module DeviseMultiFactor
2
+ module Schema
3
+ def second_factor_attempts_count
4
+ apply_devise_schema :second_factor_attempts_count, Integer, :default => 0
5
+ end
6
+
7
+ def encrypted_otp_secret_key
8
+ apply_devise_schema :encrypted_otp_secret_key, String
9
+ end
10
+
11
+ def direct_otp
12
+ apply_devise_schema :direct_otp, String
13
+ end
14
+
15
+ def direct_otp_sent_at
16
+ apply_devise_schema :direct_otp_sent_at, DateTime
17
+ end
18
+
19
+ def totp_timestamp
20
+ apply_devise_schema :totp_timestamp, Integer
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,3 @@
1
+ module DeviseMultiFactor
2
+ VERSION = "3.1.5".freeze
3
+ end
@@ -0,0 +1,13 @@
1
+ require 'rails/generators/active_record'
2
+
3
+ module ActiveRecord
4
+ module Generators
5
+ class DeviseMultiFactorGenerator < ActiveRecord::Generators::Base
6
+ source_root File.expand_path("../templates", __FILE__)
7
+
8
+ def copy_devise_multi_factor_migration
9
+ migration_template "migration.rb", "db/migrate/devise_multi_factor_add_to_#{table_name}.rb"
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,11 @@
1
+ class DeviseMultiFactorAddTo<%= table_name.camelize %> < ActiveRecord::Migration[6.1]
2
+ def change
3
+ change_table :<%= table_name %>, bulk: true do |t|
4
+ t.integer :second_factor_attempts_count, default: 0, null: false
5
+ t.string :encrypted_otp_secret_key
6
+ t.string :direct_otp
7
+ t.datetime :direct_otp_sent_at
8
+ t.integer :totp_timestamp
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,17 @@
1
+ module TwoFactorAuthenticatable
2
+ module Generators
3
+ class DeviseMultiFactorGenerator < Rails::Generators::NamedBase
4
+ namespace "devise_multi_factor"
5
+
6
+ desc "Adds :two_factor_authenticable directive in the given model. It also generates an active record migration."
7
+
8
+ def inject_devise_multi_factor_content
9
+ path = File.join("app", "models", "#{file_path}.rb")
10
+ inject_into_file(path, "two_factor_authenticatable, :", :after => "devise :") if File.exists?(path)
11
+ inject_into_file(path, "totp_enrollable, :", :after => "devise :") if File.exists?(path)
12
+ end
13
+
14
+ hook_for :orm
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,41 @@
1
+ require 'spec_helper'
2
+
3
+ describe Devise::TwoFactorAuthenticationController, type: :controller do
4
+ describe 'is_fully_authenticated? helper' do
5
+ def post_code(code)
6
+ if Rails::VERSION::MAJOR >= 5
7
+ post :update, params: { code: code }
8
+ else
9
+ post :update, code: code
10
+ end
11
+ end
12
+
13
+ before do
14
+ sign_in
15
+ end
16
+
17
+ context 'after user enters valid OTP code' do
18
+ it 'returns true' do
19
+ controller.current_user.send_new_otp
20
+ post_code controller.current_user.direct_otp
21
+ expect(subject.is_fully_authenticated?).to eq true
22
+ end
23
+ end
24
+
25
+ context 'when user has not entered any OTP yet' do
26
+ it 'returns false' do
27
+ get :show
28
+
29
+ expect(subject.is_fully_authenticated?).to eq false
30
+ end
31
+ end
32
+
33
+ context 'when user enters an invalid OTP' do
34
+ it 'returns false' do
35
+ post_code '12345'
36
+
37
+ expect(subject.is_fully_authenticated?).to eq false
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,237 @@
1
+ require 'spec_helper'
2
+ include AuthenticatedModelHelper
3
+
4
+ feature "User of two factor authentication" do
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)
11
+ end
12
+ end
13
+
14
+ it 'does not send an SMS before the user has signed in' do
15
+ expect(SMSProvider.messages).to be_empty
16
+ end
17
+
18
+ it 'sends code via SMS after sign in' do
19
+ visit new_user_session_path
20
+ complete_sign_in_form_for(user)
21
+
22
+ expect(page).to have_content 'Enter the code that was sent to you'
23
+
24
+ expect(SMSProvider.messages.size).to eq(1)
25
+ message = SMSProvider.last_message
26
+ expect(message.to).to eq(user.phone_number)
27
+ expect(message.body).to eq(user.reload.direct_otp)
28
+ end
29
+
30
+ it 'authenticates a valid OTP code' do
31
+ visit new_user_session_path
32
+ complete_sign_in_form_for(user)
33
+
34
+ expect(page).to have_content('You are signed in as Marissa')
35
+
36
+ fill_in 'code', with: SMSProvider.last_message.body
37
+ click_button 'Submit'
38
+
39
+ within('.flash.notice') do
40
+ expect(page).to have_content('Two factor authentication successful.')
41
+ end
42
+
43
+ expect(current_path).to eq root_path
44
+ end
45
+ end
46
+
47
+ it_behaves_like 'sends and authenticates code', create_user('not_encrypted')
48
+ it_behaves_like 'sends and authenticates code', create_user, 'encrypted'
49
+ end
50
+
51
+ scenario "must be logged in" do
52
+ visit user_two_factor_authentication_path
53
+
54
+ expect(page).to have_content("Welcome Home")
55
+ expect(page).to have_content("You are signed out")
56
+ end
57
+
58
+ context "when logged in" do
59
+ let(:user) { create_user }
60
+
61
+ background do
62
+ login_as user
63
+ end
64
+
65
+ scenario "is redirected to TFA when path requires authentication" do
66
+ visit dashboard_path + "?A=param%20a&B=param%20b"
67
+
68
+ expect(page).to_not have_content("Your Personal Dashboard")
69
+
70
+ fill_in "code", with: SMSProvider.last_message.body
71
+ click_button "Submit"
72
+
73
+ expect(page).to have_content("Your Personal Dashboard")
74
+ expect(page).to have_content("You are signed in as Marissa")
75
+ expect(page).to have_content("Param A is param a")
76
+ expect(page).to have_content("Param B is param b")
77
+ end
78
+
79
+ scenario "is locked out after max failed attempts" do
80
+ visit user_two_factor_authentication_path
81
+
82
+ max_attempts = User.max_login_attempts
83
+
84
+ max_attempts.times do
85
+ fill_in "code", with: "incorrect#{rand(100)}"
86
+ click_button "Submit"
87
+
88
+ within(".flash.alert") do
89
+ expect(page).to have_content("Attempt failed")
90
+ end
91
+ end
92
+
93
+ expect(page).to have_content("Access completely denied")
94
+ expect(page).to have_content("You are signed out")
95
+ end
96
+
97
+ scenario "cannot retry authentication after max attempts" do
98
+ user.update_attribute(:second_factor_attempts_count, User.max_login_attempts)
99
+
100
+ visit user_two_factor_authentication_path
101
+
102
+ expect(page).to have_content("Access completely denied")
103
+ expect(page).to have_content("You are signed out")
104
+ end
105
+
106
+ describe "rememberable TFA" do
107
+ before do
108
+ @original_remember_otp_session_for_seconds = User.remember_otp_session_for_seconds
109
+ User.remember_otp_session_for_seconds = 30.days
110
+ end
111
+
112
+ after do
113
+ User.remember_otp_session_for_seconds = @original_remember_otp_session_for_seconds
114
+ end
115
+
116
+ scenario "doesn't require TFA code again within 30 days" do
117
+ sms_sign_in
118
+
119
+ logout
120
+
121
+ login_as user
122
+ visit dashboard_path
123
+ expect(page).to have_content("Your Personal Dashboard")
124
+ expect(page).to have_content("You are signed in as Marissa")
125
+ end
126
+
127
+ scenario "requires TFA code again after 30 days" do
128
+ sms_sign_in
129
+
130
+ logout
131
+
132
+ Timecop.travel(30.days.from_now)
133
+ login_as user
134
+ visit dashboard_path
135
+ expect(page).to have_content("You are signed in as Marissa")
136
+ expect(page).to have_content("Enter the code that was sent to you")
137
+ end
138
+
139
+ scenario 'TFA should be different for different users' do
140
+ sms_sign_in
141
+
142
+ tfa_cookie1 = get_tfa_cookie()
143
+
144
+ logout
145
+ reset_session!
146
+
147
+ user2 = create_user()
148
+ login_as(user2)
149
+ sms_sign_in
150
+
151
+ tfa_cookie2 = get_tfa_cookie()
152
+
153
+ expect(tfa_cookie1).not_to eq tfa_cookie2
154
+ end
155
+
156
+ def sms_sign_in
157
+ SMSProvider.messages.clear()
158
+ visit user_two_factor_authentication_path
159
+ fill_in 'code', with: SMSProvider.last_message.body
160
+ click_button 'Submit'
161
+ end
162
+
163
+ scenario 'TFA should be unique for specific user' do
164
+ sms_sign_in
165
+
166
+ tfa_cookie1 = get_tfa_cookie()
167
+
168
+ logout
169
+ reset_session!
170
+
171
+ user2 = create_user()
172
+ set_tfa_cookie(tfa_cookie1)
173
+ login_as(user2)
174
+ visit dashboard_path
175
+ expect(page).to have_content("Enter the code that was sent to you")
176
+ end
177
+
178
+ scenario 'Delete cookie when user logs out if enabled' do
179
+ user.class.delete_cookie_on_logout = true
180
+
181
+ login_as user
182
+ logout
183
+
184
+ login_as user
185
+
186
+ visit dashboard_path
187
+ expect(page).to have_content("Enter the code that was sent to you")
188
+ end
189
+ end
190
+
191
+ it 'sets the warden session need_two_factor_authentication key to true' do
192
+ session_hash = { 'need_two_factor_authentication' => true }
193
+
194
+ expect(page.get_rack_session_key('warden.user.user.session')).to eq session_hash
195
+ end
196
+ end
197
+
198
+ describe 'signing in' do
199
+ let(:user) { create_user }
200
+ let(:admin) { create_admin }
201
+
202
+ scenario 'user signs is' do
203
+ visit new_user_session_path
204
+ complete_sign_in_form_for(user)
205
+
206
+ expect(page).to have_content('Signed in successfully.')
207
+ end
208
+
209
+ scenario 'admin signs in' do
210
+ visit new_admin_session_path
211
+ complete_sign_in_form_for(admin)
212
+
213
+ expect(page).to have_content('Signed in successfully.')
214
+ end
215
+ end
216
+
217
+ describe 'signing out' do
218
+ let(:user) { create_user }
219
+ let(:admin) { create_admin }
220
+
221
+ scenario 'user signs out' do
222
+ visit new_user_session_path
223
+ complete_sign_in_form_for(user)
224
+ visit destroy_user_session_path
225
+
226
+ expect(page).to have_content('Signed out successfully.')
227
+ end
228
+
229
+ scenario 'admin signs out' do
230
+ visit new_admin_session_path
231
+ complete_sign_in_form_for(admin)
232
+ visit destroy_admin_session_path
233
+
234
+ expect(page).to have_content('Signed out successfully.')
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,34 @@
1
+ require 'spec_helper'
2
+
3
+ require 'generators/active_record/devise_multi_factor_generator'
4
+
5
+ describe ActiveRecord::Generators::DeviseMultiFactorGenerator, type: :generator do
6
+ destination File.expand_path('../../../../../tmp', __FILE__)
7
+
8
+ before do
9
+ prepare_destination
10
+ end
11
+
12
+ it 'runs all methods in the generator' do
13
+ gen = generator %w(users)
14
+ expect(gen).to receive(:copy_devise_multi_factor_migration)
15
+ gen.invoke_all
16
+ end
17
+
18
+ describe 'the generated files' do
19
+ before do
20
+ run_generator %w(users)
21
+ end
22
+
23
+ describe 'the migration' do
24
+ subject { migration_file('db/migrate/devise_multi_factor_add_to_users.rb') }
25
+
26
+ it { is_expected.to exist }
27
+ it { is_expected.to be_a_migration }
28
+ it { is_expected.to contain /def change/ }
29
+ it { is_expected.to contain /def change_table :user, bulk: true do |t|/ }
30
+ it { is_expected.to contain /t.integer :second_factor_attempts_count, default: 0, null: false/ }
31
+ it { is_expected.to contain /t.string :encrypted_otp_secret_key/ }
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,282 @@
1
+ require 'spec_helper'
2
+ include AuthenticatedModelHelper
3
+
4
+ describe Devise::Models::TwoFactorAuthenticatable do
5
+ describe '#create_direct_otp' do
6
+ let(:instance) { build_guest_user }
7
+
8
+ it 'set direct_otp field' do
9
+ expect(instance.direct_otp).to be_nil
10
+ instance.create_direct_otp
11
+ expect(instance.direct_otp).not_to be_nil
12
+ end
13
+
14
+ it 'set direct_otp_send_at field to current time' do
15
+ Timecop.freeze() do
16
+ instance.create_direct_otp
17
+ expect(instance.direct_otp_sent_at).to eq(Time.now)
18
+ end
19
+ end
20
+
21
+ it 'honors .direct_otp_length' do
22
+ expect(instance.class).to receive(:direct_otp_length).and_return(10)
23
+ instance.create_direct_otp
24
+ expect(instance.direct_otp.length).to equal(10)
25
+
26
+ expect(instance.class).to receive(:direct_otp_length).and_return(6)
27
+ instance.create_direct_otp
28
+ expect(instance.direct_otp.length).to equal(6)
29
+ end
30
+
31
+ it "honors 'direct_otp_length' in options paramater" do
32
+ instance.create_direct_otp(length: 8)
33
+ expect(instance.direct_otp.length).to equal(8)
34
+ instance.create_direct_otp(length: 10)
35
+ expect(instance.direct_otp.length).to equal(10)
36
+ end
37
+ end
38
+
39
+ describe '#authenticate_direct_otp' do
40
+ let(:instance) { build_guest_user }
41
+ it 'fails if no direct_otp has been set' do
42
+ expect(instance.authenticate_direct_otp('12345')).to eq(false)
43
+ end
44
+
45
+ context 'after generating an OTP' do
46
+ before :each do
47
+ instance.create_direct_otp
48
+ end
49
+
50
+ it 'accepts correct OTP' do
51
+ Timecop.freeze(Time.now + instance.class.direct_otp_valid_for - 1.second)
52
+ expect(instance.authenticate_direct_otp(instance.direct_otp)).to eq(true)
53
+ end
54
+
55
+ it 'rejects invalid OTP' do
56
+ Timecop.freeze(Time.now + instance.class.direct_otp_valid_for - 1.second)
57
+ expect(instance.authenticate_direct_otp('12340')).to eq(false)
58
+ end
59
+
60
+ it 'rejects expired OTP' do
61
+ Timecop.freeze(Time.now + instance.class.direct_otp_valid_for + 1.second)
62
+ expect(instance.authenticate_direct_otp(instance.direct_otp)).to eq(false)
63
+ end
64
+
65
+ it 'prevents code re-use' do
66
+ expect(instance.authenticate_direct_otp(instance.direct_otp)).to eq(true)
67
+ expect(instance.authenticate_direct_otp(instance.direct_otp)).to eq(false)
68
+ end
69
+ end
70
+ end
71
+
72
+ describe '#authenticate_totp' do
73
+ shared_examples 'authenticate_totp' do |instance|
74
+ before :each do
75
+ instance.otp_secret_key = '2z6hxkdwi3uvrnpn'
76
+ instance.totp_timestamp = nil
77
+ @totp_helper = TotpHelper.new(instance.otp_secret_key, instance.class.otp_length)
78
+ end
79
+
80
+ def do_invoke(code, user)
81
+ user.authenticate_totp(code)
82
+ end
83
+
84
+ it 'authenticates a recently created code' do
85
+ code = @totp_helper.totp_code
86
+ expect(do_invoke(code, instance)).to eq(true)
87
+ end
88
+
89
+ it 'authenticates a code entered with a space' do
90
+ code = @totp_helper.totp_code.insert(3, ' ')
91
+ expect(do_invoke(code, instance)).to eq(true)
92
+ end
93
+
94
+ it 'does not authenticate an old code' do
95
+ code = @totp_helper.totp_code(1.minutes.ago.to_i)
96
+ expect(do_invoke(code, instance)).to eq(false)
97
+ end
98
+
99
+ it 'prevents code reuse' do
100
+ code = @totp_helper.totp_code
101
+ expect(do_invoke(code, instance)).to eq(true)
102
+ expect(do_invoke(code, instance)).to eq(false)
103
+ end
104
+ end
105
+
106
+ it_behaves_like 'authenticate_totp', GuestUser.new
107
+ it_behaves_like 'authenticate_totp', EncryptedUser.new
108
+ end
109
+
110
+ describe '#send_two_factor_authentication_code' do
111
+ let(:instance) { build_guest_user }
112
+
113
+ it 'raises an error by default' do
114
+ expect { instance.send_two_factor_authentication_code(123) }.
115
+ to raise_error(NotImplementedError)
116
+ end
117
+
118
+ it 'is overrideable' do
119
+ def instance.send_two_factor_authentication_code(code)
120
+ 'Code sent'
121
+ end
122
+ expect(instance.send_two_factor_authentication_code(123)).to eq('Code sent')
123
+ end
124
+ end
125
+
126
+ describe '#provisioning_uri' do
127
+
128
+ shared_examples 'provisioning_uri' do |instance|
129
+ it 'fails until generate_totp_secret is called' do
130
+ expect { instance.provisioning_uri }.to raise_error(Exception)
131
+ end
132
+
133
+ describe 'with secret set' do
134
+ before do
135
+ instance.email = 'houdini@example.com'
136
+ instance.otp_secret_key = instance.generate_totp_secret
137
+ end
138
+
139
+ it "returns uri with user's email" do
140
+ expect(instance.provisioning_uri).
141
+ to match(%r{otpauth://totp/houdini%40example.com\?secret=\w{32}})
142
+ end
143
+
144
+ it 'returns uri with issuer option' do
145
+ expect(instance.provisioning_uri('houdini')).
146
+ to match(%r{otpauth://totp/houdini\?secret=\w{32}$})
147
+ end
148
+
149
+ it 'returns uri with issuer option' do
150
+ require 'cgi'
151
+ uri = URI.parse(instance.provisioning_uri('houdini', issuer: 'Magic'))
152
+ params = CGI.parse(uri.query)
153
+
154
+ expect(uri.scheme).to eq('otpauth')
155
+ expect(uri.host).to eq('totp')
156
+ expect(uri.path).to eq('/Magic:houdini')
157
+ expect(params['issuer'].shift).to eq('Magic')
158
+ expect(params['secret'].shift).to match(/\w{32}/)
159
+ end
160
+ end
161
+ end
162
+
163
+ it_behaves_like 'provisioning_uri', GuestUser.new
164
+ it_behaves_like 'provisioning_uri', EncryptedUser.new
165
+ end
166
+
167
+ describe '#generate_totp_secret' do
168
+ shared_examples 'generate_totp_secret' do |klass|
169
+ let(:instance) { klass.new }
170
+
171
+ it 'returns a 32 character string' do
172
+ secret = instance.generate_totp_secret
173
+
174
+ expect(secret).to match(/\w{32}/)
175
+ end
176
+ end
177
+
178
+ it_behaves_like 'generate_totp_secret', GuestUser
179
+ it_behaves_like 'generate_totp_secret', EncryptedUser
180
+ end
181
+
182
+ describe '#enroll_totp!' do
183
+ shared_examples 'enroll_totp!' do |klass|
184
+ let(:instance) { klass.new }
185
+ let(:secret) { instance.generate_totp_secret }
186
+ let(:totp_helper) { TotpHelper.new(secret, instance.class.otp_length) }
187
+
188
+ describe 'when given correct code' do
189
+ it 'populates otp_secret_key column' do
190
+ instance.enroll_totp!(secret, totp_helper.totp_code)
191
+
192
+ expect(instance.otp_secret_key).to match(secret)
193
+ end
194
+
195
+ it 'updates the encrypted_otp_secret_key and otp totp_timestamp' do
196
+ allow(instance).to receive(:update_columns).and_return(true)
197
+ allow_any_instance_of(ROTP::TOTP).to receive(:verify).and_return(15445051)
198
+
199
+ instance.enroll_totp!(secret, totp_helper.totp_code)
200
+
201
+ expect(instance).to have_received(:update_columns)
202
+ .with(totp_timestamp: 15445051, otp_secret_key: secret)
203
+ end
204
+
205
+ it 'returns true' do
206
+ expect(instance.enroll_totp!(secret, totp_helper.totp_code)).to be true
207
+ end
208
+ end
209
+
210
+ describe 'when given incorrect code' do
211
+ it 'does not populate otp_secret_key' do
212
+ instance.enroll_totp!(secret, '123')
213
+ expect(instance.otp_secret_key).to be_nil
214
+ end
215
+
216
+ it 'returns false' do
217
+ expect(instance.enroll_totp!(secret, '123')).to be false
218
+ end
219
+ end
220
+ end
221
+
222
+ it_behaves_like 'enroll_totp!', GuestUser
223
+ it_behaves_like 'enroll_totp!', EncryptedUser
224
+ end
225
+
226
+ describe '#max_login_attempts' do
227
+ let(:instance) { build_guest_user }
228
+
229
+ before do
230
+ @original_max_login_attempts = GuestUser.max_login_attempts
231
+ GuestUser.max_login_attempts = 3
232
+ end
233
+
234
+ after { GuestUser.max_login_attempts = @original_max_login_attempts }
235
+
236
+ it 'returns class setting' do
237
+ expect(instance.max_login_attempts).to eq(3)
238
+ end
239
+
240
+ it 'returns false as boolean' do
241
+ instance.second_factor_attempts_count = nil
242
+ expect(instance.max_login_attempts?).to be_falsey
243
+ instance.second_factor_attempts_count = 0
244
+ expect(instance.max_login_attempts?).to be_falsey
245
+ instance.second_factor_attempts_count = 1
246
+ expect(instance.max_login_attempts?).to be_falsey
247
+ instance.second_factor_attempts_count = 2
248
+ expect(instance.max_login_attempts?).to be_falsey
249
+ end
250
+
251
+ it 'returns true as boolean after too many attempts' do
252
+ instance.second_factor_attempts_count = 3
253
+ expect(instance.max_login_attempts?).to be_truthy
254
+ instance.second_factor_attempts_count = 4
255
+ expect(instance.max_login_attempts?).to be_truthy
256
+ end
257
+ end
258
+
259
+ describe '.has_one_time_password' do
260
+ context 'when encrypted: true option is passed' do
261
+ let(:instance) { EncryptedUser.new }
262
+
263
+ it 'encrypts otp_secret_key' do
264
+ instance.otp_secret_key = '2z6hxkdwi3uvrnpn'
265
+
266
+ expect(instance.encrypted_otp_secret_key).to match(/.{44}/)
267
+ end
268
+
269
+ it 'does not encrypt a nil otp_secret_key' do
270
+ instance.otp_secret_key = nil
271
+
272
+ expect(instance.encrypted_otp_secret_key).to be_nil
273
+ end
274
+
275
+ it 'does not encrypt an empty otp_secret_key' do
276
+ instance.otp_secret_key = ''
277
+
278
+ expect(instance.encrypted_otp_secret_key).to eq ''
279
+ end
280
+ end
281
+ end
282
+ end