devise-2fa 0.1.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/.gitignore +36 -0
- data/.hound.yml +2 -0
- data/.ruby-style.yml +1248 -0
- data/.travis.yml +28 -0
- data/Gemfile +25 -0
- data/LICENSE +21 -0
- data/README.md +130 -0
- data/Rakefile +41 -0
- data/app/controllers/devise/credentials_controller.rb +100 -0
- data/app/controllers/devise/tokens_controller.rb +99 -0
- data/app/views/devise/credentials/refresh.html.erb +20 -0
- data/app/views/devise/credentials/show.html.erb +23 -0
- data/app/views/devise/tokens/_token_secret.html.erb +19 -0
- data/app/views/devise/tokens/_trusted_devices.html.erb +10 -0
- data/app/views/devise/tokens/recovery.html.erb +21 -0
- data/app/views/devise/tokens/recovery_codes.text.erb +3 -0
- data/app/views/devise/tokens/show.html.erb +19 -0
- data/config/locales/en.yml +57 -0
- data/devise-2fa.gemspec +27 -0
- data/lib/devise-2fa.rb +74 -0
- data/lib/devise-2fa/version.rb +5 -0
- data/lib/devise_two_factorable/controllers/helpers.rb +136 -0
- data/lib/devise_two_factorable/controllers/url_helpers.rb +30 -0
- data/lib/devise_two_factorable/engine.rb +22 -0
- data/lib/devise_two_factorable/helpers.rb +136 -0
- data/lib/devise_two_factorable/hooks.rb +11 -0
- data/lib/devise_two_factorable/hooks/sessions.rb +49 -0
- data/lib/devise_two_factorable/mapping.rb +12 -0
- data/lib/devise_two_factorable/models/two_factorable.rb +131 -0
- data/lib/devise_two_factorable/routes.rb +26 -0
- data/lib/devise_two_factorable/two_factorable.rb +131 -0
- data/lib/generators/active_record/devise_two_factor_generator.rb +32 -0
- data/lib/generators/active_record/templates/migration.rb +27 -0
- data/lib/generators/devise_two_factor/devise_two_factor_generator.rb +16 -0
- data/lib/generators/devise_two_factor/install_generator.rb +52 -0
- data/lib/generators/devise_two_factor/views_generator.rb +19 -0
- data/lib/generators/mongoid/devise_two_factor_generator.rb +34 -0
- data/test/dummy/README.rdoc +261 -0
- data/test/dummy/Rakefile +7 -0
- data/test/dummy/app/assets/javascripts/application.js +13 -0
- data/test/dummy/app/assets/stylesheets/application.css +13 -0
- data/test/dummy/app/controllers/application_controller.rb +4 -0
- data/test/dummy/app/controllers/posts_controller.rb +83 -0
- data/test/dummy/app/helpers/application_helper.rb +2 -0
- data/test/dummy/app/helpers/posts_helper.rb +2 -0
- data/test/dummy/app/mailers/.gitkeep +0 -0
- data/test/dummy/app/models/post.rb +2 -0
- data/test/dummy/app/models/user.rb +20 -0
- data/test/dummy/app/views/layouts/application.html.erb +14 -0
- data/test/dummy/app/views/posts/_form.html.erb +25 -0
- data/test/dummy/app/views/posts/edit.html.erb +6 -0
- data/test/dummy/app/views/posts/index.html.erb +25 -0
- data/test/dummy/app/views/posts/new.html.erb +5 -0
- data/test/dummy/app/views/posts/show.html.erb +15 -0
- data/test/dummy/config.ru +4 -0
- data/test/dummy/config/application.rb +67 -0
- data/test/dummy/config/boot.rb +10 -0
- data/test/dummy/config/database.yml +25 -0
- data/test/dummy/config/environment.rb +5 -0
- data/test/dummy/config/environments/development.rb +37 -0
- data/test/dummy/config/environments/production.rb +73 -0
- data/test/dummy/config/environments/test.rb +36 -0
- data/test/dummy/config/initializers/backtrace_silencers.rb +7 -0
- data/test/dummy/config/initializers/devise.rb +251 -0
- data/test/dummy/config/initializers/inflections.rb +15 -0
- data/test/dummy/config/initializers/mime_types.rb +5 -0
- data/test/dummy/config/initializers/secret_token.rb +8 -0
- data/test/dummy/config/initializers/session_store.rb +8 -0
- data/test/dummy/config/initializers/wrap_parameters.rb +14 -0
- data/test/dummy/config/locales/en.yml +5 -0
- data/test/dummy/config/routes.rb +6 -0
- data/test/dummy/db/migrate/20130125101430_create_users.rb +9 -0
- data/test/dummy/db/migrate/20130131092406_add_devise_to_users.rb +52 -0
- data/test/dummy/db/migrate/20130131142320_create_posts.rb +10 -0
- data/test/dummy/db/migrate/20130131160351_devise_otp_add_to_users.rb +28 -0
- data/test/dummy/lib/assets/.gitkeep +0 -0
- data/test/dummy/public/404.html +26 -0
- data/test/dummy/public/422.html +26 -0
- data/test/dummy/public/500.html +25 -0
- data/test/dummy/public/favicon.ico +0 -0
- data/test/dummy/script/rails +6 -0
- data/test/integration/persistence_test.rb +63 -0
- data/test/integration/refresh_test.rb +103 -0
- data/test/integration/sign_in_test.rb +85 -0
- data/test/integration/token_test.rb +30 -0
- data/test/integration_tests_helper.rb +64 -0
- data/test/model_tests_helper.rb +20 -0
- data/test/models/two_factorable_test.rb +120 -0
- data/test/orm/active_record.rb +4 -0
- data/test/orm/mongoid.rb +13 -0
- data/test/support/mongoid.yml +6 -0
- data/test/support/symmetric_encryption.yml +70 -0
- data/test/test_helper.rb +18 -0
- metadata +269 -0
@@ -0,0 +1,22 @@
|
|
1
|
+
module DeviseTwoFactorable
|
2
|
+
class Engine < ::Rails::Engine
|
3
|
+
ActiveSupport.on_load(:action_controller) do
|
4
|
+
include DeviseTwoFactorable::Controllers::UrlHelpers
|
5
|
+
include DeviseTwoFactorable::Controllers::Helpers
|
6
|
+
end
|
7
|
+
ActiveSupport.on_load(:action_view) do
|
8
|
+
include DeviseTwoFactorable::Controllers::UrlHelpers
|
9
|
+
include DeviseTwoFactorable::Controllers::Helpers
|
10
|
+
end
|
11
|
+
|
12
|
+
# We use to_prepare instead of after_initialize here because Devise is a Rails engine;
|
13
|
+
config.to_prepare do
|
14
|
+
DeviseTwoFactorable::Hooks.apply
|
15
|
+
end
|
16
|
+
|
17
|
+
# extend mapping with after_initialize because is not reloaded
|
18
|
+
config.after_initialize do
|
19
|
+
Devise::Mapping.send :prepend, DeviseTwoFactorable::Mapping
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,136 @@
|
|
1
|
+
require 'rqrcode'
|
2
|
+
require 'base64'
|
3
|
+
|
4
|
+
module DeviseTwoFactorable
|
5
|
+
module Controllers
|
6
|
+
module Helpers
|
7
|
+
def authenticate_scope!
|
8
|
+
send(:"authenticate_#{resource_name}!", force: true)
|
9
|
+
self.resource = send("current_#{resource_name}")
|
10
|
+
end
|
11
|
+
|
12
|
+
#
|
13
|
+
# similar to DeviseController#set_flash_message, but sets the scope inside
|
14
|
+
# the otp controller
|
15
|
+
#
|
16
|
+
def otp_set_flash_message(key, kind, options = {})
|
17
|
+
options[:scope] ||= "devise.two_factor.#{controller_name}"
|
18
|
+
options[:default] = Array(options[:default]).unshift(kind.to_sym)
|
19
|
+
options[:resource_name] = resource_name
|
20
|
+
options = devise_i18n_options(options) if respond_to?(:devise_i18n_options, true)
|
21
|
+
message = I18n.t("#{options[:resource_name]}.#{kind}", options)
|
22
|
+
flash[key] = message if message.present?
|
23
|
+
end
|
24
|
+
|
25
|
+
def otp_t
|
26
|
+
end
|
27
|
+
|
28
|
+
def trusted_devices_enabled?
|
29
|
+
resource.class.otp_trust_persistence && (resource.class.otp_trust_persistence > 0)
|
30
|
+
end
|
31
|
+
|
32
|
+
def recovery_enabled?
|
33
|
+
resource_class.otp_recovery_tokens && (resource_class.otp_recovery_tokens > 0)
|
34
|
+
end
|
35
|
+
|
36
|
+
#
|
37
|
+
# Sanity check for resource validity
|
38
|
+
#
|
39
|
+
def ensure_resource!
|
40
|
+
raise ArgumentError, 'Should not happen' if resource.nil?
|
41
|
+
end
|
42
|
+
|
43
|
+
# FIXME: do cookies and persistence need to be scoped? probably
|
44
|
+
|
45
|
+
#
|
46
|
+
# check if the resource needs a credentials refresh. IE, they need to be asked a password again to access
|
47
|
+
# this resource.
|
48
|
+
#
|
49
|
+
def needs_credentials_refresh?(resource)
|
50
|
+
return false unless resource.class.otp_credentials_refresh
|
51
|
+
|
52
|
+
(!session[otp_scoped_refresh_property].present? ||
|
53
|
+
(session[otp_scoped_refresh_property] < DateTime.now)).tap { |need| otp_set_refresh_return_url if need }
|
54
|
+
end
|
55
|
+
|
56
|
+
#
|
57
|
+
# credentials are refreshed
|
58
|
+
#
|
59
|
+
def otp_refresh_credentials_for(resource)
|
60
|
+
return false unless resource.class.otp_credentials_refresh
|
61
|
+
session[otp_scoped_refresh_property] = (Time.now + resource.class.otp_credentials_refresh)
|
62
|
+
end
|
63
|
+
|
64
|
+
#
|
65
|
+
# is the current browser trusted?
|
66
|
+
#
|
67
|
+
def is_otp_trusted_device_for?(resource)
|
68
|
+
return false unless resource.class.otp_trust_persistence
|
69
|
+
if cookies[otp_scoped_persistence_cookie].present?
|
70
|
+
cookies.signed[otp_scoped_persistence_cookie] ==
|
71
|
+
[resource.to_key, resource.authenticatable_salt, resource.otp_persistence_seed]
|
72
|
+
else
|
73
|
+
false
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
#
|
78
|
+
# make the current browser trusted
|
79
|
+
#
|
80
|
+
def otp_set_trusted_device_for(resource)
|
81
|
+
return unless resource.class.otp_trust_persistence
|
82
|
+
cookies.signed[otp_scoped_persistence_cookie] = {
|
83
|
+
httponly: true,
|
84
|
+
expires: Time.now + resource.class.otp_trust_persistence,
|
85
|
+
value: [resource.to_key, resource.authenticatable_salt, resource.otp_persistence_seed]
|
86
|
+
}
|
87
|
+
end
|
88
|
+
|
89
|
+
def otp_set_refresh_return_url
|
90
|
+
session[otp_scoped_refresh_return_url_property] = request.fullpath
|
91
|
+
end
|
92
|
+
|
93
|
+
def otp_fetch_refresh_return_url
|
94
|
+
session.delete(otp_scoped_refresh_return_url_property) { :root }
|
95
|
+
end
|
96
|
+
|
97
|
+
def otp_scoped_refresh_return_url_property
|
98
|
+
"otp_#{resource_name}refresh_return_url".to_sym
|
99
|
+
end
|
100
|
+
|
101
|
+
def otp_scoped_refresh_property
|
102
|
+
"otp_#{resource_name}refresh_after".to_sym
|
103
|
+
end
|
104
|
+
|
105
|
+
def otp_scoped_persistence_cookie
|
106
|
+
"otp_#{resource_name}_device_trusted"
|
107
|
+
end
|
108
|
+
|
109
|
+
#
|
110
|
+
# make the current browser NOT trusted
|
111
|
+
#
|
112
|
+
def otp_clear_trusted_device_for(_resource)
|
113
|
+
cookies.delete(otp_scoped_persistence_cookie)
|
114
|
+
end
|
115
|
+
|
116
|
+
#
|
117
|
+
# clears the persistence list for this kind of resource
|
118
|
+
#
|
119
|
+
def otp_reset_persistence_for(resource)
|
120
|
+
otp_clear_trusted_device_for(resource)
|
121
|
+
resource.reset_otp_persistence!
|
122
|
+
end
|
123
|
+
|
124
|
+
#
|
125
|
+
# returns the URL for the QR Code to initialize the Authenticator device
|
126
|
+
#
|
127
|
+
def otp_authenticator_token_image(resource)
|
128
|
+
data = resource.otp_provisioning_uri
|
129
|
+
qrcode = RQRCode::QRCode.new(data, level: :m, mode: :byte_8bit)
|
130
|
+
png = qrcode.as_png(fill: 'white', color: 'black', border_modules: 1, module_px_size: 4)
|
131
|
+
url = "data:image/png;base64,#{Base64.encode64(png.to_s).strip}"
|
132
|
+
image_tag(url, alt: 'OTP Authenticator QRCode')
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
module DeviseTwoFactorable::Hooks
|
2
|
+
module Sessions
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
include DeviseTwoFactorable::Controllers::UrlHelpers
|
5
|
+
#
|
6
|
+
# replaces Devise::SessionsController#create
|
7
|
+
#
|
8
|
+
def create
|
9
|
+
resource = warden.authenticate!(auth_options)
|
10
|
+
|
11
|
+
devise_stored_location = stored_location_for(resource) # Grab the current stored location before it gets lost by warden.logout
|
12
|
+
|
13
|
+
otp_refresh_credentials_for(resource)
|
14
|
+
|
15
|
+
if otp_challenge_required_on?(resource)
|
16
|
+
challenge = resource.generate_otp_challenge!
|
17
|
+
warden.logout
|
18
|
+
store_location_for(resource, devise_stored_location) # restore the stored location
|
19
|
+
respond_with resource, location: credential_path_for(resource, challenge: challenge)
|
20
|
+
elsif otp_mandatory_on?(resource) # if mandatory, log in user but send him to the must activate otp
|
21
|
+
set_flash_message(:notice, :signed_in_but_otp) if is_navigational_format?
|
22
|
+
sign_in(resource_name, resource)
|
23
|
+
respond_with resource, location: token_path_for(resource)
|
24
|
+
else
|
25
|
+
super
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
#
|
32
|
+
# resource should be challenged for otp
|
33
|
+
#
|
34
|
+
def otp_challenge_required_on?(resource)
|
35
|
+
return false unless resource.respond_to?(:otp_enabled) && resource.respond_to?(:otp_auth_secret)
|
36
|
+
resource.otp_enabled && !is_otp_trusted_device_for?(resource)
|
37
|
+
end
|
38
|
+
|
39
|
+
#
|
40
|
+
# the resource -should- have otp turned on, but it isn't
|
41
|
+
#
|
42
|
+
def otp_mandatory_on?(resource)
|
43
|
+
return true if resource.class.otp_mandatory
|
44
|
+
return false unless resource.respond_to?(:otp_mandatory)
|
45
|
+
|
46
|
+
resource.otp_mandatory && !resource.otp_enabled
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
module DeviseTwoFactorable
|
2
|
+
module Mapping
|
3
|
+
private
|
4
|
+
|
5
|
+
def default_controllers(options)
|
6
|
+
options[:controllers] ||= {}
|
7
|
+
options[:controllers][:tokens] ||= 'devise/tokens'
|
8
|
+
options[:controllers][:credentials] ||= 'devise/credentials'
|
9
|
+
super
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,131 @@
|
|
1
|
+
require 'rotp'
|
2
|
+
|
3
|
+
module Devise::Models
|
4
|
+
module TwoFactorable
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
included do
|
8
|
+
before_validation :generate_otp_auth_secret, on: :create
|
9
|
+
before_validation :generate_otp_persistence_seed, on: :create
|
10
|
+
scope :with_valid_otp_challenge, ->{ where(:otp_challenge_expires.gt => Time.current) }
|
11
|
+
end
|
12
|
+
|
13
|
+
module ClassMethods
|
14
|
+
::Devise::Models.config(self, :otp_authentication_timeout, :otp_drift_window, :otp_trust_persistence,
|
15
|
+
:otp_mandatory, :otp_credentials_refresh, :otp_issuer, :otp_recovery_tokens)
|
16
|
+
|
17
|
+
def find_valid_otp_challenge(challenge)
|
18
|
+
with_valid_otp_challenge.where(otp_session_challenge: challenge).first
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def time_based_otp
|
23
|
+
@time_based_otp ||= ROTP::TOTP.new(otp_auth_secret, issuer: (self.class.otp_issuer || Rails.application.class.parent_name).to_s)
|
24
|
+
end
|
25
|
+
|
26
|
+
def recovery_otp
|
27
|
+
@recovery_otp ||= ROTP::HOTP.new(otp_recovery_secret)
|
28
|
+
end
|
29
|
+
|
30
|
+
def otp_provisioning_uri
|
31
|
+
time_based_otp.provisioning_uri(otp_provisioning_identifier)
|
32
|
+
end
|
33
|
+
|
34
|
+
def otp_provisioning_identifier
|
35
|
+
email
|
36
|
+
end
|
37
|
+
|
38
|
+
def reset_otp_credentials
|
39
|
+
@time_based_otp = nil
|
40
|
+
@recovery_otp = nil
|
41
|
+
generate_otp_auth_secret
|
42
|
+
reset_otp_persistence
|
43
|
+
update_attributes!(otp_enabled: false,
|
44
|
+
otp_session_challenge: nil,
|
45
|
+
otp_challenge_expires: nil,
|
46
|
+
otp_recovery_counter: 0)
|
47
|
+
end
|
48
|
+
|
49
|
+
def reset_otp_credentials!
|
50
|
+
reset_otp_credentials
|
51
|
+
save!
|
52
|
+
end
|
53
|
+
|
54
|
+
def reset_otp_persistence
|
55
|
+
generate_otp_persistence_seed
|
56
|
+
end
|
57
|
+
|
58
|
+
def reset_otp_persistence!
|
59
|
+
reset_otp_persistence
|
60
|
+
save!
|
61
|
+
end
|
62
|
+
|
63
|
+
def enable_otp!
|
64
|
+
reset_otp_credentials! if otp_persistence_seed.nil?
|
65
|
+
|
66
|
+
update_attributes!(otp_enabled: true, otp_enabled_on: Time.now)
|
67
|
+
end
|
68
|
+
|
69
|
+
def disable_otp!
|
70
|
+
update_attributes!(otp_enabled: false, otp_enabled_on: nil)
|
71
|
+
end
|
72
|
+
|
73
|
+
def generate_otp_challenge!(expires = nil)
|
74
|
+
update_attributes!(otp_session_challenge: SecureRandom.hex,
|
75
|
+
otp_challenge_expires: DateTime.now + (expires || self.class.otp_authentication_timeout))
|
76
|
+
otp_session_challenge
|
77
|
+
end
|
78
|
+
|
79
|
+
def otp_challenge_valid?
|
80
|
+
(otp_challenge_expires.nil? || otp_challenge_expires > Time.now)
|
81
|
+
end
|
82
|
+
|
83
|
+
def validate_otp_token(token, recovery = false)
|
84
|
+
if recovery
|
85
|
+
validate_otp_recovery_token token
|
86
|
+
else
|
87
|
+
validate_otp_time_token token
|
88
|
+
end
|
89
|
+
end
|
90
|
+
alias valid_otp_token? validate_otp_token
|
91
|
+
|
92
|
+
def validate_otp_time_token(token)
|
93
|
+
return false if token.blank?
|
94
|
+
validate_otp_token_with_drift(token)
|
95
|
+
end
|
96
|
+
alias valid_otp_time_token? validate_otp_time_token
|
97
|
+
|
98
|
+
def next_otp_recovery_tokens(number = self.class.otp_recovery_tokens)
|
99
|
+
(otp_recovery_counter..otp_recovery_counter + number).inject({}) do |h, index|
|
100
|
+
h[index] = recovery_otp.at(index)
|
101
|
+
h
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
def validate_otp_recovery_token(token)
|
106
|
+
recovery_otp.verify(token, otp_recovery_counter).tap do
|
107
|
+
self.otp_recovery_counter += 1
|
108
|
+
save!
|
109
|
+
end
|
110
|
+
end
|
111
|
+
alias valid_otp_recovery_token? validate_otp_recovery_token
|
112
|
+
|
113
|
+
private
|
114
|
+
|
115
|
+
def validate_otp_token_with_drift(token)
|
116
|
+
# should be centered around saved drift
|
117
|
+
(-self.class.otp_drift_window..self.class.otp_drift_window).any? do |drift|
|
118
|
+
time_based_otp.verify(token, Time.now.ago(30 * drift))
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
def generate_otp_persistence_seed
|
123
|
+
self.otp_persistence_seed = SecureRandom.hex
|
124
|
+
end
|
125
|
+
|
126
|
+
def generate_otp_auth_secret
|
127
|
+
self.otp_auth_secret = ROTP::Base32.random_base32
|
128
|
+
self.otp_recovery_secret = ROTP::Base32.random_base32
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module ActionDispatch::Routing
|
2
|
+
class Mapper
|
3
|
+
protected
|
4
|
+
|
5
|
+
#########
|
6
|
+
|
7
|
+
def devise_token(mapping, controllers)
|
8
|
+
resource :token, only: [:show, :update, :destroy],
|
9
|
+
path: mapping.path_names[:token], controller: controllers[:tokens] do
|
10
|
+
if Devise.otp_trust_persistence
|
11
|
+
get :persistence, action: 'get_persistence'
|
12
|
+
post :persistence, action: 'clear_persistence'
|
13
|
+
delete :persistence, action: 'delete_persistence'
|
14
|
+
end
|
15
|
+
|
16
|
+
get :recovery
|
17
|
+
end
|
18
|
+
|
19
|
+
resource :credential, only: [:show, :update],
|
20
|
+
path: mapping.path_names[:credential], controller: controllers[:credentials] do
|
21
|
+
get :refresh, action: 'get_refresh'
|
22
|
+
put :refresh, action: 'set_refresh'
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,131 @@
|
|
1
|
+
require 'rotp'
|
2
|
+
|
3
|
+
module Devise::Models
|
4
|
+
module TwoFactorable
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
included do
|
8
|
+
before_validation :generate_otp_auth_secret, on: :create
|
9
|
+
before_validation :generate_otp_persistence_seed, on: :create
|
10
|
+
scope :with_valid_otp_challenge, ->{ where(:otp_challenge_expires.gt => Time.current) }
|
11
|
+
end
|
12
|
+
|
13
|
+
module ClassMethods
|
14
|
+
::Devise::Models.config(self, :otp_authentication_timeout, :otp_drift_window, :otp_trust_persistence,
|
15
|
+
:otp_mandatory, :otp_credentials_refresh, :otp_issuer, :otp_recovery_tokens)
|
16
|
+
|
17
|
+
def find_valid_otp_challenge(challenge)
|
18
|
+
with_valid_otp_challenge.where(otp_session_challenge: challenge).first
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def time_based_otp
|
23
|
+
@time_based_otp ||= ROTP::TOTP.new(otp_auth_secret, issuer: (self.class.otp_issuer || Rails.application.class.parent_name).to_s)
|
24
|
+
end
|
25
|
+
|
26
|
+
def recovery_otp
|
27
|
+
@recovery_otp ||= ROTP::HOTP.new(otp_recovery_secret)
|
28
|
+
end
|
29
|
+
|
30
|
+
def otp_provisioning_uri
|
31
|
+
time_based_otp.provisioning_uri(otp_provisioning_identifier)
|
32
|
+
end
|
33
|
+
|
34
|
+
def otp_provisioning_identifier
|
35
|
+
email
|
36
|
+
end
|
37
|
+
|
38
|
+
def reset_otp_credentials
|
39
|
+
@time_based_otp = nil
|
40
|
+
@recovery_otp = nil
|
41
|
+
generate_otp_auth_secret
|
42
|
+
reset_otp_persistence
|
43
|
+
update_attributes!(otp_enabled: false,
|
44
|
+
otp_session_challenge: nil,
|
45
|
+
otp_challenge_expires: nil,
|
46
|
+
otp_recovery_counter: 0)
|
47
|
+
end
|
48
|
+
|
49
|
+
def reset_otp_credentials!
|
50
|
+
reset_otp_credentials
|
51
|
+
save!
|
52
|
+
end
|
53
|
+
|
54
|
+
def reset_otp_persistence
|
55
|
+
generate_otp_persistence_seed
|
56
|
+
end
|
57
|
+
|
58
|
+
def reset_otp_persistence!
|
59
|
+
reset_otp_persistence
|
60
|
+
save!
|
61
|
+
end
|
62
|
+
|
63
|
+
def enable_otp!
|
64
|
+
reset_otp_credentials! if otp_persistence_seed.nil?
|
65
|
+
|
66
|
+
update_attributes!(otp_enabled: true, otp_enabled_on: Time.now)
|
67
|
+
end
|
68
|
+
|
69
|
+
def disable_otp!
|
70
|
+
update_attributes!(otp_enabled: false, otp_enabled_on: nil)
|
71
|
+
end
|
72
|
+
|
73
|
+
def generate_otp_challenge!(expires = nil)
|
74
|
+
update_attributes!(otp_session_challenge: SecureRandom.hex,
|
75
|
+
otp_challenge_expires: DateTime.now + (expires || self.class.otp_authentication_timeout))
|
76
|
+
otp_session_challenge
|
77
|
+
end
|
78
|
+
|
79
|
+
def otp_challenge_valid?
|
80
|
+
(otp_challenge_expires.nil? || otp_challenge_expires > Time.now)
|
81
|
+
end
|
82
|
+
|
83
|
+
def validate_otp_token(token, recovery = false)
|
84
|
+
if recovery
|
85
|
+
validate_otp_recovery_token token
|
86
|
+
else
|
87
|
+
validate_otp_time_token token
|
88
|
+
end
|
89
|
+
end
|
90
|
+
alias valid_otp_token? validate_otp_token
|
91
|
+
|
92
|
+
def validate_otp_time_token(token)
|
93
|
+
return false if token.blank?
|
94
|
+
validate_otp_token_with_drift(token)
|
95
|
+
end
|
96
|
+
alias valid_otp_time_token? validate_otp_time_token
|
97
|
+
|
98
|
+
def next_otp_recovery_tokens(number = self.class.otp_recovery_tokens)
|
99
|
+
(otp_recovery_counter..otp_recovery_counter + number).inject({}) do |h, index|
|
100
|
+
h[index] = recovery_otp.at(index)
|
101
|
+
h
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
def validate_otp_recovery_token(token)
|
106
|
+
recovery_otp.verify(token, otp_recovery_counter).tap do
|
107
|
+
self.otp_recovery_counter += 1
|
108
|
+
save!
|
109
|
+
end
|
110
|
+
end
|
111
|
+
alias valid_otp_recovery_token? validate_otp_recovery_token
|
112
|
+
|
113
|
+
private
|
114
|
+
|
115
|
+
def validate_otp_token_with_drift(token)
|
116
|
+
# should be centered around saved drift
|
117
|
+
(-self.class.otp_drift_window..self.class.otp_drift_window).any? do |drift|
|
118
|
+
time_based_otp.verify(token, Time.now.ago(30 * drift))
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
def generate_otp_persistence_seed
|
123
|
+
self.otp_persistence_seed = SecureRandom.hex
|
124
|
+
end
|
125
|
+
|
126
|
+
def generate_otp_auth_secret
|
127
|
+
self.otp_auth_secret = ROTP::Base32.random_base32
|
128
|
+
self.otp_recovery_secret = ROTP::Base32.random_base32
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|