two_factor_authentication 2.2.0 → 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.
- checksums.yaml +5 -5
- data/.github/workflows/ci.yml +77 -0
- data/.gitignore +3 -0
- data/CHANGELOG.md +37 -2
- data/Gemfile +29 -8
- data/MIGRATION_GUIDE.md +143 -0
- data/README.md +145 -93
- data/app/controllers/devise/two_factor_authentication_controller.rb +3 -2
- data/app/views/devise/two_factor_authentication/show.html.erb +4 -4
- data/config/locales/de.yml +8 -0
- data/lib/generators/active_record/templates/migration.rb +9 -1
- data/lib/generators/active_record/two_factor_authentication_generator.rb +18 -0
- data/lib/generators/two_factor_authentication/two_factor_authentication_generator.rb +1 -1
- data/lib/two_factor_authentication/controllers/helpers.rb +7 -3
- data/lib/two_factor_authentication/models/two_factor_authenticatable.rb +8 -5
- data/lib/two_factor_authentication/routes.rb +1 -1
- data/lib/two_factor_authentication/schema.rb +7 -7
- data/lib/two_factor_authentication/version.rb +1 -1
- data/spec/controllers/two_factor_authentication_controller_spec.rb +84 -7
- data/spec/features/two_factor_authenticatable_spec.rb +119 -14
- data/spec/generators/active_record/two_factor_authentication_generator_spec.rb +52 -4
- data/spec/generators/two_factor_authentication/two_factor_authentication_generator_spec.rb +37 -0
- data/spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb +89 -7
- data/spec/lib/two_factor_authentication/schema_spec.rb +41 -0
- data/spec/rails_app/app/assets/config/manifest.js +2 -0
- data/spec/rails_app/app/controllers/home_controller.rb +6 -1
- data/spec/rails_app/app/models/guest_user.rb +1 -1
- data/spec/rails_app/app/models/secure_user.rb +13 -0
- data/spec/rails_app/config/application.rb +1 -1
- data/spec/rails_app/config/environments/test.rb +3 -8
- data/spec/rails_app/config/initializers/inflections.rb +4 -0
- data/spec/rails_app/config/routes.rb +2 -0
- data/spec/rails_app/db/migrate/20260929000000_add_otp_columns_to_users.rb +8 -0
- data/spec/rails_app/db/migrate/20260929000001_devise_create_secure_users.rb +22 -0
- data/spec/rails_app/db/schema.rb +26 -8
- data/spec/rails_app/lib/sms_provider.rb +2 -4
- data/spec/requests/two_factor_authentication_helpers_spec.rb +48 -0
- data/spec/spec_helper.rb +1 -0
- data/spec/support/authenticated_model_helper.rb +5 -30
- data/spec/support/controller_helper.rb +5 -4
- data/spec/support/features_spec_helper.rb +1 -0
- data/two_factor_authentication.gemspec +4 -5
- metadata +43 -26
- data/.travis.yml +0 -28
- data/spec/rails_app/config/initializers/secret_token.rb +0 -7
|
@@ -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 |
|
|
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
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
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.
|
|
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 /
|
|
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
|
|
@@ -137,8 +137,13 @@ describe Devise::Models::TwoFactorAuthenticatable do
|
|
|
137
137
|
end
|
|
138
138
|
|
|
139
139
|
it "returns uri with user's email" do
|
|
140
|
-
|
|
141
|
-
|
|
140
|
+
uri = URI.parse(instance.provisioning_uri)
|
|
141
|
+
params = URI.decode_www_form(uri.query).to_h
|
|
142
|
+
|
|
143
|
+
expect(uri.scheme).to eq('otpauth')
|
|
144
|
+
expect(uri.host).to eq('totp')
|
|
145
|
+
expect(URI.decode_www_form_component(uri.path)).to eq('/houdini@example.com')
|
|
146
|
+
expect(params['secret']).to match(/\w{32}/)
|
|
142
147
|
end
|
|
143
148
|
|
|
144
149
|
it 'returns uri with issuer option' do
|
|
@@ -147,15 +152,14 @@ describe Devise::Models::TwoFactorAuthenticatable do
|
|
|
147
152
|
end
|
|
148
153
|
|
|
149
154
|
it 'returns uri with issuer option' do
|
|
150
|
-
require 'cgi'
|
|
151
155
|
uri = URI.parse(instance.provisioning_uri('houdini', issuer: 'Magic'))
|
|
152
|
-
params =
|
|
156
|
+
params = URI.decode_www_form(uri.query).to_h
|
|
153
157
|
|
|
154
158
|
expect(uri.scheme).to eq('otpauth')
|
|
155
159
|
expect(uri.host).to eq('totp')
|
|
156
|
-
expect(uri.path).to eq('/Magic:houdini')
|
|
157
|
-
expect(params['issuer']
|
|
158
|
-
expect(params['secret']
|
|
160
|
+
expect(URI.decode_www_form_component(uri.path)).to eq('/Magic:houdini')
|
|
161
|
+
expect(params['issuer']).to eq('Magic')
|
|
162
|
+
expect(params['secret']).to match(/\w{32}/)
|
|
159
163
|
end
|
|
160
164
|
end
|
|
161
165
|
end
|
|
@@ -323,4 +327,82 @@ describe Devise::Models::TwoFactorAuthenticatable do
|
|
|
323
327
|
end
|
|
324
328
|
end
|
|
325
329
|
end
|
|
330
|
+
|
|
331
|
+
context 'with an ActiveRecord model' do
|
|
332
|
+
let(:user) { create_user }
|
|
333
|
+
|
|
334
|
+
describe '#create_direct_otp' do
|
|
335
|
+
it 'persists the code and the time it was sent' do
|
|
336
|
+
Timecop.freeze do
|
|
337
|
+
user.create_direct_otp
|
|
338
|
+
user.reload
|
|
339
|
+
|
|
340
|
+
expect(user.direct_otp).to match(/\A\d{6}\z/)
|
|
341
|
+
expect(user.direct_otp_sent_at).to be_within(1.second).of(Time.now)
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
describe '#authenticate_direct_otp' do
|
|
347
|
+
it 'clears the persisted code after a successful match' do
|
|
348
|
+
user.create_direct_otp
|
|
349
|
+
code = user.reload.direct_otp
|
|
350
|
+
|
|
351
|
+
expect(user.authenticate_direct_otp(code)).to eq(true)
|
|
352
|
+
user.reload
|
|
353
|
+
expect(user.direct_otp).to be_nil
|
|
354
|
+
expect(user.direct_otp_sent_at).to be_nil
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
describe '#send_new_otp' do
|
|
359
|
+
it 'delivers the persisted code' do
|
|
360
|
+
user.send_new_otp
|
|
361
|
+
|
|
362
|
+
expect(SMSProvider.last_message.body).to eq(user.reload.direct_otp)
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
describe '#authenticate_totp' do
|
|
367
|
+
shared_examples 'persisted totp' do
|
|
368
|
+
let(:secret) { record.generate_totp_secret }
|
|
369
|
+
|
|
370
|
+
before do
|
|
371
|
+
record.otp_secret_key = secret
|
|
372
|
+
record.save!
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
it 'stores the last used time step as a time' do
|
|
376
|
+
expect(record.authenticate_totp(ROTP::TOTP.new(secret).now)).to eq(true)
|
|
377
|
+
record.save!
|
|
378
|
+
|
|
379
|
+
expect(record.reload.totp_timestamp).to be_a(Time)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
it 'rejects a reused code after the record is reloaded' do
|
|
383
|
+
code = ROTP::TOTP.new(secret).now
|
|
384
|
+
expect(record.authenticate_totp(code)).to eq(true)
|
|
385
|
+
record.save!
|
|
386
|
+
|
|
387
|
+
expect(record.class.find(record.id).authenticate_totp(code)).to eq(false)
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
context 'with a plain secret' do
|
|
392
|
+
let(:record) { user }
|
|
393
|
+
include_examples 'persisted totp'
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
context 'with an encrypted secret' do
|
|
397
|
+
let(:record) { create_secure_user }
|
|
398
|
+
include_examples 'persisted totp'
|
|
399
|
+
|
|
400
|
+
it 'keeps the secret encrypted in the database' do
|
|
401
|
+
expect(record.reload.encrypted_otp_secret_key).to be_present
|
|
402
|
+
expect(record.encrypted_otp_secret_key).not_to include(secret)
|
|
403
|
+
expect(record.otp_secret_key).to eq(secret)
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
end
|
|
326
408
|
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
describe TwoFactorAuthentication::Schema do
|
|
4
|
+
let(:connection) { ActiveRecord::Base.connection }
|
|
5
|
+
|
|
6
|
+
after { connection.drop_table(:schema_helper_users, if_exists: true) }
|
|
7
|
+
|
|
8
|
+
def columns
|
|
9
|
+
connection.columns(:schema_helper_users).index_by(&:name)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it 'adds every column in create_table' do
|
|
13
|
+
connection.create_table(:schema_helper_users) do |t|
|
|
14
|
+
t.second_factor_attempts_count
|
|
15
|
+
t.encrypted_otp_secret_key
|
|
16
|
+
t.encrypted_otp_secret_key_iv
|
|
17
|
+
t.encrypted_otp_secret_key_salt
|
|
18
|
+
t.direct_otp
|
|
19
|
+
t.direct_otp_sent_at
|
|
20
|
+
t.totp_timestamp
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
expect(columns['second_factor_attempts_count'].type).to eq(:integer)
|
|
24
|
+
expect(columns['second_factor_attempts_count'].default.to_i).to eq(0)
|
|
25
|
+
%w(encrypted_otp_secret_key encrypted_otp_secret_key_iv encrypted_otp_secret_key_salt direct_otp).each do |name|
|
|
26
|
+
expect(columns[name].type).to eq(:string)
|
|
27
|
+
end
|
|
28
|
+
expect(columns['direct_otp_sent_at'].type).to eq(:datetime)
|
|
29
|
+
expect(columns['totp_timestamp'].type).to eq(:datetime)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'adds columns in change_table' do
|
|
33
|
+
connection.create_table(:schema_helper_users)
|
|
34
|
+
connection.change_table(:schema_helper_users) do |t|
|
|
35
|
+
t.second_factor_attempts_count
|
|
36
|
+
t.totp_timestamp
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
expect(columns.keys).to include('second_factor_attempts_count', 'totp_timestamp')
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
class HomeController < ApplicationController
|
|
2
|
-
before_action :authenticate_user!, only: :dashboard
|
|
2
|
+
before_action :authenticate_user!, only: [:dashboard, :secret]
|
|
3
3
|
|
|
4
4
|
def index
|
|
5
5
|
end
|
|
@@ -7,4 +7,9 @@ class HomeController < ApplicationController
|
|
|
7
7
|
def dashboard
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
+
# Responds to any format, to prove the 2FA check halts non-HTML/JSON requests
|
|
11
|
+
def secret
|
|
12
|
+
render plain: 'top secret'
|
|
13
|
+
end
|
|
14
|
+
|
|
10
15
|
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
class SecureUser < ActiveRecord::Base
|
|
2
|
+
devise :two_factor_authenticatable, :database_authenticatable, :validatable
|
|
3
|
+
|
|
4
|
+
has_one_time_password(encrypted: true)
|
|
5
|
+
|
|
6
|
+
def send_two_factor_authentication_code(code)
|
|
7
|
+
SMSProvider.send_message(to: phone_number, body: code)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def phone_number
|
|
11
|
+
'14155550000'
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -9,14 +9,8 @@ Dummy::Application.configure do
|
|
|
9
9
|
config.eager_load = false
|
|
10
10
|
|
|
11
11
|
# Configure static asset server for tests with Cache-Control for performance
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
config.serve_static_files = true
|
|
15
|
-
else
|
|
16
|
-
config.serve_static_assets = true
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
config.static_cache_control = "public, max-age=3600"
|
|
12
|
+
config.public_file_server.enabled = true
|
|
13
|
+
config.public_file_server.headers = { "Cache-Control" => "public, max-age=3600" }
|
|
20
14
|
|
|
21
15
|
# Show full error reports and disable caching
|
|
22
16
|
config.consider_all_requests_local = true
|
|
@@ -38,4 +32,5 @@ Dummy::Application.configure do
|
|
|
38
32
|
|
|
39
33
|
# For testing session variables in Capybara specs
|
|
40
34
|
config.middleware.use RackSessionAccess::Middleware
|
|
35
|
+
config.assets.check_precompiled_asset = false
|
|
41
36
|
end
|
|
@@ -3,8 +3,10 @@ Dummy::Application.routes.draw do
|
|
|
3
3
|
root to: "home#index"
|
|
4
4
|
|
|
5
5
|
match "/dashboard", to: "home#dashboard", as: :dashboard, via: [:get]
|
|
6
|
+
get "/secret", to: "home#secret", as: :secret
|
|
6
7
|
|
|
7
8
|
devise_for :users
|
|
9
|
+
devise_for :secure_users
|
|
8
10
|
|
|
9
11
|
# The priority is based upon order of creation:
|
|
10
12
|
# first created -> highest priority.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
class AddOtpColumnsToUsers < ActiveRecord::Migration[5.0]
|
|
2
|
+
def change
|
|
3
|
+
add_column :users, :otp_secret_key, :string
|
|
4
|
+
add_column :users, :direct_otp, :string
|
|
5
|
+
add_column :users, :direct_otp_sent_at, :datetime
|
|
6
|
+
add_column :users, :totp_timestamp, :timestamp
|
|
7
|
+
end
|
|
8
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# A second 2FA scope whose TOTP secret is encrypted.
|
|
2
|
+
class DeviseCreateSecureUsers < ActiveRecord::Migration[5.0]
|
|
3
|
+
def change
|
|
4
|
+
create_table(:secure_users) do |t|
|
|
5
|
+
t.string :email, null: false, default: ""
|
|
6
|
+
t.string :encrypted_password, null: false, default: ""
|
|
7
|
+
|
|
8
|
+
t.integer :second_factor_attempts_count, default: 0
|
|
9
|
+
t.string :encrypted_otp_secret_key
|
|
10
|
+
t.string :encrypted_otp_secret_key_iv
|
|
11
|
+
t.string :encrypted_otp_secret_key_salt
|
|
12
|
+
t.string :direct_otp
|
|
13
|
+
t.datetime :direct_otp_sent_at
|
|
14
|
+
t.timestamp :totp_timestamp
|
|
15
|
+
|
|
16
|
+
t.timestamps null: false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
add_index :secure_users, :email, unique: true
|
|
20
|
+
add_index :secure_users, :encrypted_otp_secret_key, unique: true
|
|
21
|
+
end
|
|
22
|
+
end
|
data/spec/rails_app/db/schema.rb
CHANGED
|
@@ -2,16 +2,15 @@
|
|
|
2
2
|
# of editing this file, please use the migrations feature of Active Record to
|
|
3
3
|
# incrementally modify your database, and then regenerate this schema definition.
|
|
4
4
|
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# from scratch.
|
|
9
|
-
#
|
|
5
|
+
# This file is the source Rails uses to define your schema when running `bin/rails
|
|
6
|
+
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
|
|
7
|
+
# be faster and is potentially less error prone than running all of your
|
|
8
|
+
# migrations from scratch. Old migrations may fail to apply correctly if those
|
|
9
|
+
# migrations use external dependencies or application code.
|
|
10
10
|
#
|
|
11
11
|
# It's strongly recommended that you check this file into your version control system.
|
|
12
12
|
|
|
13
|
-
ActiveRecord::Schema.define(version:
|
|
14
|
-
|
|
13
|
+
ActiveRecord::Schema.define(version: 2026_09_29_000001) do
|
|
15
14
|
create_table "admins", force: :cascade do |t|
|
|
16
15
|
t.string "email", default: "", null: false
|
|
17
16
|
t.string "encrypted_password", default: "", null: false
|
|
@@ -29,6 +28,22 @@ ActiveRecord::Schema.define(version: 2016_02_09_032439) do
|
|
|
29
28
|
t.index ["reset_password_token"], name: "index_admins_on_reset_password_token", unique: true
|
|
30
29
|
end
|
|
31
30
|
|
|
31
|
+
create_table "secure_users", force: :cascade do |t|
|
|
32
|
+
t.string "email", default: "", null: false
|
|
33
|
+
t.string "encrypted_password", default: "", null: false
|
|
34
|
+
t.integer "second_factor_attempts_count", default: 0
|
|
35
|
+
t.string "encrypted_otp_secret_key"
|
|
36
|
+
t.string "encrypted_otp_secret_key_iv"
|
|
37
|
+
t.string "encrypted_otp_secret_key_salt"
|
|
38
|
+
t.string "direct_otp"
|
|
39
|
+
t.datetime "direct_otp_sent_at"
|
|
40
|
+
t.datetime "totp_timestamp"
|
|
41
|
+
t.datetime "created_at", null: false
|
|
42
|
+
t.datetime "updated_at", null: false
|
|
43
|
+
t.index ["email"], name: "index_secure_users_on_email", unique: true
|
|
44
|
+
t.index ["encrypted_otp_secret_key"], name: "index_secure_users_on_encrypted_otp_secret_key", unique: true
|
|
45
|
+
end
|
|
46
|
+
|
|
32
47
|
create_table "users", force: :cascade do |t|
|
|
33
48
|
t.string "email", default: "", null: false
|
|
34
49
|
t.string "encrypted_password", default: "", null: false
|
|
@@ -47,9 +62,12 @@ ActiveRecord::Schema.define(version: 2016_02_09_032439) do
|
|
|
47
62
|
t.string "encrypted_otp_secret_key"
|
|
48
63
|
t.string "encrypted_otp_secret_key_iv"
|
|
49
64
|
t.string "encrypted_otp_secret_key_salt"
|
|
65
|
+
t.string "otp_secret_key"
|
|
66
|
+
t.string "direct_otp"
|
|
67
|
+
t.datetime "direct_otp_sent_at"
|
|
68
|
+
t.datetime "totp_timestamp"
|
|
50
69
|
t.index ["email"], name: "index_users_on_email", unique: true
|
|
51
70
|
t.index ["encrypted_otp_secret_key"], name: "index_users_on_encrypted_otp_secret_key", unique: true
|
|
52
71
|
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
|
53
72
|
end
|
|
54
|
-
|
|
55
73
|
end
|
|
@@ -1,13 +1,11 @@
|
|
|
1
|
-
require 'ostruct'
|
|
2
|
-
|
|
3
1
|
class SMSProvider
|
|
4
|
-
Message =
|
|
2
|
+
Message = Struct.new(:to, :body)
|
|
5
3
|
|
|
6
4
|
class_attribute :messages
|
|
7
5
|
self.messages = []
|
|
8
6
|
|
|
9
7
|
def self.send_message(opts = {})
|
|
10
|
-
self.messages << Message.new(opts)
|
|
8
|
+
self.messages << Message.new(opts[:to], opts[:body])
|
|
11
9
|
end
|
|
12
10
|
|
|
13
11
|
def self.last_message
|