devise-multi-factor 3.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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