devise_xfactor_authentication 2.2.18
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/.codeclimate.yml +21 -0
- data/.gitignore +23 -0
- data/.rubocop.yml +295 -0
- data/.travis.yml +28 -0
- data/CHANGELOG.md +119 -0
- data/Gemfile +31 -0
- data/LICENSE +19 -0
- data/README.md +405 -0
- data/Rakefile +14 -0
- data/app/controllers/devise/devise_xfactor_authentication_controller.rb +84 -0
- data/app/views/devise/devise_xfactor_authentication/max_login_attempts_reached.html.erb +3 -0
- data/app/views/devise/devise_xfactor_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_xfactor_authentication.gemspec +39 -0
- data/lib/devise_xfactor_authentication/controllers/helpers.rb +49 -0
- data/lib/devise_xfactor_authentication/hooks/devise_xfactor_authenticatable.rb +17 -0
- data/lib/devise_xfactor_authentication/models/devise_xfactor_authenticatable.rb +203 -0
- data/lib/devise_xfactor_authentication/orm/active_record.rb +14 -0
- data/lib/devise_xfactor_authentication/rails.rb +7 -0
- data/lib/devise_xfactor_authentication/routes.rb +11 -0
- data/lib/devise_xfactor_authentication/schema.rb +31 -0
- data/lib/devise_xfactor_authentication/version.rb +3 -0
- data/lib/devise_xfactor_authentication.rb +52 -0
- data/lib/generators/active_record/devise_xfactor_authentication_generator.rb +14 -0
- data/lib/generators/active_record/templates/migration.rb +14 -0
- data/lib/generators/devise_xfactor_authentication/devise_xfactor_authentication_generator.rb +17 -0
- data/spec/controllers/devise_xfactor_authentication_controller_spec.rb +41 -0
- data/spec/features/devise_xfactor_authenticatable_spec.rb +237 -0
- data/spec/generators/active_record/devise_xfactor_authentication_generator_spec.rb +36 -0
- data/spec/lib/devise_xfactor_authentication/models/devise_xfactor_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 +7 -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 +63 -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_devise_xfactor_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 +55 -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 +6 -0
- data/spec/spec_helper.rb +26 -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 +293 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
require 'devise_xfactor_authentication/hooks/devise_xfactor_authenticatable'
|
|
2
|
+
require 'rotp'
|
|
3
|
+
require 'encryptor'
|
|
4
|
+
|
|
5
|
+
module Devise
|
|
6
|
+
module Models
|
|
7
|
+
module DeviseXfactorAuthenticatable
|
|
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_devise_xfactor_authentication?(request)
|
|
60
|
+
true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def send_new_otp(options = {})
|
|
64
|
+
create_direct_otp options
|
|
65
|
+
send_devise_xfactor_authentication_code(direct_otp)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def send_new_otp_after_login?
|
|
69
|
+
!totp_enabled?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def send_devise_xfactor_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::Base32.random_base32
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def create_direct_otp(options = {})
|
|
99
|
+
# Create a new random OTP and store it in the database
|
|
100
|
+
digits = options[:length] || self.class.direct_otp_length || 6
|
|
101
|
+
update(
|
|
102
|
+
direct_otp: random_base10(digits),
|
|
103
|
+
direct_otp_sent_at: Time.now.utc
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def without_spaces(code)
|
|
110
|
+
code.gsub(/\s/, '')
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def random_base10(digits)
|
|
114
|
+
SecureRandom.random_number(10**digits).to_s.rjust(digits, '0')
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def direct_otp_expired?
|
|
118
|
+
Time.now.utc > direct_otp_sent_at + self.class.direct_otp_valid_for
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def clear_direct_otp
|
|
122
|
+
update(direct_otp: nil, direct_otp_sent_at: nil)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
module EncryptionInstanceMethods
|
|
127
|
+
def otp_secret_key
|
|
128
|
+
otp_decrypt(encrypted_otp_secret_key)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def otp_secret_key=(value)
|
|
132
|
+
self.encrypted_otp_secret_key = otp_encrypt(value)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def otp_decrypt(encrypted_value)
|
|
138
|
+
return encrypted_value if encrypted_value.blank?
|
|
139
|
+
|
|
140
|
+
encrypted_value = encrypted_value.unpack('m').first
|
|
141
|
+
|
|
142
|
+
value = ::Encryptor.decrypt(encryption_options_for(encrypted_value))
|
|
143
|
+
|
|
144
|
+
if defined?(Encoding)
|
|
145
|
+
encoding = Encoding.default_internal || Encoding.default_external
|
|
146
|
+
value = value.force_encoding(encoding.name)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
value
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def otp_encrypt(value)
|
|
153
|
+
return value if value.blank?
|
|
154
|
+
|
|
155
|
+
value = value.to_s
|
|
156
|
+
encrypted_value = ::Encryptor.encrypt(encryption_options_for(value))
|
|
157
|
+
|
|
158
|
+
encrypted_value = [encrypted_value].pack('m')
|
|
159
|
+
|
|
160
|
+
encrypted_value
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def encryption_options_for(value)
|
|
164
|
+
{
|
|
165
|
+
value: value,
|
|
166
|
+
key: Devise.otp_secret_encryption_key,
|
|
167
|
+
iv: iv_for_attribute,
|
|
168
|
+
salt: salt_for_attribute,
|
|
169
|
+
algorithm: 'aes-256-cbc'
|
|
170
|
+
}
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def iv_for_attribute(algorithm = 'aes-256-cbc')
|
|
174
|
+
iv = encrypted_otp_secret_key_iv
|
|
175
|
+
|
|
176
|
+
if iv.nil?
|
|
177
|
+
algo = OpenSSL::Cipher.new(algorithm)
|
|
178
|
+
iv = [algo.random_iv].pack('m')
|
|
179
|
+
self.encrypted_otp_secret_key_iv = iv
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
iv.unpack('m').first if iv.present?
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def salt_for_attribute
|
|
186
|
+
salt = encrypted_otp_secret_key_salt ||
|
|
187
|
+
self.encrypted_otp_secret_key_salt = generate_random_base64_encoded_salt
|
|
188
|
+
|
|
189
|
+
decode_salt_if_encoded(salt)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def generate_random_base64_encoded_salt
|
|
193
|
+
prefix = '_'
|
|
194
|
+
prefix + [SecureRandom.random_bytes].pack('m')
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def decode_salt_if_encoded(salt)
|
|
198
|
+
salt.slice(0).eql?('_') ? salt.slice(1..-1).unpack('m').first : salt
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
require "active_record"
|
|
2
|
+
|
|
3
|
+
module DeviseXfactorAuthentication
|
|
4
|
+
module Orm
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
module Schema
|
|
7
|
+
include DeviseXfactorAuthentication::Schema
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
ActiveRecord::ConnectionAdapters::Table.send :include, DeviseXfactorAuthentication::Orm::ActiveRecord::Schema
|
|
14
|
+
ActiveRecord::ConnectionAdapters::TableDefinition.send :include, DeviseXfactorAuthentication::Orm::ActiveRecord::Schema
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module ActionDispatch::Routing
|
|
2
|
+
class Mapper
|
|
3
|
+
protected
|
|
4
|
+
|
|
5
|
+
def devise_devise_xfactor_authentication(mapping, controllers)
|
|
6
|
+
resource :devise_xfactor_authentication, :only => [:show, :update, :resend_code], :path => mapping.path_names[:devise_xfactor_authentication], :controller => controllers[:devise_xfactor_authentication] do
|
|
7
|
+
collection { get "resend_code" }
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module DeviseXfactorAuthentication
|
|
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_xfactor_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 DeviseXfactorAuthentication
|
|
38
|
+
NEED_AUTHENTICATION = 'need_devise_xfactor_authentication'
|
|
39
|
+
REMEMBER_TFA_COOKIE_NAME = "remember_tfa"
|
|
40
|
+
|
|
41
|
+
autoload :Schema, 'devise_xfactor_authentication/schema'
|
|
42
|
+
module Controllers
|
|
43
|
+
autoload :Helpers, 'devise_xfactor_authentication/controllers/helpers'
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
Devise.add_module :devise_xfactor_authenticatable, :model => 'devise_xfactor_authentication/models/devise_xfactor_authenticatable', :controller => :devise_xfactor_authentication, :route => :devise_xfactor_authentication
|
|
48
|
+
|
|
49
|
+
require 'devise_xfactor_authentication/orm/active_record' if defined?(ActiveRecord::Base)
|
|
50
|
+
require 'devise_xfactor_authentication/routes'
|
|
51
|
+
require 'devise_xfactor_authentication/models/devise_xfactor_authenticatable'
|
|
52
|
+
require 'devise_xfactor_authentication/rails'
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
require 'rails/generators/active_record'
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module Generators
|
|
5
|
+
class DeviseXfactorAuthenticationGenerator < ActiveRecord::Generators::Base
|
|
6
|
+
source_root File.expand_path("../templates", __FILE__)
|
|
7
|
+
|
|
8
|
+
def copy_devise_xfactor_authentication_migration
|
|
9
|
+
migration_template "migration.rb", "db/migrate/devise_xfactor_authentication_add_to_#{table_name}.rb"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class DeviseXfactorAuthenticationAddTo<%= table_name.camelize %> < ActiveRecord::Migration[7.0]
|
|
2
|
+
def change
|
|
3
|
+
add_column :<%= table_name %>, :second_factor_attempts_count, :integer, default: 0
|
|
4
|
+
add_column :<%= table_name %>, :encrypted_otp_secret_key, :string
|
|
5
|
+
add_column :<%= table_name %>, :encrypted_otp_secret_key_iv, :string
|
|
6
|
+
add_column :<%= table_name %>, :encrypted_otp_secret_key_salt, :string
|
|
7
|
+
add_column :<%= table_name %>, :direct_otp, :string
|
|
8
|
+
add_column :<%= table_name %>, :direct_otp_sent_at, :datetime
|
|
9
|
+
add_column :<%= table_name %>, :totp_timestamp, :timestamp
|
|
10
|
+
add_column :<%= table_name %>, :otp_secret_key, :string
|
|
11
|
+
|
|
12
|
+
add_index :<%= table_name %>, :encrypted_otp_secret_key, unique: true
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module DeviseXfactorAuthenticatable
|
|
2
|
+
module Generators
|
|
3
|
+
class DeviseXfactorAuthenticationGenerator < Rails::Generators::NamedBase
|
|
4
|
+
namespace "devise_xfactor_authentication"
|
|
5
|
+
|
|
6
|
+
desc "Adds :devise_xfactor_authenticable directive in the given model. It also generates an active record migration."
|
|
7
|
+
|
|
8
|
+
def inject_devise_xfactor_authentication_content
|
|
9
|
+
path = File.join("app", "models", "#{file_path}.rb")
|
|
10
|
+
inject_into_file(path, "devise_xfactor_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::DeviseXfactorAuthenticationController, type: :controller do
|
|
4
|
+
describe 'is_fully_authenticated? helper' do
|
|
5
|
+
def post_code(code)
|
|
6
|
+
if Rails::VERSION::MAJOR >= 5
|
|
7
|
+
post :update, params: { code: code }
|
|
8
|
+
else
|
|
9
|
+
post :update, code: code
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
before do
|
|
14
|
+
sign_in
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
context 'after user enters valid OTP code' do
|
|
18
|
+
it 'returns true' do
|
|
19
|
+
controller.current_user.send_new_otp
|
|
20
|
+
post_code controller.current_user.direct_otp
|
|
21
|
+
expect(subject.is_fully_authenticated?).to eq true
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
context 'when user has not entered any OTP yet' do
|
|
26
|
+
it 'returns false' do
|
|
27
|
+
get :show
|
|
28
|
+
|
|
29
|
+
expect(subject.is_fully_authenticated?).to eq false
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
context 'when user enters an invalid OTP' do
|
|
34
|
+
it 'returns false' do
|
|
35
|
+
post_code '12345'
|
|
36
|
+
|
|
37
|
+
expect(subject.is_fully_authenticated?).to eq false
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
include AuthenticatedModelHelper
|
|
3
|
+
|
|
4
|
+
feature "User of two factor authentication" do
|
|
5
|
+
context 'sending two factor authentication code via SMS' do
|
|
6
|
+
shared_examples 'sends and authenticates code' do |user, type|
|
|
7
|
+
before do
|
|
8
|
+
user.reload
|
|
9
|
+
if type == 'encrypted'
|
|
10
|
+
allow(User).to receive(:has_one_time_password).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
|
+
within('.flash.notice') do
|
|
40
|
+
expect(page).to have_content('Two factor authentication successful.')
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
expect(current_path).to eq root_path
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it_behaves_like 'sends and authenticates code', create_user('not_encrypted')
|
|
48
|
+
it_behaves_like 'sends and authenticates code', create_user, 'encrypted'
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
scenario "must be logged in" do
|
|
52
|
+
visit user_devise_xfactor_authentication_path
|
|
53
|
+
|
|
54
|
+
expect(page).to have_content("Welcome Home")
|
|
55
|
+
expect(page).to have_content("You are signed out")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
context "when logged in" do
|
|
59
|
+
let(:user) { create_user }
|
|
60
|
+
|
|
61
|
+
background do
|
|
62
|
+
login_as user
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
scenario "is redirected to TFA when path requires authentication" do
|
|
66
|
+
visit dashboard_path + "?A=param%20a&B=param%20b"
|
|
67
|
+
|
|
68
|
+
expect(page).to_not have_content("Your Personal Dashboard")
|
|
69
|
+
|
|
70
|
+
fill_in "code", with: SMSProvider.last_message.body
|
|
71
|
+
click_button "Submit"
|
|
72
|
+
|
|
73
|
+
expect(page).to have_content("Your Personal Dashboard")
|
|
74
|
+
expect(page).to have_content("You are signed in as Marissa")
|
|
75
|
+
expect(page).to have_content("Param A is param a")
|
|
76
|
+
expect(page).to have_content("Param B is param b")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
scenario "is locked out after max failed attempts" do
|
|
80
|
+
visit user_devise_xfactor_authentication_path
|
|
81
|
+
|
|
82
|
+
max_attempts = User.max_login_attempts
|
|
83
|
+
|
|
84
|
+
max_attempts.times do
|
|
85
|
+
fill_in "code", with: "incorrect#{rand(100)}"
|
|
86
|
+
click_button "Submit"
|
|
87
|
+
|
|
88
|
+
within(".flash.alert") do
|
|
89
|
+
expect(page).to have_content("Attempt failed")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
expect(page).to have_content("Access completely denied")
|
|
94
|
+
expect(page).to have_content("You are signed out")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
scenario "cannot retry authentication after max attempts" do
|
|
98
|
+
user.update(:second_factor_attempts_count, User.max_login_attempts)
|
|
99
|
+
|
|
100
|
+
visit user_devise_xfactor_authentication_path
|
|
101
|
+
|
|
102
|
+
expect(page).to have_content("Access completely denied")
|
|
103
|
+
expect(page).to have_content("You are signed out")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
describe "rememberable TFA" do
|
|
107
|
+
before do
|
|
108
|
+
@original_remember_otp_session_for_seconds = User.remember_otp_session_for_seconds
|
|
109
|
+
User.remember_otp_session_for_seconds = 30.days
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
after do
|
|
113
|
+
User.remember_otp_session_for_seconds = @original_remember_otp_session_for_seconds
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
scenario "doesn't require TFA code again within 30 days" do
|
|
117
|
+
sms_sign_in
|
|
118
|
+
|
|
119
|
+
logout
|
|
120
|
+
|
|
121
|
+
login_as user
|
|
122
|
+
visit dashboard_path
|
|
123
|
+
expect(page).to have_content("Your Personal Dashboard")
|
|
124
|
+
expect(page).to have_content("You are signed in as Marissa")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
scenario "requires TFA code again after 30 days" do
|
|
128
|
+
sms_sign_in
|
|
129
|
+
|
|
130
|
+
logout
|
|
131
|
+
|
|
132
|
+
Timecop.travel(30.days.from_now)
|
|
133
|
+
login_as user
|
|
134
|
+
visit dashboard_path
|
|
135
|
+
expect(page).to have_content("You are signed in as Marissa")
|
|
136
|
+
expect(page).to have_content("Enter the code that was sent to you")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
scenario 'TFA should be different for different users' do
|
|
140
|
+
sms_sign_in
|
|
141
|
+
|
|
142
|
+
tfa_cookie1 = get_tfa_cookie()
|
|
143
|
+
|
|
144
|
+
logout
|
|
145
|
+
reset_session!
|
|
146
|
+
|
|
147
|
+
user2 = create_user()
|
|
148
|
+
login_as(user2)
|
|
149
|
+
sms_sign_in
|
|
150
|
+
|
|
151
|
+
tfa_cookie2 = get_tfa_cookie()
|
|
152
|
+
|
|
153
|
+
expect(tfa_cookie1).not_to eq tfa_cookie2
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def sms_sign_in
|
|
157
|
+
SMSProvider.messages.clear()
|
|
158
|
+
visit user_devise_xfactor_authentication_path
|
|
159
|
+
fill_in 'code', with: SMSProvider.last_message.body
|
|
160
|
+
click_button 'Submit'
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
scenario 'TFA should be unique for specific user' do
|
|
164
|
+
sms_sign_in
|
|
165
|
+
|
|
166
|
+
tfa_cookie1 = get_tfa_cookie()
|
|
167
|
+
|
|
168
|
+
logout
|
|
169
|
+
reset_session!
|
|
170
|
+
|
|
171
|
+
user2 = create_user()
|
|
172
|
+
set_tfa_cookie(tfa_cookie1)
|
|
173
|
+
login_as(user2)
|
|
174
|
+
visit dashboard_path
|
|
175
|
+
expect(page).to have_content("Enter the code that was sent to you")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
scenario 'Delete cookie when user logs out if enabled' do
|
|
179
|
+
user.class.delete_cookie_on_logout = true
|
|
180
|
+
|
|
181
|
+
login_as user
|
|
182
|
+
logout
|
|
183
|
+
|
|
184
|
+
login_as user
|
|
185
|
+
|
|
186
|
+
visit dashboard_path
|
|
187
|
+
expect(page).to have_content("Enter the code that was sent to you")
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
it 'sets the warden session need_devise_xfactor_authentication key to true' do
|
|
192
|
+
session_hash = { 'need_devise_xfactor_authentication' => true }
|
|
193
|
+
|
|
194
|
+
expect(page.get_rack_session_key('warden.user.user.session')).to eq session_hash
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
describe 'signing in' do
|
|
199
|
+
let(:user) { create_user }
|
|
200
|
+
let(:admin) { create_admin }
|
|
201
|
+
|
|
202
|
+
scenario 'user signs is' do
|
|
203
|
+
visit new_user_session_path
|
|
204
|
+
complete_sign_in_form_for(user)
|
|
205
|
+
|
|
206
|
+
expect(page).to have_content('Signed in successfully.')
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
scenario 'admin signs in' do
|
|
210
|
+
visit new_admin_session_path
|
|
211
|
+
complete_sign_in_form_for(admin)
|
|
212
|
+
|
|
213
|
+
expect(page).to have_content('Signed in successfully.')
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
describe 'signing out' do
|
|
218
|
+
let(:user) { create_user }
|
|
219
|
+
let(:admin) { create_admin }
|
|
220
|
+
|
|
221
|
+
scenario 'user signs out' do
|
|
222
|
+
visit new_user_session_path
|
|
223
|
+
complete_sign_in_form_for(user)
|
|
224
|
+
visit destroy_user_session_path
|
|
225
|
+
|
|
226
|
+
expect(page).to have_content('Signed out successfully.')
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
scenario 'admin signs out' do
|
|
230
|
+
visit new_admin_session_path
|
|
231
|
+
complete_sign_in_form_for(admin)
|
|
232
|
+
visit destroy_admin_session_path
|
|
233
|
+
|
|
234
|
+
expect(page).to have_content('Signed out successfully.')
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
require 'generators/active_record/devise_xfactor_authentication_generator'
|
|
4
|
+
|
|
5
|
+
describe ActiveRecord::Generators::DeviseXfactorAuthenticationGenerator, type: :generator do
|
|
6
|
+
destination File.expand_path('../../../../../tmp', __FILE__)
|
|
7
|
+
|
|
8
|
+
before do
|
|
9
|
+
prepare_destination
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it 'runs all methods in the generator' do
|
|
13
|
+
gen = generator %w(users)
|
|
14
|
+
expect(gen).to receive(:copy_devise_xfactor_authentication_migration)
|
|
15
|
+
gen.invoke_all
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
describe 'the generated files' do
|
|
19
|
+
before do
|
|
20
|
+
run_generator %w(users)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
describe 'the migration' do
|
|
24
|
+
subject { migration_file('db/migrate/devise_xfactor_authentication_add_to_users.rb') }
|
|
25
|
+
|
|
26
|
+
it { is_expected.to exist }
|
|
27
|
+
it { is_expected.to be_a_migration }
|
|
28
|
+
it { is_expected.to contain /def change/ }
|
|
29
|
+
it { is_expected.to contain /add_column :users, :second_factor_attempts_count, :integer, default: 0/ }
|
|
30
|
+
it { is_expected.to contain /add_column :users, :encrypted_otp_secret_key, :string/ }
|
|
31
|
+
it { is_expected.to contain /add_column :users, :encrypted_otp_secret_key_iv, :string/ }
|
|
32
|
+
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/ }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|