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.
Files changed (45) 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 -8
  6. data/MIGRATION_GUIDE.md +143 -0
  7. data/README.md +145 -93
  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 +7 -3
  15. data/lib/two_factor_authentication/models/two_factor_authenticatable.rb +8 -5
  16. data/lib/two_factor_authentication/routes.rb +1 -1
  17. data/lib/two_factor_authentication/schema.rb +7 -7
  18. data/lib/two_factor_authentication/version.rb +1 -1
  19. data/spec/controllers/two_factor_authentication_controller_spec.rb +84 -7
  20. data/spec/features/two_factor_authenticatable_spec.rb +119 -14
  21. data/spec/generators/active_record/two_factor_authentication_generator_spec.rb +52 -4
  22. data/spec/generators/two_factor_authentication/two_factor_authentication_generator_spec.rb +37 -0
  23. data/spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb +89 -7
  24. data/spec/lib/two_factor_authentication/schema_spec.rb +41 -0
  25. data/spec/rails_app/app/assets/config/manifest.js +2 -0
  26. data/spec/rails_app/app/controllers/home_controller.rb +6 -1
  27. data/spec/rails_app/app/models/guest_user.rb +1 -1
  28. data/spec/rails_app/app/models/secure_user.rb +13 -0
  29. data/spec/rails_app/config/application.rb +1 -1
  30. data/spec/rails_app/config/environments/test.rb +3 -8
  31. data/spec/rails_app/config/initializers/inflections.rb +4 -0
  32. data/spec/rails_app/config/routes.rb +2 -0
  33. data/spec/rails_app/db/migrate/20260929000000_add_otp_columns_to_users.rb +8 -0
  34. data/spec/rails_app/db/migrate/20260929000001_devise_create_secure_users.rb +22 -0
  35. data/spec/rails_app/db/schema.rb +26 -8
  36. data/spec/rails_app/lib/sms_provider.rb +2 -4
  37. data/spec/requests/two_factor_authentication_helpers_spec.rb +48 -0
  38. data/spec/spec_helper.rb +1 -0
  39. data/spec/support/authenticated_model_helper.rb +5 -30
  40. data/spec/support/controller_helper.rb +5 -4
  41. data/spec/support/features_spec_helper.rb +1 -0
  42. data/two_factor_authentication.gemspec +4 -5
  43. metadata +43 -26
  44. data/.travis.yml +0 -28
  45. 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 |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
@@ -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
- expect(instance.provisioning_uri).
141
- to match(%r{otpauth://totp/houdini@example.com\?secret=\w{32}})
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 = CGI.parse(uri.query)
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'].shift).to eq('Magic')
158
- expect(params['secret'].shift).to match(/\w{32}/)
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
@@ -0,0 +1,2 @@
1
+ //= link_directory ../javascripts .js
2
+ //= link_directory ../stylesheets .css
@@ -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
@@ -7,7 +7,7 @@ class GuestUser
7
7
  attr_accessor :direct_otp, :direct_otp_sent_at, :otp_secret_key, :email,
8
8
  :second_factor_attempts_count, :totp_timestamp
9
9
 
10
- def update_attributes(attrs)
10
+ def update(attrs)
11
11
  attrs.each do |key, value|
12
12
  send(key.to_s + '=', value)
13
13
  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
@@ -1,3 +1,4 @@
1
+ require 'logger'
1
2
  require File.expand_path('../boot', __FILE__)
2
3
 
3
4
  require "active_record/railtie"
@@ -60,4 +61,3 @@ module Dummy
60
61
  config.secret_key_base = 'secretvalue'
61
62
  end
62
63
  end
63
-
@@ -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
- if Rails::VERSION::MAJOR == 4 && Rails::VERSION::MINOR >= 2 ||
13
- Rails::VERSION::MAJOR >= 5
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
@@ -13,3 +13,7 @@
13
13
  # ActiveSupport::Inflector.inflections do |inflect|
14
14
  # inflect.acronym 'RESTful'
15
15
  # end
16
+
17
+ ActiveSupport::Inflector.inflections(:en) do |inflect|
18
+ inflect.acronym 'SMS'
19
+ 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
@@ -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
- # Note that this schema.rb definition is the authoritative source for your
6
- # database schema. If you need to create the application database on another
7
- # system, you should be using db:schema:load, not running all the migrations
8
- # from scratch. The latter is a flawed and unsustainable approach (the more migrations
9
- # you'll amass, the slower it'll run and the greater likelihood for issues).
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: 2016_02_09_032439) do
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 = Class.new(OpenStruct)
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