devise-multi-factor 3.1.5

Sign up to get free protection for your applications and to get access to all the features.
Files changed (101) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +21 -0
  3. data/.github/workflows/gem-push.yml +42 -0
  4. data/.gitignore +23 -0
  5. data/.rubocop.yml +295 -0
  6. data/.travis.yml +28 -0
  7. data/CHANGELOG.md +119 -0
  8. data/Gemfile +32 -0
  9. data/LICENSE +19 -0
  10. data/README.md +322 -0
  11. data/Rakefile +12 -0
  12. data/app/controllers/devise/totp_controller.rb +79 -0
  13. data/app/controllers/devise/two_factor_authentication_controller.rb +84 -0
  14. data/app/views/devise/two_factor_authentication/max_login_attempts_reached.html.erb +3 -0
  15. data/app/views/devise/two_factor_authentication/new.html.erb +14 -0
  16. data/app/views/devise/two_factor_authentication/show.html.erb +19 -0
  17. data/config/locales/de.yml +8 -0
  18. data/config/locales/en.yml +8 -0
  19. data/config/locales/es.yml +8 -0
  20. data/config/locales/fr.yml +8 -0
  21. data/config/locales/ru.yml +8 -0
  22. data/devise-multi-factor.gemspec +40 -0
  23. data/lib/devise-multi-factor.rb +1 -0
  24. data/lib/devise_multi_factor.rb +56 -0
  25. data/lib/devise_multi_factor/controllers/helpers.rb +57 -0
  26. data/lib/devise_multi_factor/hooks/two_factor_authenticatable.rb +17 -0
  27. data/lib/devise_multi_factor/models/totp_enrollable.rb +7 -0
  28. data/lib/devise_multi_factor/models/two_factor_authenticatable.rb +142 -0
  29. data/lib/devise_multi_factor/orm/active_record.rb +14 -0
  30. data/lib/devise_multi_factor/rails.rb +7 -0
  31. data/lib/devise_multi_factor/routes.rb +15 -0
  32. data/lib/devise_multi_factor/schema.rb +23 -0
  33. data/lib/devise_multi_factor/version.rb +3 -0
  34. data/lib/generators/active_record/devise_multi_factor_generator.rb +13 -0
  35. data/lib/generators/active_record/templates/migration.rb +11 -0
  36. data/lib/generators/devise_multi_factor/devise_multi_factor_generator.rb +17 -0
  37. data/spec/controllers/two_factor_authentication_controller_spec.rb +41 -0
  38. data/spec/features/two_factor_authenticatable_spec.rb +237 -0
  39. data/spec/generators/active_record/devise_multi_factor_generator_spec.rb +34 -0
  40. data/spec/lib/devise_multi_factor/models/two_factor_authenticatable_spec.rb +282 -0
  41. data/spec/rails_app/.gitignore +3 -0
  42. data/spec/rails_app/README.md +3 -0
  43. data/spec/rails_app/Rakefile +7 -0
  44. data/spec/rails_app/app/assets/config/manifest.js +2 -0
  45. data/spec/rails_app/app/assets/javascripts/application.js +1 -0
  46. data/spec/rails_app/app/assets/stylesheets/application.css +4 -0
  47. data/spec/rails_app/app/controllers/application_controller.rb +3 -0
  48. data/spec/rails_app/app/controllers/home_controller.rb +10 -0
  49. data/spec/rails_app/app/helpers/application_helper.rb +8 -0
  50. data/spec/rails_app/app/mailers/.gitkeep +0 -0
  51. data/spec/rails_app/app/models/.gitkeep +0 -0
  52. data/spec/rails_app/app/models/admin.rb +6 -0
  53. data/spec/rails_app/app/models/encrypted_user.rb +7 -0
  54. data/spec/rails_app/app/models/guest_user.rb +7 -0
  55. data/spec/rails_app/app/models/test_user.rb +38 -0
  56. data/spec/rails_app/app/models/user.rb +18 -0
  57. data/spec/rails_app/app/views/home/dashboard.html.erb +11 -0
  58. data/spec/rails_app/app/views/home/index.html.erb +3 -0
  59. data/spec/rails_app/app/views/layouts/application.html.erb +20 -0
  60. data/spec/rails_app/config.ru +4 -0
  61. data/spec/rails_app/config/application.rb +61 -0
  62. data/spec/rails_app/config/boot.rb +10 -0
  63. data/spec/rails_app/config/database.yml +19 -0
  64. data/spec/rails_app/config/environment.rb +5 -0
  65. data/spec/rails_app/config/environments/development.rb +28 -0
  66. data/spec/rails_app/config/environments/production.rb +68 -0
  67. data/spec/rails_app/config/environments/test.rb +41 -0
  68. data/spec/rails_app/config/initializers/backtrace_silencers.rb +7 -0
  69. data/spec/rails_app/config/initializers/cookies_serializer.rb +3 -0
  70. data/spec/rails_app/config/initializers/devise.rb +258 -0
  71. data/spec/rails_app/config/initializers/inflections.rb +15 -0
  72. data/spec/rails_app/config/initializers/mime_types.rb +5 -0
  73. data/spec/rails_app/config/initializers/secret_token.rb +7 -0
  74. data/spec/rails_app/config/initializers/session_store.rb +8 -0
  75. data/spec/rails_app/config/initializers/wrap_parameters.rb +14 -0
  76. data/spec/rails_app/config/locales/devise.en.yml +59 -0
  77. data/spec/rails_app/config/locales/en.yml +5 -0
  78. data/spec/rails_app/config/routes.rb +65 -0
  79. data/spec/rails_app/db/migrate/20140403184646_devise_create_users.rb +42 -0
  80. data/spec/rails_app/db/migrate/20140407172619_two_factor_authentication_add_to_users.rb +17 -0
  81. data/spec/rails_app/db/migrate/20140407215513_add_nickanme_to_users.rb +7 -0
  82. data/spec/rails_app/db/migrate/20151224171231_add_encrypted_columns_to_user.rb +7 -0
  83. data/spec/rails_app/db/migrate/20151224180310_populate_otp_column.rb +19 -0
  84. data/spec/rails_app/db/migrate/20151228230340_remove_otp_secret_key_from_user.rb +5 -0
  85. data/spec/rails_app/db/migrate/20160209032439_devise_create_admins.rb +42 -0
  86. data/spec/rails_app/db/schema.rb +55 -0
  87. data/spec/rails_app/lib/assets/.gitkeep +0 -0
  88. data/spec/rails_app/lib/sms_provider.rb +17 -0
  89. data/spec/rails_app/public/404.html +26 -0
  90. data/spec/rails_app/public/422.html +26 -0
  91. data/spec/rails_app/public/500.html +25 -0
  92. data/spec/rails_app/public/favicon.ico +0 -0
  93. data/spec/rails_app/script/rails +6 -0
  94. data/spec/spec_helper.rb +26 -0
  95. data/spec/support/authenticated_model_helper.rb +29 -0
  96. data/spec/support/capybara.rb +3 -0
  97. data/spec/support/controller_helper.rb +16 -0
  98. data/spec/support/features_spec_helper.rb +42 -0
  99. data/spec/support/sms_provider.rb +5 -0
  100. data/spec/support/totp_helper.rb +11 -0
  101. metadata +315 -0
@@ -0,0 +1,84 @@
1
+ require 'devise/version'
2
+
3
+ class Devise::TwoFactorAuthenticationController < DeviseController
4
+ prepend_before_action :authenticate_scope!
5
+ before_action :prepare_and_validate, :handle_two_factor_authentication
6
+
7
+ def show
8
+ end
9
+
10
+ def update
11
+ render :show and return if params[:code].nil?
12
+
13
+ if resource.authenticate_otp(params[:code])
14
+ after_two_factor_success_for(resource)
15
+ else
16
+ after_two_factor_fail_for(resource)
17
+ end
18
+ end
19
+
20
+ def resend_code
21
+ resource.send_new_otp
22
+ redirect_to send("#{resource_name}_two_factor_authentication_path"), notice: I18n.t('devise.two_factor_authentication.code_has_been_sent')
23
+ end
24
+
25
+ private
26
+
27
+ def after_two_factor_success_for(resource)
28
+ set_remember_two_factor_cookie(resource)
29
+
30
+ warden.session(resource_name)[DeviseMultiFactor::NEED_AUTHENTICATION] = false
31
+ # For compatability with devise versions below v4.2.0
32
+ # https://github.com/plataformatec/devise/commit/2044fffa25d781fcbaf090e7728b48b65c854ccb
33
+ if respond_to?(:bypass_sign_in)
34
+ bypass_sign_in(resource, scope: resource_name)
35
+ else
36
+ sign_in(resource_name, resource, bypass: true)
37
+ end
38
+ set_flash_message :notice, :success
39
+ resource.update_attribute(:second_factor_attempts_count, 0)
40
+
41
+ redirect_to after_two_factor_success_path_for(resource)
42
+ end
43
+
44
+ def set_remember_two_factor_cookie(resource)
45
+ expires_seconds = resource.class.remember_otp_session_for_seconds
46
+
47
+ if expires_seconds && expires_seconds > 0
48
+ cookies.signed[DeviseMultiFactor::REMEMBER_TFA_COOKIE_NAME] = {
49
+ value: "#{resource.class}-#{resource.public_send(Devise.second_factor_resource_id)}",
50
+ expires: expires_seconds.seconds.from_now
51
+ }
52
+ end
53
+ end
54
+
55
+ def after_two_factor_success_path_for(resource)
56
+ stored_location_for(resource_name) || :root
57
+ end
58
+
59
+ def after_two_factor_fail_for(resource)
60
+ resource.second_factor_attempts_count += 1
61
+ resource.save
62
+ set_flash_message :alert, :attempt_failed, now: true
63
+
64
+ if resource.max_login_attempts?
65
+ sign_out(resource)
66
+ render :max_login_attempts_reached
67
+ else
68
+ render :show
69
+ end
70
+ end
71
+
72
+ def authenticate_scope!
73
+ self.resource = send("current_#{resource_name}")
74
+ end
75
+
76
+ def prepare_and_validate
77
+ redirect_to :root and return if resource.nil?
78
+ @limit = resource.max_login_attempts
79
+ if resource.max_login_attempts?
80
+ sign_out(resource)
81
+ render :max_login_attempts_reached and return
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,3 @@
1
+ <h2><%= I18n.t("devise.two_factor_authentication.max_login_attempts_reached") %> = <%= @limit %>.</h2>
2
+ <p><%= I18n.t("devise.two_factor_authentication.contact_administrator") %></p>
3
+
@@ -0,0 +1,14 @@
1
+ Two Factor Authentication Setup
2
+
3
+ <p><%= flash[:error] %></p>
4
+
5
+ <%= image_tag(@qr_code) if @qr_code.present? %>
6
+
7
+ <p>Authenticator Secret: <%= @otp_secret %>
8
+
9
+ <%= form_for resource, url: send("#{resource_name}_two_factor_authentication_path"), method: :post do |f| %>
10
+ <label>Authenticator Code:</label>
11
+ <%= hidden_field_tag :otp_secret_signature, @otp_secret_signature %>
12
+ <%= text_field_tag :otp_attempt, '' %>
13
+ <%= f.submit %>
14
+ <% end %>
@@ -0,0 +1,19 @@
1
+ <% if resource.direct_otp %>
2
+ <h2>Enter the code that was sent to you</h2>
3
+ <% else %>
4
+ <h2>Enter the code from your authenticator app</h2>
5
+ <% end %>
6
+
7
+ <p><%= flash[:notice] %></p>
8
+
9
+ <%= form_tag([resource_name, :two_factor_authentication], :method => :put) do %>
10
+ <%= text_field_tag :code, '', autofocus: true %>
11
+ <%= submit_tag "Submit" %>
12
+ <% end %>
13
+
14
+ <% if resource.direct_otp %>
15
+ <%= link_to "Resend Code", send("resend_code_#{resource_name}_two_factor_authentication_path"), action: :get %>
16
+ <% else %>
17
+ <%= link_to "Send me a code instead", send("resend_code_#{resource_name}_two_factor_authentication_path"), action: :get %>
18
+ <% end %>
19
+ <%= link_to "Sign out", send("destroy_#{resource_name}_session_path"), :method => :delete %>
@@ -0,0 +1,8 @@
1
+ de:
2
+ devise:
3
+ two_factor_authentication:
4
+ success: "Ihre Zwei-Faktor-Authentifizierung war erfolgreich."
5
+ attempt_failed: "Authentifizierungsversuch fehlgeschlagen."
6
+ max_login_attempts_reached: "Ihr Zugang wurde ganz verweigert, da Sie Ihr Versuchslimit erreicht haben."
7
+ contact_administrator: "Kontaktieren Sie bitte einen Ihrer Administratoren."
8
+ code_has_been_sent: "Ihr Einmal-Passwort wurde verschickt."
@@ -0,0 +1,8 @@
1
+ en:
2
+ devise:
3
+ two_factor_authentication:
4
+ success: "Two factor authentication successful."
5
+ attempt_failed: "Attempt failed."
6
+ max_login_attempts_reached: "Access completely denied as you have reached your attempts limit"
7
+ contact_administrator: "Please contact your system administrator."
8
+ code_has_been_sent: "Your authentication code has been sent."
@@ -0,0 +1,8 @@
1
+ es:
2
+ devise:
3
+ two_factor_authentication:
4
+ success: "Autenticación multi-factor realizada exitosamente."
5
+ attempt_failed: "La autenticación ha fallado."
6
+ max_login_attempts_reached: "Has llegado al límite de intentos fallidos, acceso denegado."
7
+ contact_administrator: "Contacte a su administrador de sistema."
8
+ code_has_been_sent: "El código de autenticación ha sido enviado."
@@ -0,0 +1,8 @@
1
+ fr:
2
+ devise:
3
+ two_factor_authentication:
4
+ success: "Validation en deux étapes effectuée avec succès."
5
+ attempt_failed: "La connexion a échoué."
6
+ max_login_attempts_reached: "Limite de tentatives atteinte, accès refusé."
7
+ contact_administrator: "Merci de contacter votre administrateur système."
8
+ code_has_been_sent: "Votre code de validation envoyé."
@@ -0,0 +1,8 @@
1
+ ru:
2
+ devise:
3
+ two_factor_authentication:
4
+ success: "Двухфакторная авторизация успешно пройдена."
5
+ attempt_failed: "Неверный код."
6
+ max_login_attempts_reached: "Доступ заблокирован. Превышено число попыток авторизации"
7
+ contact_administrator: "Пожалуйста, свяжитесь с системным администратором."
8
+ code_has_been_sent: "Ваш персональный код был отправлен."
@@ -0,0 +1,40 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+ require "devise_multi_factor/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "devise-multi-factor"
7
+ s.version = DeviseMultiFactor::VERSION.dup
8
+ s.authors = ["Dmitrii Golub", "Alex Santos"]
9
+ s.email = ["hello@alexcsantos.com"]
10
+ s.homepage = "https://github.com/Colex/devise_multi_factor"
11
+ s.summary = %q{Two factor authentication plugin for devise}
12
+ s.description = <<-EOF
13
+ ### Features ###
14
+ * control sms code pattern
15
+ * configure max login attempts
16
+ * per user level control if he really need two factor authentication
17
+ * your own sms logic
18
+ EOF
19
+
20
+ s.rubyforge_project = "devise_multi_factor"
21
+
22
+ s.files = `git ls-files`.split("\n")
23
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
24
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
25
+ s.require_paths = ["lib"]
26
+
27
+ s.add_runtime_dependency 'rails', '>= 3.1.1'
28
+ s.add_runtime_dependency 'devise'
29
+ s.add_runtime_dependency 'randexp'
30
+ s.add_runtime_dependency 'rotp', '>= 4.0.0'
31
+ s.add_runtime_dependency 'encryptor'
32
+
33
+ s.add_development_dependency 'bundler'
34
+ s.add_development_dependency 'rake'
35
+ s.add_development_dependency 'rspec-rails', '>= 3.0.1'
36
+ s.add_development_dependency 'capybara', '~> 2.5'
37
+ s.add_development_dependency 'pry'
38
+ s.add_development_dependency 'rubocop'
39
+ s.add_development_dependency 'timecop'
40
+ end
@@ -0,0 +1 @@
1
+ require 'devise_multi_factor'
@@ -0,0 +1,56 @@
1
+ require 'devise_multi_factor/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_issuer
16
+ @@otp_issuer = nil
17
+
18
+ mattr_accessor :otp_length
19
+ @@otp_length = 6
20
+
21
+ mattr_accessor :direct_otp_length
22
+ @@direct_otp_length = 6
23
+
24
+ mattr_accessor :direct_otp_valid_for
25
+ @@direct_otp_valid_for = 5.minutes
26
+
27
+ mattr_accessor :remember_otp_session_for_seconds
28
+ @@remember_otp_session_for_seconds = 0
29
+
30
+ mattr_accessor :otp_secret_encryption_key
31
+ @@otp_secret_encryption_key = nil
32
+
33
+ mattr_accessor :second_factor_resource_id
34
+ @@second_factor_resource_id = 'id'
35
+
36
+ mattr_accessor :delete_cookie_on_logout
37
+ @@delete_cookie_on_logout = false
38
+ end
39
+
40
+ module DeviseMultiFactor
41
+ NEED_AUTHENTICATION = 'need_two_factor_authentication'
42
+ REMEMBER_TFA_COOKIE_NAME = "remember_tfa"
43
+
44
+ autoload :Schema, 'devise_multi_factor/schema'
45
+ module Controllers
46
+ autoload :Helpers, 'devise_multi_factor/controllers/helpers'
47
+ end
48
+ end
49
+
50
+ Devise.add_module :two_factor_authenticatable, :model => 'devise_multi_factor/models/two_factor_authenticatable', :controller => :two_factor_authentication, :route => :two_factor_authentication
51
+ Devise.add_module :totp_enrollable, model: 'devise_multi_factor/models/totp_enrollable', controller: :totp, route: :totp
52
+
53
+ require 'devise_multi_factor/orm/active_record' if defined?(ActiveRecord::Base)
54
+ require 'devise_multi_factor/routes'
55
+ require 'devise_multi_factor/models/two_factor_authenticatable'
56
+ require 'devise_multi_factor/rails'
@@ -0,0 +1,57 @@
1
+ module DeviseMultiFactor
2
+ module Controllers
3
+ module Helpers
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ before_action :handle_two_factor_authentication
8
+ end
9
+
10
+ private
11
+
12
+ def two_factor_authenticate!
13
+ Devise.mappings.keys.flatten.any? do |scope|
14
+ if signed_in?(scope) and warden.session(scope)[DeviseMultiFactor::NEED_AUTHENTICATION]
15
+ handle_failed_second_factor(scope)
16
+ end
17
+ end
18
+ end
19
+
20
+ def handle_two_factor_authentication
21
+ unless devise_controller?
22
+ two_factor_authenticate!
23
+ end
24
+ end
25
+
26
+ def handle_failed_second_factor(scope)
27
+ if request.format.present?
28
+ if request.format.html?
29
+ session["#{scope}_return_to"] = request.original_fullpath if request.get?
30
+ redirect_to two_factor_authentication_path_for(scope)
31
+ elsif request.format.json?
32
+ session["#{scope}_return_to"] = root_path(format: :html)
33
+ render json: { redirect_to: two_factor_authentication_path_for(scope) }, status: :unauthorized
34
+ end
35
+ else
36
+ head :unauthorized
37
+ end
38
+ end
39
+
40
+ def two_factor_authentication_path_for(resource_or_scope = nil)
41
+ scope = Devise::Mapping.find_scope!(resource_or_scope)
42
+ change_path = "#{scope}_two_factor_authentication_path"
43
+ send(change_path)
44
+ end
45
+ end
46
+ end
47
+ end
48
+
49
+ module Devise
50
+ module Controllers
51
+ module Helpers
52
+ def is_fully_authenticated?
53
+ !session["warden.user.user.session"].try(:[], DeviseMultiFactor::NEED_AUTHENTICATION)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,17 @@
1
+ Warden::Manager.after_authentication do |resource, auth, options|
2
+ if auth.env["action_dispatch.cookies"]
3
+ expected_cookie_value = "#{resource.class}-#{resource.public_send(Devise.second_factor_resource_id)}"
4
+ actual_cookie_value = auth.env["action_dispatch.cookies"].signed[DeviseMultiFactor::REMEMBER_TFA_COOKIE_NAME]
5
+ bypass_by_cookie = actual_cookie_value == expected_cookie_value
6
+ end
7
+
8
+ if resource.respond_to?(:need_two_factor_authentication?) && !bypass_by_cookie
9
+ if auth.session(options[:scope])[DeviseMultiFactor::NEED_AUTHENTICATION] = resource.need_two_factor_authentication?(auth.request)
10
+ resource.send_new_otp if resource.send_new_otp_after_login?
11
+ end
12
+ end
13
+ end
14
+
15
+ Warden::Manager.before_logout do |resource, auth, _options|
16
+ auth.cookies.delete DeviseMultiFactor::REMEMBER_TFA_COOKIE_NAME if Devise.delete_cookie_on_logout
17
+ end
@@ -0,0 +1,7 @@
1
+ module Devise
2
+ module Models
3
+ module TotpEnrollable
4
+ extend ActiveSupport::Concern
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,142 @@
1
+ require 'devise_multi_factor/hooks/two_factor_authenticatable'
2
+ require 'rotp'
3
+
4
+ module Devise
5
+ module Models
6
+ module TwoFactorAuthenticatable
7
+ extend ActiveSupport::Concern
8
+
9
+ module ClassMethods
10
+ def has_one_time_password(options = {})
11
+ include InstanceMethodsOnActivation
12
+
13
+ encrypt_options = {
14
+ key: otp_secret_encryption_key,
15
+ encrypted_attribute: 'encrypted_otp_secret_key',
16
+ }.compact
17
+ encrypt_options = encrypt_options.merge(options[:encrypt]) if options[:encrypt].is_a?(Hash)
18
+ encrypts(:otp_secret_key, encrypt_options || {})
19
+ end
20
+
21
+ def generate_totp_secret
22
+ # ROTP gem since version 5 to version 5.1
23
+ # at version 5.1 ROTP gem reinstates.
24
+ # Details: https://github.com/mdp/rotp/blob/master/CHANGELOG.md#510
25
+ ROTP::Base32.try(:random) || ROTP::Base32.random_base32
26
+ end
27
+
28
+ ::Devise::Models.config(
29
+ self, :max_login_attempts, :allowed_otp_drift_seconds, :otp_issuer, :otp_length,
30
+ :remember_otp_session_for_seconds, :otp_secret_encryption_key,
31
+ :direct_otp_length, :direct_otp_valid_for, :totp_timestamp, :delete_cookie_on_logout
32
+ )
33
+ end
34
+
35
+ module InstanceMethodsOnActivation
36
+ def authenticate_otp(code, options = {})
37
+ return true if direct_otp && authenticate_direct_otp(code)
38
+ return true if totp_enabled? && authenticate_totp(code, options)
39
+ false
40
+ end
41
+
42
+ def authenticate_direct_otp(code)
43
+ return false if direct_otp.nil? || direct_otp != code || direct_otp_expired?
44
+
45
+ clear_direct_otp
46
+ true
47
+ end
48
+
49
+ def authenticate_totp(code, options = {})
50
+ totp_secret = options[:otp_secret_key] || otp_secret_key
51
+ digits = options[:otp_length] || self.class.otp_length
52
+ drift = options[:drift] || self.class.allowed_otp_drift_seconds
53
+ raise "authenticate_totp called with no otp_secret_key set" if totp_secret.nil?
54
+
55
+ totp = ROTP::TOTP.new(totp_secret, digits: digits)
56
+ new_timestamp = totp.verify(
57
+ without_spaces(code),
58
+ drift_ahead: drift, drift_behind: drift, after: totp_timestamp
59
+ )
60
+ return false unless new_timestamp
61
+
62
+ self.totp_timestamp = new_timestamp
63
+ true
64
+ end
65
+
66
+ def provisioning_uri(account = nil, options = {})
67
+ totp_secret = options[:otp_secret_key] || otp_secret_key
68
+ options[:digits] ||= options[:otp_length] || self.class.otp_length
69
+ raise "provisioning_uri called with no otp_secret_key set" if totp_secret.nil?
70
+ account ||= email if respond_to?(:email)
71
+ ROTP::TOTP.new(totp_secret, options).provisioning_uri(account)
72
+ end
73
+
74
+ def enroll_totp!(otp_secret_key, code)
75
+ return false unless authenticate_totp(code, { otp_secret_key: otp_secret_key })
76
+
77
+ update_columns(totp_timestamp: totp_timestamp, otp_secret_key: otp_secret_key)
78
+ end
79
+
80
+ def need_two_factor_authentication?(request)
81
+ totp_enabled?
82
+ end
83
+
84
+ def send_new_otp(options = {})
85
+ create_direct_otp options
86
+ send_two_factor_authentication_code(direct_otp)
87
+ end
88
+
89
+ def send_new_otp_after_login?
90
+ !totp_enabled?
91
+ end
92
+
93
+ def send_two_factor_authentication_code(code)
94
+ raise NotImplementedError.new("No default implementation - please define in your class.")
95
+ end
96
+
97
+ def max_login_attempts?
98
+ second_factor_attempts_count.to_i >= max_login_attempts.to_i
99
+ end
100
+
101
+ def max_login_attempts
102
+ self.class.max_login_attempts
103
+ end
104
+
105
+ def totp_enabled?
106
+ respond_to?(:otp_secret_key) && !otp_secret_key.nil?
107
+ end
108
+
109
+ def generate_totp_secret
110
+ self.class.generate_totp_secret
111
+ end
112
+
113
+ def create_direct_otp(options = {})
114
+ # Create a new random OTP and store it in the database
115
+ digits = options[:length] || self.class.direct_otp_length || 6
116
+ update_columns(
117
+ direct_otp: random_base10(digits),
118
+ direct_otp_sent_at: Time.now.utc
119
+ )
120
+ end
121
+
122
+ private
123
+
124
+ def without_spaces(code)
125
+ code.gsub(/[[:space:]]/, '')
126
+ end
127
+
128
+ def random_base10(digits)
129
+ SecureRandom.random_number(10**digits).to_s.rjust(digits, '0')
130
+ end
131
+
132
+ def direct_otp_expired?
133
+ Time.now.utc > direct_otp_sent_at + self.class.direct_otp_valid_for
134
+ end
135
+
136
+ def clear_direct_otp
137
+ update_columns(direct_otp: nil, direct_otp_sent_at: nil)
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end