rails_base 0.75.5 → 0.80.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.
Files changed (88) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/javascripts/rails_base/rails_base_query_checker.js +36 -0
  3. data/app/controllers/rails_base/admin_controller.rb +54 -9
  4. data/app/controllers/rails_base/mfa/evaluation_controller.rb +59 -0
  5. data/app/controllers/rails_base/mfa/register/sms_controller.rb +45 -0
  6. data/app/controllers/rails_base/mfa/register/totp_controller.rb +42 -0
  7. data/app/controllers/rails_base/mfa/validate/sms_controller.rb +83 -0
  8. data/app/controllers/rails_base/mfa/validate/totp_controller.rb +35 -0
  9. data/app/controllers/rails_base/secondary_authentication_controller.rb +40 -96
  10. data/app/controllers/rails_base/user_settings_controller.rb +11 -1
  11. data/app/controllers/rails_base/users/registrations_controller.rb +1 -1
  12. data/app/controllers/rails_base/users/sessions_controller.rb +16 -13
  13. data/app/controllers/rails_base_application_controller.rb +96 -1
  14. data/app/jobs/twilio_job.rb +1 -1
  15. data/app/mailers/rails_base/email_verification_mailer.rb +6 -4
  16. data/app/mailers/rails_base/event_mailer.rb +4 -2
  17. data/app/mailers/rails_base/mailer_kwarg_inject.rb +31 -0
  18. data/app/models/rails_base/user_constants.rb +6 -3
  19. data/app/models/rails_base/user_helper/totp/backup_method_options.rb +33 -0
  20. data/app/models/rails_base/user_helper/totp/class_options.rb +35 -0
  21. data/app/models/rails_base/user_helper/totp/consume_method_options.rb +60 -0
  22. data/app/models/rails_base/user_helper/totp.rb +41 -0
  23. data/app/models/user.rb +28 -13
  24. data/app/services/rails_base/authentication/constants.rb +1 -1
  25. data/app/services/rails_base/authentication/decision_twofa_type.rb +61 -30
  26. data/app/services/rails_base/authentication/send_forgot_password.rb +0 -1
  27. data/app/services/rails_base/authentication/single_sign_on_send.rb +1 -1
  28. data/app/services/rails_base/authentication/sso_verify_email.rb +3 -1
  29. data/app/services/rails_base/authentication/update_phone_send_verification.rb +2 -2
  30. data/app/services/rails_base/authentication/verify_forgot_password.rb +8 -11
  31. data/app/services/rails_base/mfa/decision.rb +70 -0
  32. data/app/services/rails_base/mfa/encrypt_token.rb +34 -0
  33. data/app/services/rails_base/mfa/sms/remove.rb +35 -0
  34. data/app/services/rails_base/{authentication/send_login_mfa_to_user.rb → mfa/sms/send.rb} +19 -13
  35. data/app/services/rails_base/mfa/sms/validate.rb +105 -0
  36. data/app/services/rails_base/mfa/strategy/base.rb +44 -0
  37. data/app/services/rails_base/mfa/strategy/every_request.rb +14 -0
  38. data/app/services/rails_base/mfa/strategy/skip_every_request.rb +14 -0
  39. data/app/services/rails_base/mfa/strategy/time_based.rb +24 -0
  40. data/app/services/rails_base/mfa/totp/helper.rb +21 -0
  41. data/app/services/rails_base/mfa/totp/otp_metadata.rb +19 -0
  42. data/app/services/rails_base/mfa/totp/remove.rb +40 -0
  43. data/app/services/rails_base/mfa/totp/validate_code.rb +52 -0
  44. data/app/services/rails_base/mfa/totp/validate_temporary_code.rb +37 -0
  45. data/app/services/rails_base/mfa.rb +18 -0
  46. data/app/services/rails_base/name_change.rb +3 -3
  47. data/app/views/layouts/rails_base/application.html.erb +22 -6
  48. data/app/views/rails_base/devise/passwords/new.html.erb +1 -1
  49. data/app/views/rails_base/mfa/_switch_mfa_type.html.erb +17 -0
  50. data/app/views/rails_base/mfa/validate/sms/sms_event_input.html.erb +2 -0
  51. data/app/views/rails_base/mfa/validate/totp/totp_event_input.html.erb +1 -0
  52. data/app/views/rails_base/secondary_authentication/reset_password_input.html.erb +4 -0
  53. data/app/views/rails_base/shared/_enable_mfa_auth_modal.html.erb +1 -1
  54. data/app/views/rails_base/shared/_logged_in_header.html.erb +1 -25
  55. data/app/views/rails_base/shared/_modify_mfa_auth_modal.html.erb +102 -3
  56. data/app/views/rails_base/shared/_standardized_collapse.html.erb +28 -0
  57. data/app/views/rails_base/shared/mfa/sms/_login_input.html.erb +13 -0
  58. data/app/views/rails_base/shared/mfa/totp/_login_input.html.erb +22 -0
  59. data/app/views/rails_base/shared/totp/_add_authenticator.html.erb +76 -0
  60. data/app/views/rails_base/shared/totp/_add_authenticator_modal.html.erb +25 -0
  61. data/app/views/rails_base/shared/totp/_confirm_code.html.erb +31 -0
  62. data/app/views/rails_base/shared/totp/_confirm_code_ajax.html.erb +3 -0
  63. data/app/views/rails_base/shared/totp/_confirm_code_rest.html.erb +5 -0
  64. data/app/views/rails_base/shared/totp/_remove_authenticator_modal.html.erb +50 -0
  65. data/app/views/rails_base/user_settings/index.html.erb +84 -1
  66. data/config/initializers/admin_action_helper.rb +44 -8
  67. data/config/routes.rb +42 -7
  68. data/db/migrate/20240808013706_add_totp_to_users.rb +9 -0
  69. data/db/migrate/20240825012724_reconfigure_mfa_variable_names.rb +10 -0
  70. data/lib/rails_base/admin/action_helper.rb +0 -1
  71. data/lib/rails_base/admin/default_index_tile.rb +3 -3
  72. data/lib/rails_base/config.rb +26 -22
  73. data/lib/rails_base/configuration/admin.rb +5 -5
  74. data/lib/rails_base/configuration/appearance.rb +0 -2
  75. data/lib/rails_base/configuration/base.rb +1 -0
  76. data/lib/rails_base/configuration/mfa.rb +27 -60
  77. data/lib/rails_base/configuration/totp.rb +82 -0
  78. data/lib/rails_base/configuration/twilio.rb +85 -0
  79. data/lib/rails_base/mfa_event.rb +186 -0
  80. data/lib/rails_base/version.rb +3 -3
  81. data/lib/rails_base.rb +1 -0
  82. data/lib/twilio_helper.rb +3 -3
  83. metadata +129 -64
  84. data/app/controllers/rails_base/mfa_auth_controller.rb +0 -50
  85. data/app/services/rails_base/authentication/mfa_set_encrypt_token.rb +0 -32
  86. data/app/services/rails_base/authentication/mfa_validator.rb +0 -88
  87. data/app/views/rails_base/mfa_auth/mfa_code.html.erb +0 -11
  88. data/app/views/rails_base/secondary_authentication/forgot_password.html.erb +0 -9
@@ -7,6 +7,16 @@ module RailsBase
7
7
 
8
8
  # GET user/settings
9
9
  def index
10
+ @type = :rest
11
+ @endpoint = RailsBase.url_routes.totp_register_validate_path
12
+
13
+ if current_user.mfa_sms_enabled
14
+ clear_mfa_event_from_session!(event_name: RailsBase::MfaEvent::ENABLE_SMS_EVENT)
15
+ add_mfa_event_to_session(event: RailsBase::MfaEvent.sms_disable(user: current_user))
16
+ else
17
+ clear_mfa_event_from_session!(event_name: RailsBase::MfaEvent::DISABLE_SMS_EVENT)
18
+ add_mfa_event_to_session(event: RailsBase::MfaEvent.sms_enable(user: current_user))
19
+ end
10
20
  end
11
21
 
12
22
  # POST user/settings/edit/name
@@ -14,7 +24,7 @@ module RailsBase
14
24
  result = NameChange.call(
15
25
  first_name: params[:user][:first_name],
16
26
  last_name: params[:user][:last_name],
17
- current_user: current_user
27
+ current_user: current_user,
18
28
  )
19
29
 
20
30
  if result.failure?
@@ -50,7 +50,7 @@ class RailsBase::Users::RegistrationsController < Devise::RegistrationsControlle
50
50
  end
51
51
  session[:mfa_randomized_token] = nil
52
52
  session[:mfa_randomized_token] =
53
- RailsBase::Authentication::MfaSetEncryptToken.call(purpose: RailsBase::Authentication::Constants::SSOVE_PURPOSE, user: resource, expires_at: Time.zone.now + 20.minutes).encrypted_val
53
+ RailsBase::Mfa::EncryptToken.call(purpose: RailsBase::Authentication::Constants::SSOVE_PURPOSE, user: resource, expires_at: Time.zone.now + 20.minutes).encrypted_val
54
54
  redirect_to RailsBase.url_routes.auth_static_path, notice: "Check email for verification email."
55
55
  else
56
56
  flash[:error] = resource.errors.messages
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'twilio_helper'
4
+
3
5
  class RailsBase::Users::SessionsController < Devise::SessionsController
4
6
  prepend_before_action :protect_heartbeat, only: [:hearbeat_without_auth, :hearbeat_with_auth]
5
7
  prepend_before_action :skip_timeout, only: [:hearbeat_without_auth]
@@ -13,7 +15,7 @@ class RailsBase::Users::SessionsController < Devise::SessionsController
13
15
  # POST /user/sign_in
14
16
  def create
15
17
  # Warden/Devise will try to sign the user in before we explicitly do
16
- # Sign ou the user when this happens so we can sign them back in later
18
+ # Sign out the user when this happens so we can sign them back in later
17
19
  sign_out(current_user) if current_user
18
20
 
19
21
  authenticate = RailsBase::Authentication::AuthenticateUser.call(email: params[:user][:email], password: params[:user][:password])
@@ -33,25 +35,26 @@ class RailsBase::Users::SessionsController < Devise::SessionsController
33
35
 
34
36
  if mfa_decision.set_mfa_randomized_token
35
37
  session[:mfa_randomized_token] =
36
- RailsBase::Authentication::MfaSetEncryptToken.call(
38
+ RailsBase::Mfa::EncryptToken.call(
37
39
  user: authenticate.user,
38
40
  expires_at: mfa_decision.token_ttl,
39
41
  purpose: mfa_decision.mfa_purpose,
40
42
  ).encrypted_val
41
43
  end
42
44
 
43
- redirect =
44
- if mfa_decision.sign_in_user
45
- sign_in(authenticate.user)
46
- # only referentially redirect when we know the user should sign in
47
- redirect_from_reference
48
- end
49
-
50
- redirect ||= mfa_decision.redirect_url
51
-
52
- logger.info { "Successful sign in: Redirecting to #{redirect}" }
45
+ if mfa_decision.sign_in_user
46
+ sign_in(authenticate.user)
47
+ session.merge!(mfa_decision.session || {})
48
+ # only referentially redirect when we know the user should sign in
49
+ redirect_to(redirect_from_reference || RailsBase.url_routes.authenticated_root_path, mfa_decision.flash)
50
+ return
51
+ end
53
52
 
54
- redirect_to(redirect, mfa_decision.flash)
53
+ ####
54
+ # User needs MFA
55
+ ####
56
+ add_mfa_event_to_session(event: RailsBase::MfaEvent.login_event(user: authenticate.user))
57
+ redirect_to(mfa_decision.redirect_url, mfa_decision.flash)
55
58
  end
56
59
 
57
60
  # DELETE /user/sign_out
@@ -6,6 +6,7 @@ class RailsBaseApplicationController < ActionController::Base
6
6
  before_action :is_timeout_error?
7
7
  before_action :admin_reset_impersonation_session!
8
8
  before_action :footer_mode_case
9
+ after_action :clear_expired_mfa_events_from_session!
9
10
 
10
11
  before_action :populate_admin_actions, if: -> { RailsBase.config.admin.enable_actions? }
11
12
  after_action :capture_admin_action
@@ -128,6 +129,100 @@ class RailsBaseApplicationController < ActionController::Base
128
129
 
129
130
  protected
130
131
 
132
+ def add_mfa_event_to_session(event:)
133
+ unless RailsBase::MfaEvent === event
134
+ logger.error("Failed to add MFA event to session. Unexpected event passed")
135
+ return false
136
+ end
137
+
138
+ session[:"__#{RailsBase.app_name}_mfa_events"] ||= {}
139
+ # nested hashes in the session are string keys -- ensure it gets converted to a string during assignment to avoid confusion
140
+ session[:"__#{RailsBase.app_name}_mfa_events"][event.event.to_s] = event.to_hash.to_json
141
+ end
142
+
143
+ def clear_mfa_event_from_session!(event_name:)
144
+ session[:"__#{RailsBase.app_name}_mfa_events"] ||= {}
145
+ session[:"__#{RailsBase.app_name}_mfa_events"].delete(event_name.to_s)
146
+
147
+ true
148
+ end
149
+
150
+ def clear_expired_mfa_events_from_session!
151
+ mfa_events = session.delete(:"__#{RailsBase.app_name}_mfa_events")
152
+ return true if mfa_events.nil?
153
+
154
+ mfa_events.each do |event_name, metadata|
155
+ event_object = RailsBase::MfaEvent.new(**JSON.parse(metadata).deep_symbolize_keys)
156
+ if event_object.valid_by_death_time?
157
+ add_mfa_event_to_session(event: event_object)
158
+ else
159
+ logger.warn("MFA event: [#{event_name}] is no longer valid. Ejecting from session ")
160
+ end
161
+
162
+ end
163
+ rescue => e
164
+ logger.error("Oh know! We may have just removed all MFA events. Re-Auth is now required")
165
+ true
166
+ end
167
+
168
+ def validate_mfa_with_event_json!(mfa_event_name: params[:mfa_event])
169
+ return true if soft_mfa_with_event(mfa_event_name:)
170
+
171
+ render json: { error: "Unauthorized. Incorrect Event" }.to_json, :status => 401
172
+ false
173
+ end
174
+
175
+ def validate_mfa_with_event!(mfa_event_name: params[:mfa_event])
176
+ return true if soft_mfa_with_event(mfa_event_name:)
177
+
178
+ redirect = @__rails_base_mfa_event&.invalid_redirect || RailsBase.url_routes.unauthenticated_root_path
179
+ redirect_to(redirect, alert: @__rails_base_mfa_event_invalid_reason)
180
+ false
181
+ end
182
+
183
+ def soft_mfa_with_event(mfa_event_name: params[:mfa_event])
184
+ # nested hashes in the session are string keys -- ensure it gets converted to a string during lookup
185
+ mfa_event = session.dig(:"__#{RailsBase.app_name}_mfa_events", mfa_event_name.to_s)
186
+ if mfa_event.nil?
187
+ @__rails_base_mfa_event_invalid_reason = "Unauthorized MFA event"
188
+ return false
189
+ end
190
+ @__rails_base_mfa_event = RailsBase::MfaEvent.new(**JSON.parse(mfa_event).deep_symbolize_keys)
191
+
192
+ if @__rails_base_mfa_event.valid?
193
+ @__rails_base_mfa_event.increase_access_count!
194
+ return true
195
+ end
196
+ @__rails_base_mfa_event_invalid_reason = "MFA event for #{mfa_event_name} is invalid. #{@__rails_base_mfa_event.event}"
197
+
198
+ false
199
+ end
200
+
201
+ def json_validate_current_user!
202
+ return if current_user
203
+
204
+ render json: { error: "Unauthorized" }.to_json, :status => 401
205
+ return false
206
+ end
207
+
208
+ def validate_mfa_token!(purpose: RailsBase::Authentication::Constants::MSET_PURPOSE)
209
+ return true if soft_validate_mfa_token(token: session[:mfa_randomized_token], purpose: purpose)
210
+
211
+ if user_signed_in?
212
+ redirect_to RailsBase.url_routes.user_settings_path, alert: @token_verifier.message
213
+ else
214
+ redirect_to RailsBase.url_routes.new_user_session_path, alert: @token_verifier.message
215
+ end
216
+ return false
217
+ end
218
+
219
+ def soft_validate_mfa_token(token:, purpose: RailsBase::Authentication::Constants::MSET_PURPOSE)
220
+ @token_verifier =
221
+ RailsBase::Authentication::SessionTokenVerifier.call(purpose: purpose, mfa_randomized_token: token)
222
+
223
+ @token_verifier.success?
224
+ end
225
+
131
226
  def admin_get_token(encrypted_val:)
132
227
  params = {
133
228
  mfa_randomized_token: encrypted_val,
@@ -144,7 +239,7 @@ class RailsBaseApplicationController < ActionController::Base
144
239
  purpose: RailsBase::Authentication::Constants::ADMIN_REMEMBER_REASON,
145
240
  expires_at: RailsBase::Authentication::Constants::ADMIN_MAX_IDLE_TIME.from_now
146
241
  }
147
- encrpytion = RailsBase::Authentication::MfaSetEncryptToken.call(params)
242
+ encrpytion = RailsBase::Mfa::EncryptToken.call(params)
148
243
  session[RailsBase::Authentication::Constants::ADMIN_REMEMBER_REASON] = encrpytion.encrypted_val
149
244
  end
150
245
  end
@@ -1,7 +1,7 @@
1
1
  require 'twilio_helper'
2
2
 
3
3
  class TwilioJob < RailsBase::ApplicationJob
4
- queue_as RailsBase.config.mfa.active_job_queue
4
+ queue_as RailsBase.config.twilio.active_job_queue
5
5
 
6
6
  def perform(message:, to:)
7
7
  TwilioHelper.send_sms(message: message, to: to)
@@ -1,22 +1,24 @@
1
1
  class RailsBase::EmailVerificationMailer < RailsBase::ApplicationMailer
2
2
  default from: Rails.configuration.mail_from
3
3
 
4
- def email_verification(user:, url:)
4
+ def email_verification(user, url)
5
5
  @user = user
6
6
  @sso_url_for_user = url
7
7
  mail(to: @user.email, subject: "Welcome to #{RailsBase.app_name}")
8
8
  end
9
9
 
10
- def forgot_password(user:, url:)
10
+ def forgot_password(user, url)
11
11
  @user = user
12
12
  @sso_url_for_user = url
13
13
  mail(to: @user.email, subject: "#{RailsBase.app_name}: Forgot Password")
14
14
  end
15
15
 
16
- def event(user:, event:, msg: nil)
17
- @user = user
16
+ def event(user, event, msg = nil)
17
+ @user = user
18
18
  @event = event
19
19
  @msg = msg
20
20
  mail(to: @user.email, subject: "#{RailsBase.app_name}: #{event}")
21
21
  end
22
+
23
+ include ::RailsBase::MailerKwargInject
22
24
  end
@@ -1,16 +1,18 @@
1
1
  class RailsBase::EventMailer < RailsBase::ApplicationMailer
2
2
  default from: Rails.configuration.mail_from
3
3
 
4
- def send_sso(user:, message:)
4
+ def send_sso(user, message)
5
5
  @user = user
6
6
  @message = message
7
7
  mail(to: user.email, subject: "#{RailsBase.app_name}: SSO login", template_name: 'event')
8
8
  # event(user: user, event: 'SSO login', message: message)
9
9
  end
10
10
 
11
- def event(user:, event:, message:)
11
+ def event(user, event, message)
12
12
  @user = user
13
13
  @message = message
14
14
  mail(to: @user.email, subject: "#{RailsBase.app_name}: #{event}", template_name: 'event')
15
15
  end
16
+
17
+ include ::RailsBase::MailerKwargInject
16
18
  end
@@ -0,0 +1,31 @@
1
+ module RailsBase::MailerKwargInject
2
+
3
+ def self.included(base)
4
+ base.extend(ClassMethods)
5
+ base.inject_safety_net!
6
+ end
7
+
8
+ module ClassMethods
9
+ def inject_safety_net!
10
+ action_methods.each do |method_name|
11
+ alias_method_name = "__rails_base__kwarg_inject_#{method_name}__"
12
+ self.alias_method(alias_method_name, method_name)
13
+
14
+ self.define_method(method_name) do |*args, **kwargs, &block|
15
+ parameter_name_order = method(alias_method_name).parameters.map(&:second)
16
+ begin
17
+ self.send(alias_method_name, *args, **kwargs, &block)
18
+ rescue ArgumentError => e
19
+ if Hash === args[0]
20
+ new_arg_list = parameter_name_order.map { args[0][_1] }
21
+ self.send(alias_method_name, *new_arg_list, &block)
22
+ ActiveSupport::Deprecation.warn("Method Signature of `#{self.class}.#{method_name}` will change from KWargs to ARGs. Please modify your code.")
23
+ else
24
+ raise
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -8,9 +8,11 @@ module RailsBase
8
8
  ]
9
9
 
10
10
  SOFT_DESTROY_PARAMS = {
11
- mfa_enabled: false,
11
+ mfa_sms_enabled: false,
12
+ mfa_otp_enabled: false,
12
13
  email_validated: false,
13
- last_mfa_login: nil,
14
+ last_mfa_sms_login: nil,
15
+ last_mfa_otp_login: nil,
14
16
  encrypted_password: '',
15
17
  phone_number: nil,
16
18
  }
@@ -20,7 +22,8 @@ module RailsBase
20
22
  admin: ->(user) { RailsBase.config.admin.admin_type_tile_users?(user) } ,
21
23
  email: ->(user) { RailsBase.config.admin.email_tile_users?(user) } ,
22
24
  email_validated: ->(user) { RailsBase.config.admin.email_validate_tile_users?(user) } ,
23
- mfa_enabled: ->(user) { RailsBase.config.admin.mfa_tile_users?(user) } ,
25
+ mfa_sms_enabled: ->(user) { RailsBase.config.admin.mfa_tile_users?(user) } ,
26
+ mfa_otp_enabled: ->(user) { RailsBase.config.admin.mfa_tile_users?(user) } ,
24
27
  phone_number: ->(user) { RailsBase.config.admin.phone_tile_users?(user) } ,
25
28
  last_known_timezone: ->(user) { RailsBase.config.admin.modify_timezone_tile_users?(user) }
26
29
  }
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsBase
4
+ module UserHelper
5
+ module Totp
6
+ module BackupMethodOptions
7
+ def generate_otp_backup_codes!
8
+ codes = User.generate_backup_codes
9
+ self.otp_backup_codes = codes
10
+ save!
11
+
12
+ codes
13
+ end
14
+
15
+ def invalidate_otp_backup_code!(code)
16
+ codes = self.otp_backup_codes || []
17
+
18
+ return false unless codes.include?(code)
19
+
20
+ codes.delete(code)
21
+
22
+ self.otp_backup_codes = codes
23
+
24
+ save!
25
+ end
26
+
27
+ def totp_config
28
+ RailsBase.config.totp
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsBase
4
+ module UserHelper
5
+ module Totp
6
+ module ClassOptions
7
+ def totp_drift_ahead
8
+ RailsBase.config.totp.allowed_drift_ahead || RailsBase.config.totp.allowed_drift
9
+ end
10
+
11
+ def totp_drift_behind
12
+ RailsBase.config.totp.allowed_drift_behind || RailsBase.config.totp.allowed_drift
13
+ end
14
+
15
+ def generate_otp_secret(otp_secret_length = RailsBase.config.totp.secret_code_length)
16
+ ROTP::Base32.random_base32(otp_secret_length)
17
+ end
18
+
19
+ def generate_backup_codes
20
+ number_of_codes = RailsBase.config.totp.backup_code_count
21
+ code_length = RailsBase.config.totp.backup_code_length
22
+ codes = []
23
+ number_of_codes.times do
24
+ codes << SecureRandom.hex(code_length / 2) # Hexstring has length 2*n
25
+ end
26
+
27
+ codes
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+
34
+
35
+
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rqrcode"
4
+
5
+ module RailsBase
6
+ module UserHelper
7
+ module Totp
8
+ module ConsumeMethodOptions
9
+ def persist_otp_metadata!
10
+ reload # Ensure the user is relaoded to use the correct data for the secret
11
+ metadata = otp_metadata(safe: true, use_existing_temp: true)
12
+
13
+ self.otp_secret = self.otp_secret || self.temp_otp_secret
14
+ self.temp_otp_secret = nil
15
+ self.mfa_otp_enabled = true
16
+ save!
17
+ end
18
+
19
+ def otp_provisioning_uri(options = {})
20
+ label = options.delete(:label) || "#{RailsBase.app_name}:#{self.email}"
21
+ otp_secret = options[:otp_secret] || self.otp_secret
22
+
23
+ ROTP::TOTP.new(otp_secret, options).provisioning_uri(label)
24
+ end
25
+
26
+ def otp_metadata(safe: false, use_existing_temp: false)
27
+ secret ||= self.otp_secret
28
+ secret ||= self.temp_otp_secret if safe && use_existing_temp
29
+ secret ||= temporary_otp! if safe
30
+
31
+ uri = otp_provisioning_uri({ otp_secret: secret })
32
+ { secret: secret, uri: uri, qr_code: qr_code(uri) }
33
+ end
34
+
35
+ protected
36
+
37
+ def qr_code(uri)
38
+ qrcode = RQRCode::QRCode.new(uri)
39
+ qrcode.as_svg(
40
+ color: "000",
41
+ shape_rendering: "crispEdges",
42
+ module_size: 4,
43
+ standalone: true,
44
+ use_path: true
45
+ )
46
+ end
47
+
48
+ def temporary_otp!(otp_secret_length = RailsBase.config.totp.secret_code_length)
49
+ otp_secret = User.generate_otp_secret(otp_secret_length)
50
+
51
+ self.temp_otp_secret = otp_secret
52
+ save!
53
+
54
+ otp_secret
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rotp"
4
+
5
+ ####
6
+ #
7
+ # Copied with love from https://github.com/devise-two-factor/devise-two-factor
8
+ #
9
+ ####
10
+
11
+ module RailsBase
12
+ module UserHelper
13
+ module Totp
14
+ extend ActiveSupport::Concern
15
+
16
+ class Error < StandardError; end
17
+ class NotRequired < Error; end
18
+
19
+ included do
20
+ serialize :otp_backup_codes, Array
21
+ end
22
+
23
+ def self.included(base)
24
+ base.include(ConsumeMethodOptions)
25
+ base.include(BackupMethodOptions)
26
+ base.extend(ClassOptions)
27
+ end
28
+
29
+ def reset_otp!
30
+ self.otp_secret = nil
31
+ self.temp_otp_secret = nil
32
+ self.consumed_timestep = nil
33
+ self.mfa_otp_enabled = false
34
+ self.otp_backup_codes = []
35
+ self.last_mfa_otp_login = nil
36
+
37
+ save!
38
+ end
39
+ end
40
+ end
41
+ end
data/app/models/user.rb CHANGED
@@ -6,9 +6,9 @@
6
6
  # first_name :string(255) default(""), not null
7
7
  # last_name :string(255) default(""), not null
8
8
  # phone_number :string(255)
9
- # last_mfa_login :datetime
9
+ # last_mfa_sms_login :datetime
10
10
  # email_validated :boolean default(FALSE)
11
- # mfa_enabled :boolean default(FALSE), not null
11
+ # mfa_sms_enabled :boolean default(FALSE), not null
12
12
  # active :boolean default(TRUE), not null
13
13
  # admin :string(255)
14
14
  # last_known_timezone :string(255)
@@ -25,6 +25,12 @@
25
25
  # last_sign_in_ip :string(255)
26
26
  # created_at :datetime not null
27
27
  # updated_at :datetime not null
28
+ # otp_secret :string(255)
29
+ # temp_otp_secret :string(255)
30
+ # consumed_timestep :integer
31
+ # mfa_otp_enabled :boolean default(FALSE)
32
+ # otp_backup_codes :text(65535)
33
+ # last_mfa_otp_login :datetime
28
34
  #
29
35
  class User < RailsBase::ApplicationRecord
30
36
  # Include default devise modules. Others available are:
@@ -33,13 +39,14 @@ class User < RailsBase::ApplicationRecord
33
39
  :recoverable, :rememberable, :validatable, :timeoutable, :trackable
34
40
 
35
41
  include RailsBase::UserConstants
42
+ include RailsBase::UserHelper::Totp
36
43
 
37
44
  validate :enforce_owner, if: :will_save_change_to_admin?
38
45
  validate :enforce_admin_type, if: :will_save_change_to_admin?
39
46
 
40
47
  def self._def_admin_convenience_method!(admin_method:)
41
48
  types = RailsBase.config.admin.admin_types
42
- #### metods on the instance
49
+ #### methods on the instance
43
50
  define_method("at_least_#{admin_method}?") do
44
51
  i = types.find_index(admin.to_sym)
45
52
  i >= types.find_index(admin_method.to_sym)
@@ -65,8 +72,16 @@ class User < RailsBase::ApplicationRecord
65
72
  end
66
73
  end
67
74
 
68
- def self.time_bound
69
- Time.zone.now - RailsBase.config.auth.mfa_time_duration
75
+ def self.masked_number(phone_number)
76
+ return nil unless phone_number
77
+
78
+ "(#{phone_number[0]}**) ****-**#{phone_number[-2..-1]}"
79
+ end
80
+
81
+ def self.readable_phone_number(phone_number)
82
+ return nil unless phone_number
83
+
84
+ "(#{phone_number[0..2]}) #{phone_number[3..5]}-#{phone_number[6..-1]}"
70
85
  end
71
86
 
72
87
  def admin
@@ -77,20 +92,20 @@ class User < RailsBase::ApplicationRecord
77
92
  "#{first_name} #{last_name}"
78
93
  end
79
94
 
80
- def past_mfa_time_duration?
81
- return true if last_mfa_login.nil?
82
-
83
- last_mfa_login < self.class.time_bound
95
+ def set_last_mfa_sms_login!(time: Time.zone.now)
96
+ update(last_mfa_sms_login: time)
84
97
  end
85
98
 
86
- def set_last_mfa_login!(time: Time.zone.now)
87
- update(last_mfa_login: time)
99
+ def set_last_mfa_otp_login!(time: Time.zone.now)
100
+ update(last_mfa_otp_login: time)
88
101
  end
89
102
 
90
103
  def masked_phone
91
- return nil unless phone_number
104
+ User.masked_number(phone_number)
105
+ end
92
106
 
93
- "(#{phone_number[0]}**) ****-**#{phone_number[-2..-1]}"
107
+ def readable_phone
108
+ User.readable_phone_number(phone_number)
94
109
  end
95
110
 
96
111
  def soft_destroy_user!
@@ -5,7 +5,7 @@ module RailsBase::Authentication
5
5
  BASE_URL = RailsBase.config.app.base_url
6
6
  BASE_URL_PORT = RailsBase.config.app.base_port
7
7
  MFA_REASON = :two_factor_mfa_code
8
- MFA_LENGTH = RailsBase.config.mfa.mfa_length
8
+ MFA_LENGTH = RailsBase.config.twilio.mfa_length
9
9
  EMAIL_LENGTH = 255 # MAX LENGTH we can insert into mysql
10
10
 
11
11
  MIN_NAME = 2