devise_two_factor_authentication 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 (94) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/tests.yml +42 -0
  3. data/.gitignore +23 -0
  4. data/.rubocop.yml +293 -0
  5. data/CHANGELOG.md +119 -0
  6. data/Gemfile +35 -0
  7. data/LICENSE +19 -0
  8. data/README.md +401 -0
  9. data/Rakefile +16 -0
  10. data/app/controllers/devise/two_factor_authentication_controller.rb +88 -0
  11. data/app/views/devise/two_factor_authentication/max_login_attempts_reached.html.erb +3 -0
  12. data/app/views/devise/two_factor_authentication/show.html.erb +19 -0
  13. data/config/locales/de.yml +8 -0
  14. data/config/locales/en.yml +8 -0
  15. data/config/locales/es.yml +8 -0
  16. data/config/locales/fr.yml +8 -0
  17. data/config/locales/ru.yml +8 -0
  18. data/devise_two_factor_authentication.gemspec +40 -0
  19. data/lib/devise_two_factor_authentication/controllers/helpers.rb +54 -0
  20. data/lib/devise_two_factor_authentication/hooks/two_factor_authenticatable.rb +17 -0
  21. data/lib/devise_two_factor_authentication/models/two_factor_authenticatable.rb +206 -0
  22. data/lib/devise_two_factor_authentication/orm/active_record.rb +14 -0
  23. data/lib/devise_two_factor_authentication/rails.rb +7 -0
  24. data/lib/devise_two_factor_authentication/routes.rb +19 -0
  25. data/lib/devise_two_factor_authentication/schema.rb +31 -0
  26. data/lib/devise_two_factor_authentication/version.rb +3 -0
  27. data/lib/devise_two_factor_authentication.rb +52 -0
  28. data/lib/generators/active_record/templates/migration.rb +15 -0
  29. data/lib/generators/active_record/two_factor_authentication_generator.rb +14 -0
  30. data/lib/generators/two_factor_authentication/two_factor_authentication_generator.rb +17 -0
  31. data/spec/controllers/two_factor_authentication_controller_spec.rb +41 -0
  32. data/spec/features/two_factor_authenticatable_spec.rb +236 -0
  33. data/spec/generators/active_record/two_factor_authentication_generator_spec.rb +36 -0
  34. data/spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb +326 -0
  35. data/spec/rails_app/.gitignore +3 -0
  36. data/spec/rails_app/README.md +3 -0
  37. data/spec/rails_app/Rakefile +9 -0
  38. data/spec/rails_app/app/assets/config/manifest.js +2 -0
  39. data/spec/rails_app/app/assets/javascripts/application.js +1 -0
  40. data/spec/rails_app/app/assets/stylesheets/application.css +4 -0
  41. data/spec/rails_app/app/controllers/application_controller.rb +3 -0
  42. data/spec/rails_app/app/controllers/home_controller.rb +10 -0
  43. data/spec/rails_app/app/helpers/application_helper.rb +8 -0
  44. data/spec/rails_app/app/mailers/.gitkeep +0 -0
  45. data/spec/rails_app/app/models/.gitkeep +0 -0
  46. data/spec/rails_app/app/models/admin.rb +6 -0
  47. data/spec/rails_app/app/models/encrypted_user.rb +15 -0
  48. data/spec/rails_app/app/models/guest_user.rb +17 -0
  49. data/spec/rails_app/app/models/user.rb +14 -0
  50. data/spec/rails_app/app/views/home/dashboard.html.erb +11 -0
  51. data/spec/rails_app/app/views/home/index.html.erb +3 -0
  52. data/spec/rails_app/app/views/layouts/application.html.erb +20 -0
  53. data/spec/rails_app/config/application.rb +64 -0
  54. data/spec/rails_app/config/boot.rb +10 -0
  55. data/spec/rails_app/config/database.yml +19 -0
  56. data/spec/rails_app/config/environment.rb +5 -0
  57. data/spec/rails_app/config/environments/development.rb +28 -0
  58. data/spec/rails_app/config/environments/production.rb +68 -0
  59. data/spec/rails_app/config/environments/test.rb +41 -0
  60. data/spec/rails_app/config/initializers/backtrace_silencers.rb +7 -0
  61. data/spec/rails_app/config/initializers/cookies_serializer.rb +3 -0
  62. data/spec/rails_app/config/initializers/devise.rb +258 -0
  63. data/spec/rails_app/config/initializers/inflections.rb +15 -0
  64. data/spec/rails_app/config/initializers/mime_types.rb +5 -0
  65. data/spec/rails_app/config/initializers/secret_token.rb +7 -0
  66. data/spec/rails_app/config/initializers/session_store.rb +8 -0
  67. data/spec/rails_app/config/initializers/wrap_parameters.rb +14 -0
  68. data/spec/rails_app/config/locales/devise.en.yml +59 -0
  69. data/spec/rails_app/config/locales/en.yml +5 -0
  70. data/spec/rails_app/config/routes.rb +65 -0
  71. data/spec/rails_app/config.ru +4 -0
  72. data/spec/rails_app/db/migrate/20140403184646_devise_create_users.rb +42 -0
  73. data/spec/rails_app/db/migrate/20140407172619_two_factor_authentication_add_to_users.rb +15 -0
  74. data/spec/rails_app/db/migrate/20140407215513_add_nickanme_to_users.rb +7 -0
  75. data/spec/rails_app/db/migrate/20151224171231_add_encrypted_columns_to_user.rb +9 -0
  76. data/spec/rails_app/db/migrate/20151224180310_populate_otp_column.rb +19 -0
  77. data/spec/rails_app/db/migrate/20151228230340_remove_otp_secret_key_from_user.rb +5 -0
  78. data/spec/rails_app/db/migrate/20160209032439_devise_create_admins.rb +42 -0
  79. data/spec/rails_app/db/schema.rb +54 -0
  80. data/spec/rails_app/lib/assets/.gitkeep +0 -0
  81. data/spec/rails_app/lib/sms_provider.rb +17 -0
  82. data/spec/rails_app/public/404.html +26 -0
  83. data/spec/rails_app/public/422.html +26 -0
  84. data/spec/rails_app/public/500.html +25 -0
  85. data/spec/rails_app/public/favicon.ico +0 -0
  86. data/spec/rails_app/script/rails +9 -0
  87. data/spec/spec_helper.rb +27 -0
  88. data/spec/support/authenticated_model_helper.rb +59 -0
  89. data/spec/support/capybara.rb +3 -0
  90. data/spec/support/controller_helper.rb +16 -0
  91. data/spec/support/features_spec_helper.rb +42 -0
  92. data/spec/support/sms_provider.rb +5 -0
  93. data/spec/support/totp_helper.rb +11 -0
  94. metadata +294 -0
@@ -0,0 +1,17 @@
1
+ Warden::Manager.after_authentication do |user, auth, options|
2
+ if auth.env["action_dispatch.cookies"]
3
+ expected_cookie_value = "#{user.class}-#{user.public_send(Devise.second_factor_resource_id)}"
4
+ actual_cookie_value = auth.env["action_dispatch.cookies"].signed[DeviseTwoFactorAuthentication::REMEMBER_TFA_COOKIE_NAME]
5
+ bypass_by_cookie = actual_cookie_value == expected_cookie_value
6
+ end
7
+
8
+ if user.respond_to?(:need_two_factor_authentication?) && !bypass_by_cookie
9
+ if auth.session(options[:scope])[DeviseTwoFactorAuthentication::NEED_AUTHENTICATION] = user.need_two_factor_authentication?(auth.request)
10
+ user.send_new_otp if user.send_new_otp_after_login?
11
+ end
12
+ end
13
+ end
14
+
15
+ Warden::Manager.before_logout do |user, auth, _options|
16
+ auth.cookies.delete DeviseTwoFactorAuthentication::REMEMBER_TFA_COOKIE_NAME if Devise.delete_cookie_on_logout
17
+ end
@@ -0,0 +1,206 @@
1
+ require 'devise_two_factor_authentication/hooks/two_factor_authenticatable'
2
+ require 'rotp'
3
+ require 'encryptor'
4
+
5
+ module Devise
6
+ module Models
7
+ module TwoFactorAuthenticatable
8
+ extend ActiveSupport::Concern
9
+
10
+ module ClassMethods
11
+ def has_one_time_password(options = {})
12
+ include InstanceMethodsOnActivation
13
+ include EncryptionInstanceMethods if options[:encrypted] == true
14
+ end
15
+
16
+ ::Devise::Models.config(
17
+ self, :max_login_attempts, :allowed_otp_drift_seconds, :otp_length,
18
+ :remember_otp_session_for_seconds, :otp_secret_encryption_key,
19
+ :direct_otp_length, :direct_otp_valid_for, :totp_timestamp, :delete_cookie_on_logout
20
+ )
21
+ end
22
+
23
+ module InstanceMethodsOnActivation
24
+ def authenticate_otp(code, options = {})
25
+ return true if direct_otp && authenticate_direct_otp(code)
26
+ return true if totp_enabled? && authenticate_totp(code, options)
27
+ false
28
+ end
29
+
30
+ def authenticate_direct_otp(code)
31
+ return false if direct_otp.nil? || direct_otp != code || direct_otp_expired?
32
+ clear_direct_otp
33
+ true
34
+ end
35
+
36
+ def authenticate_totp(code, options = {})
37
+ totp_secret = options[:otp_secret_key] || otp_secret_key
38
+ digits = options[:otp_length] || self.class.otp_length
39
+ drift = options[:drift] || self.class.allowed_otp_drift_seconds
40
+ raise "authenticate_totp called with no otp_secret_key set" if totp_secret.nil?
41
+ totp = ROTP::TOTP.new(totp_secret, digits: digits)
42
+ new_timestamp = totp.verify(
43
+ without_spaces(code),
44
+ drift_ahead: drift, drift_behind: drift, after: totp_timestamp
45
+ )
46
+ return false unless new_timestamp
47
+ self.totp_timestamp = new_timestamp
48
+ true
49
+ end
50
+
51
+ def provisioning_uri(account = nil, options = {})
52
+ totp_secret = options[:otp_secret_key] || otp_secret_key
53
+ options[:digits] ||= options[:otp_length] || self.class.otp_length
54
+ raise "provisioning_uri called with no otp_secret_key set" if totp_secret.nil?
55
+ account ||= email if respond_to?(:email)
56
+ ROTP::TOTP.new(totp_secret, options).provisioning_uri(account)
57
+ end
58
+
59
+ def need_two_factor_authentication?(request)
60
+ true
61
+ end
62
+
63
+ def send_new_otp(options = {})
64
+ create_direct_otp options
65
+ send_two_factor_authentication_code(direct_otp)
66
+ end
67
+
68
+ def send_new_otp_after_login?
69
+ !totp_enabled?
70
+ end
71
+
72
+ def send_two_factor_authentication_code(code)
73
+ raise NotImplementedError.new("No default implementation - please define in your class.")
74
+ end
75
+
76
+ def max_login_attempts?
77
+ second_factor_attempts_count.to_i >= max_login_attempts.to_i
78
+ end
79
+
80
+ def max_login_attempts
81
+ self.class.max_login_attempts
82
+ end
83
+
84
+ def totp_enabled?
85
+ respond_to?(:otp_secret_key) && !otp_secret_key.nil?
86
+ end
87
+
88
+ def confirm_totp_secret(secret, code, options = {})
89
+ return false unless authenticate_totp(code, {otp_secret_key: secret})
90
+ self.otp_secret_key = secret
91
+ true
92
+ end
93
+
94
+ def generate_totp_secret
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
99
+ end
100
+
101
+ def create_direct_otp(options = {})
102
+ # Create a new random OTP and store it in the database
103
+ digits = options[:length] || self.class.direct_otp_length || 6
104
+ update(
105
+ direct_otp: random_base10(digits),
106
+ direct_otp_sent_at: Time.now.utc
107
+ )
108
+ end
109
+
110
+ private
111
+
112
+ def without_spaces(code)
113
+ code.gsub(/\s/, '')
114
+ end
115
+
116
+ def random_base10(digits)
117
+ SecureRandom.random_number(10**digits).to_s.rjust(digits, '0')
118
+ end
119
+
120
+ def direct_otp_expired?
121
+ Time.now.utc > direct_otp_sent_at + self.class.direct_otp_valid_for
122
+ end
123
+
124
+ def clear_direct_otp
125
+ update(direct_otp: nil, direct_otp_sent_at: nil)
126
+ end
127
+ end
128
+
129
+ module EncryptionInstanceMethods
130
+ def otp_secret_key
131
+ otp_decrypt(encrypted_otp_secret_key)
132
+ end
133
+
134
+ def otp_secret_key=(value)
135
+ self.encrypted_otp_secret_key = otp_encrypt(value)
136
+ end
137
+
138
+ private
139
+
140
+ def otp_decrypt(encrypted_value)
141
+ return encrypted_value if encrypted_value.blank?
142
+
143
+ encrypted_value = encrypted_value.unpack('m').first
144
+
145
+ value = ::Encryptor.decrypt(encryption_options_for(encrypted_value))
146
+
147
+ if defined?(Encoding)
148
+ encoding = Encoding.default_internal || Encoding.default_external
149
+ value = value.force_encoding(encoding.name)
150
+ end
151
+
152
+ value
153
+ end
154
+
155
+ def otp_encrypt(value)
156
+ return value if value.blank?
157
+
158
+ value = value.to_s
159
+ encrypted_value = ::Encryptor.encrypt(encryption_options_for(value))
160
+
161
+ encrypted_value = [encrypted_value].pack('m')
162
+
163
+ encrypted_value
164
+ end
165
+
166
+ def encryption_options_for(value)
167
+ {
168
+ value: value,
169
+ key: Devise.otp_secret_encryption_key,
170
+ iv: iv_for_attribute,
171
+ salt: salt_for_attribute,
172
+ algorithm: 'aes-256-cbc'
173
+ }
174
+ end
175
+
176
+ def iv_for_attribute(algorithm = 'aes-256-cbc')
177
+ iv = encrypted_otp_secret_key_iv
178
+
179
+ if iv.nil?
180
+ algo = OpenSSL::Cipher.new(algorithm)
181
+ iv = [algo.random_iv].pack('m')
182
+ self.encrypted_otp_secret_key_iv = iv
183
+ end
184
+
185
+ iv.unpack('m').first if iv.present?
186
+ end
187
+
188
+ def salt_for_attribute
189
+ salt = encrypted_otp_secret_key_salt ||
190
+ self.encrypted_otp_secret_key_salt = generate_random_base64_encoded_salt
191
+
192
+ decode_salt_if_encoded(salt)
193
+ end
194
+
195
+ def generate_random_base64_encoded_salt
196
+ prefix = '_'
197
+ prefix + [SecureRandom.random_bytes].pack('m')
198
+ end
199
+
200
+ def decode_salt_if_encoded(salt)
201
+ salt.slice(0).eql?('_') ? salt.slice(1..-1).unpack('m').first : salt
202
+ end
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,14 @@
1
+ require "active_record"
2
+
3
+ module Devise2Fa
4
+ module Orm
5
+ module ActiveRecord
6
+ module Schema
7
+ # include Devise2Fa::Schema
8
+ end
9
+ end
10
+ end
11
+ end
12
+
13
+ ActiveRecord::ConnectionAdapters::Table.send :include, Devise2Fa::Orm::ActiveRecord::Schema
14
+ ActiveRecord::ConnectionAdapters::TableDefinition.send :include, Devise2Fa::Orm::ActiveRecord::Schema
@@ -0,0 +1,7 @@
1
+ module DeviseTwoFactorAuthentication
2
+ class Engine < ::Rails::Engine
3
+ ActiveSupport.on_load(:action_controller) do
4
+ include DeviseTwoFactorAuthentication::Controllers::Helpers
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,19 @@
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_path(mapping), as: "resend_code" }
8
+ end
9
+ end
10
+
11
+ def resend_code_path(mapping)
12
+ Devise.mappings[resource_name(mapping)].path_names[:two_factor_authentication_resend_code] || "resend_code"
13
+ end
14
+
15
+ def resource_name(mapping)
16
+ mapping.class_name.underscore.to_sym
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,31 @@
1
+ module DeviseTwoFactorAuthentication
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 encrypted_otp_secret_key_iv
12
+ apply_devise_schema :encrypted_otp_secret_key_iv, String
13
+ end
14
+
15
+ def encrypted_otp_secret_key_salt
16
+ apply_devise_schema :encrypted_otp_secret_key_salt, String
17
+ end
18
+
19
+ def direct_otp
20
+ apply_devise_schema :direct_otp, String
21
+ end
22
+
23
+ def direct_otp_sent_at
24
+ apply_devise_schema :direct_otp_sent_at, DateTime
25
+ end
26
+
27
+ def totp_timestamp
28
+ apply_devise_schema :totp_timestamp, Timestamp
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,3 @@
1
+ module DeviseTwoFactorAuthentication
2
+ VERSION = "3.0.0".freeze
3
+ end
@@ -0,0 +1,52 @@
1
+ require 'devise_two_factor_authentication/version'
2
+ require 'devise'
3
+ require 'active_support/concern'
4
+ require "active_model"
5
+ require "active_support/core_ext/class/attribute_accessors"
6
+ require "cgi"
7
+
8
+ module Devise
9
+ mattr_accessor :max_login_attempts
10
+ @@max_login_attempts = 3
11
+
12
+ mattr_accessor :allowed_otp_drift_seconds
13
+ @@allowed_otp_drift_seconds = 30
14
+
15
+ mattr_accessor :otp_length
16
+ @@otp_length = 6
17
+
18
+ mattr_accessor :direct_otp_length
19
+ @@direct_otp_length = 6
20
+
21
+ mattr_accessor :direct_otp_valid_for
22
+ @@direct_otp_valid_for = 5.minutes
23
+
24
+ mattr_accessor :remember_otp_session_for_seconds
25
+ @@remember_otp_session_for_seconds = 0
26
+
27
+ mattr_accessor :otp_secret_encryption_key
28
+ @@otp_secret_encryption_key = ''
29
+
30
+ mattr_accessor :second_factor_resource_id
31
+ @@second_factor_resource_id = 'id'
32
+
33
+ mattr_accessor :delete_cookie_on_logout
34
+ @@delete_cookie_on_logout = false
35
+ end
36
+
37
+ module DeviseTwoFactorAuthentication
38
+ NEED_AUTHENTICATION = 'need_two_factor_authentication'
39
+ REMEMBER_TFA_COOKIE_NAME = "remember_tfa"
40
+
41
+ autoload :Schema, 'devise_two_factor_authentication/schema'
42
+ module Controllers
43
+ autoload :Helpers, 'devise_two_factor_authentication/controllers/helpers'
44
+ end
45
+ end
46
+
47
+ Devise.add_module :two_factor_authenticatable, :model => 'devise_two_factor_authentication/models/two_factor_authenticatable', :controller => :two_factor_authentication, :route => :two_factor_authentication
48
+
49
+ require 'devise_two_factor_authentication/orm/active_record' if defined?(ActiveRecord::Base)
50
+ require 'devise_two_factor_authentication/routes'
51
+ require 'devise_two_factor_authentication/models/two_factor_authenticatable'
52
+ require 'devise_two_factor_authentication/rails'
@@ -0,0 +1,15 @@
1
+ class TwoFactorAuthenticationAddTo<%= table_name.camelize %> < ActiveRecord::Migration
2
+ disable_ddl_transaction!
3
+
4
+ def change
5
+ add_column :<%= table_name %>, :second_factor_attempts_count, :integer, default: 0
6
+ add_column :<%= table_name %>, :encrypted_otp_secret_key, :string
7
+ add_column :<%= table_name %>, :encrypted_otp_secret_key_iv, :string
8
+ add_column :<%= table_name %>, :encrypted_otp_secret_key_salt, :string
9
+ add_column :<%= table_name %>, :direct_otp, :string
10
+ add_column :<%= table_name %>, :direct_otp_sent_at, :datetime
11
+ add_column :<%= table_name %>, :totp_timestamp, :timestamp
12
+
13
+ add_index :<%= table_name %>, :encrypted_otp_secret_key, unique: true, algorithm: :concurrently
14
+ end
15
+ end
@@ -0,0 +1,14 @@
1
+ require 'rails/generators/active_record'
2
+
3
+ module ActiveRecord
4
+ module Generators
5
+ class TwoFactorAuthenticationGenerator < ActiveRecord::Generators::Base
6
+ source_root File.expand_path("../templates", __FILE__)
7
+
8
+ def copy_two_factor_authentication_migration
9
+ migration_template "migration.rb", "db/migrate/two_factor_authentication_add_to_#{table_name}.rb"
10
+ end
11
+
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,17 @@
1
+ module TwoFactorAuthenticatable
2
+ module Generators
3
+ class TwoFactorAuthenticationGenerator < Rails::Generators::NamedBase
4
+ namespace "two_factor_authentication"
5
+
6
+ desc "Adds :two_factor_authenticable directive in the given model. It also generates an active record migration."
7
+
8
+ def inject_two_factor_authentication_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
+ end
12
+
13
+ hook_for :orm
14
+
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,236 @@
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).with(encrypted: true)
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
+ expect(page).to have_selector(
40
+ ".notice",
41
+ text: "Two factor authentication successful."
42
+ )
43
+
44
+ expect(current_path).to eq root_path
45
+ end
46
+ end
47
+
48
+ it_behaves_like 'sends and authenticates code', create_user('not_encrypted')
49
+ it_behaves_like 'sends and authenticates code', create_user, 'encrypted'
50
+ end
51
+
52
+ scenario "must be logged in" do
53
+ visit user_two_factor_authentication_path
54
+
55
+ expect(page).to have_content("Welcome Home")
56
+ expect(page).to have_content("You are signed out")
57
+ end
58
+
59
+ context "when logged in" do
60
+ let(:user) { create_user }
61
+
62
+ background do
63
+ login_as user
64
+ end
65
+
66
+ scenario "is redirected to TFA when path requires authentication" do
67
+ visit dashboard_path + "?A=param%20a&B=param%20b"
68
+
69
+ expect(page).to_not have_content("Your Personal Dashboard")
70
+
71
+ fill_in "code", with: SmsProvider.last_message.body
72
+ click_button "Submit"
73
+
74
+ expect(page).to have_content("Your Personal Dashboard")
75
+ expect(page).to have_content("You are signed in as Marissa")
76
+ expect(page).to have_content("Param A is param a")
77
+ expect(page).to have_content("Param B is param b")
78
+ end
79
+
80
+ scenario "is locked out after max failed attempts" do
81
+ visit user_two_factor_authentication_path
82
+
83
+ max_attempts = User.max_login_attempts
84
+
85
+ max_attempts.times do
86
+ fill_in "code", with: "incorrect#{rand(100)}"
87
+ click_button "Submit"
88
+
89
+ expect(page).to have_selector(".alert", text: "Attempt failed")
90
+ end
91
+
92
+ expect(page).to have_content("Access completely denied")
93
+ expect(page).to have_content("You are signed out")
94
+ end
95
+
96
+ scenario "cannot retry authentication after max attempts" do
97
+ user.update_attribute(:second_factor_attempts_count, User.max_login_attempts)
98
+
99
+ visit user_two_factor_authentication_path
100
+
101
+ expect(page).to have_content("Access completely denied")
102
+ expect(page).to have_content("You are signed out")
103
+ end
104
+
105
+ describe "rememberable TFA" do
106
+ before do
107
+ @original_remember_otp_session_for_seconds = User.remember_otp_session_for_seconds
108
+ User.remember_otp_session_for_seconds = 30.days
109
+ end
110
+
111
+ after do
112
+ User.remember_otp_session_for_seconds = @original_remember_otp_session_for_seconds
113
+ end
114
+
115
+ scenario "doesn't require TFA code again within 30 days" do
116
+ sms_sign_in
117
+
118
+ logout
119
+
120
+ login_as user
121
+ visit dashboard_path
122
+ expect(page).to have_content("Your Personal Dashboard")
123
+ expect(page).to have_content("You are signed in as Marissa")
124
+ end
125
+
126
+ scenario "requires TFA code again after 30 days" do
127
+ sms_sign_in
128
+
129
+ logout
130
+
131
+ Timecop.travel(30.days.from_now)
132
+ login_as user
133
+ visit dashboard_path
134
+ expect(page).to have_content("You are signed in as Marissa")
135
+ expect(page).to have_content("Enter the code that was sent to you")
136
+ end
137
+
138
+ scenario 'TFA should be different for different users' do
139
+ sms_sign_in
140
+
141
+ tfa_cookie1 = get_tfa_cookie()
142
+
143
+ logout
144
+ reset_session!
145
+
146
+ user2 = create_user()
147
+ login_as(user2)
148
+ sms_sign_in
149
+
150
+ tfa_cookie2 = get_tfa_cookie()
151
+
152
+ expect(tfa_cookie1).not_to eq tfa_cookie2
153
+ end
154
+
155
+ def sms_sign_in
156
+ SmsProvider.messages.clear()
157
+ visit user_two_factor_authentication_path
158
+ fill_in 'code', with: SmsProvider.last_message.body
159
+ click_button 'Submit'
160
+ end
161
+
162
+ scenario 'TFA should be unique for specific user' do
163
+ sms_sign_in
164
+
165
+ tfa_cookie1 = get_tfa_cookie()
166
+
167
+ logout
168
+ reset_session!
169
+
170
+ user2 = create_user()
171
+ set_tfa_cookie(tfa_cookie1)
172
+ login_as(user2)
173
+ visit dashboard_path
174
+ expect(page).to have_content("Enter the code that was sent to you")
175
+ end
176
+
177
+ scenario 'Delete cookie when user logs out if enabled' do
178
+ user.class.delete_cookie_on_logout = true
179
+
180
+ login_as user
181
+ logout
182
+
183
+ login_as user
184
+
185
+ visit dashboard_path
186
+ expect(page).to have_content("Enter the code that was sent to you")
187
+ end
188
+ end
189
+
190
+ it 'sets the warden session need_two_factor_authentication key to true' do
191
+ session_hash = { 'need_two_factor_authentication' => true }
192
+
193
+ expect(page.get_rack_session_key('warden.user.user.session')).to eq session_hash
194
+ end
195
+ end
196
+
197
+ describe 'signing in' do
198
+ let(:user) { create_user }
199
+ let(:admin) { create_admin }
200
+
201
+ scenario 'user signs is' do
202
+ visit new_user_session_path
203
+ complete_sign_in_form_for(user)
204
+
205
+ expect(page).to have_content('Signed in successfully.')
206
+ end
207
+
208
+ scenario 'admin signs in' do
209
+ visit new_admin_session_path
210
+ complete_sign_in_form_for(admin)
211
+
212
+ expect(page).to have_content('Signed in successfully.')
213
+ end
214
+ end
215
+
216
+ describe 'signing out' do
217
+ let(:user) { create_user }
218
+ let(:admin) { create_admin }
219
+
220
+ scenario 'user signs out' do
221
+ visit new_user_session_path
222
+ complete_sign_in_form_for(user)
223
+ visit destroy_user_session_path
224
+
225
+ expect(page).to have_content('Signed out successfully.')
226
+ end
227
+
228
+ scenario 'admin signs out' do
229
+ visit new_admin_session_path
230
+ complete_sign_in_form_for(admin)
231
+ visit destroy_admin_session_path
232
+
233
+ expect(page).to have_content('Signed out successfully.')
234
+ end
235
+ end
236
+ end