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.
- checksums.yaml +7 -0
- data/.github/workflows/tests.yml +42 -0
- data/.gitignore +23 -0
- data/.rubocop.yml +293 -0
- data/CHANGELOG.md +119 -0
- data/Gemfile +35 -0
- data/LICENSE +19 -0
- data/README.md +401 -0
- data/Rakefile +16 -0
- data/app/controllers/devise/two_factor_authentication_controller.rb +88 -0
- data/app/views/devise/two_factor_authentication/max_login_attempts_reached.html.erb +3 -0
- data/app/views/devise/two_factor_authentication/show.html.erb +19 -0
- data/config/locales/de.yml +8 -0
- data/config/locales/en.yml +8 -0
- data/config/locales/es.yml +8 -0
- data/config/locales/fr.yml +8 -0
- data/config/locales/ru.yml +8 -0
- data/devise_two_factor_authentication.gemspec +40 -0
- data/lib/devise_two_factor_authentication/controllers/helpers.rb +54 -0
- data/lib/devise_two_factor_authentication/hooks/two_factor_authenticatable.rb +17 -0
- data/lib/devise_two_factor_authentication/models/two_factor_authenticatable.rb +206 -0
- data/lib/devise_two_factor_authentication/orm/active_record.rb +14 -0
- data/lib/devise_two_factor_authentication/rails.rb +7 -0
- data/lib/devise_two_factor_authentication/routes.rb +19 -0
- data/lib/devise_two_factor_authentication/schema.rb +31 -0
- data/lib/devise_two_factor_authentication/version.rb +3 -0
- data/lib/devise_two_factor_authentication.rb +52 -0
- data/lib/generators/active_record/templates/migration.rb +15 -0
- data/lib/generators/active_record/two_factor_authentication_generator.rb +14 -0
- data/lib/generators/two_factor_authentication/two_factor_authentication_generator.rb +17 -0
- data/spec/controllers/two_factor_authentication_controller_spec.rb +41 -0
- data/spec/features/two_factor_authenticatable_spec.rb +236 -0
- data/spec/generators/active_record/two_factor_authentication_generator_spec.rb +36 -0
- data/spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb +326 -0
- data/spec/rails_app/.gitignore +3 -0
- data/spec/rails_app/README.md +3 -0
- data/spec/rails_app/Rakefile +9 -0
- data/spec/rails_app/app/assets/config/manifest.js +2 -0
- data/spec/rails_app/app/assets/javascripts/application.js +1 -0
- data/spec/rails_app/app/assets/stylesheets/application.css +4 -0
- data/spec/rails_app/app/controllers/application_controller.rb +3 -0
- data/spec/rails_app/app/controllers/home_controller.rb +10 -0
- data/spec/rails_app/app/helpers/application_helper.rb +8 -0
- data/spec/rails_app/app/mailers/.gitkeep +0 -0
- data/spec/rails_app/app/models/.gitkeep +0 -0
- data/spec/rails_app/app/models/admin.rb +6 -0
- data/spec/rails_app/app/models/encrypted_user.rb +15 -0
- data/spec/rails_app/app/models/guest_user.rb +17 -0
- data/spec/rails_app/app/models/user.rb +14 -0
- data/spec/rails_app/app/views/home/dashboard.html.erb +11 -0
- data/spec/rails_app/app/views/home/index.html.erb +3 -0
- data/spec/rails_app/app/views/layouts/application.html.erb +20 -0
- data/spec/rails_app/config/application.rb +64 -0
- data/spec/rails_app/config/boot.rb +10 -0
- data/spec/rails_app/config/database.yml +19 -0
- data/spec/rails_app/config/environment.rb +5 -0
- data/spec/rails_app/config/environments/development.rb +28 -0
- data/spec/rails_app/config/environments/production.rb +68 -0
- data/spec/rails_app/config/environments/test.rb +41 -0
- data/spec/rails_app/config/initializers/backtrace_silencers.rb +7 -0
- data/spec/rails_app/config/initializers/cookies_serializer.rb +3 -0
- data/spec/rails_app/config/initializers/devise.rb +258 -0
- data/spec/rails_app/config/initializers/inflections.rb +15 -0
- data/spec/rails_app/config/initializers/mime_types.rb +5 -0
- data/spec/rails_app/config/initializers/secret_token.rb +7 -0
- data/spec/rails_app/config/initializers/session_store.rb +8 -0
- data/spec/rails_app/config/initializers/wrap_parameters.rb +14 -0
- data/spec/rails_app/config/locales/devise.en.yml +59 -0
- data/spec/rails_app/config/locales/en.yml +5 -0
- data/spec/rails_app/config/routes.rb +65 -0
- data/spec/rails_app/config.ru +4 -0
- data/spec/rails_app/db/migrate/20140403184646_devise_create_users.rb +42 -0
- data/spec/rails_app/db/migrate/20140407172619_two_factor_authentication_add_to_users.rb +15 -0
- data/spec/rails_app/db/migrate/20140407215513_add_nickanme_to_users.rb +7 -0
- data/spec/rails_app/db/migrate/20151224171231_add_encrypted_columns_to_user.rb +9 -0
- data/spec/rails_app/db/migrate/20151224180310_populate_otp_column.rb +19 -0
- data/spec/rails_app/db/migrate/20151228230340_remove_otp_secret_key_from_user.rb +5 -0
- data/spec/rails_app/db/migrate/20160209032439_devise_create_admins.rb +42 -0
- data/spec/rails_app/db/schema.rb +54 -0
- data/spec/rails_app/lib/assets/.gitkeep +0 -0
- data/spec/rails_app/lib/sms_provider.rb +17 -0
- data/spec/rails_app/public/404.html +26 -0
- data/spec/rails_app/public/422.html +26 -0
- data/spec/rails_app/public/500.html +25 -0
- data/spec/rails_app/public/favicon.ico +0 -0
- data/spec/rails_app/script/rails +9 -0
- data/spec/spec_helper.rb +27 -0
- data/spec/support/authenticated_model_helper.rb +59 -0
- data/spec/support/capybara.rb +3 -0
- data/spec/support/controller_helper.rb +16 -0
- data/spec/support/features_spec_helper.rb +42 -0
- data/spec/support/sms_provider.rb +5 -0
- data/spec/support/totp_helper.rb +11 -0
- 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,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,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
|