rails_base 0.75.6 → 0.80.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/app/assets/javascripts/rails_base/rails_base_query_checker.js +36 -0
- data/app/controllers/rails_base/admin_controller.rb +54 -9
- data/app/controllers/rails_base/mfa/evaluation_controller.rb +59 -0
- data/app/controllers/rails_base/mfa/register/sms_controller.rb +45 -0
- data/app/controllers/rails_base/mfa/register/totp_controller.rb +42 -0
- data/app/controllers/rails_base/mfa/validate/sms_controller.rb +83 -0
- data/app/controllers/rails_base/mfa/validate/totp_controller.rb +35 -0
- data/app/controllers/rails_base/secondary_authentication_controller.rb +40 -96
- data/app/controllers/rails_base/user_settings_controller.rb +11 -1
- data/app/controllers/rails_base/users/registrations_controller.rb +1 -1
- data/app/controllers/rails_base/users/sessions_controller.rb +16 -13
- data/app/controllers/rails_base_application_controller.rb +96 -1
- data/app/jobs/twilio_job.rb +1 -1
- data/app/mailers/rails_base/email_verification_mailer.rb +6 -4
- data/app/mailers/rails_base/event_mailer.rb +4 -2
- data/app/mailers/rails_base/mailer_kwarg_inject.rb +31 -0
- data/app/models/rails_base/user_constants.rb +6 -3
- data/app/models/rails_base/user_helper/totp/backup_method_options.rb +33 -0
- data/app/models/rails_base/user_helper/totp/class_options.rb +35 -0
- data/app/models/rails_base/user_helper/totp/consume_method_options.rb +60 -0
- data/app/models/rails_base/user_helper/totp.rb +41 -0
- data/app/models/user.rb +28 -13
- data/app/services/rails_base/authentication/constants.rb +1 -1
- data/app/services/rails_base/authentication/decision_twofa_type.rb +61 -30
- data/app/services/rails_base/authentication/send_forgot_password.rb +0 -1
- data/app/services/rails_base/authentication/single_sign_on_send.rb +1 -1
- data/app/services/rails_base/authentication/sso_verify_email.rb +3 -1
- data/app/services/rails_base/authentication/update_phone_send_verification.rb +2 -2
- data/app/services/rails_base/authentication/verify_forgot_password.rb +8 -11
- data/app/services/rails_base/mfa/decision.rb +70 -0
- data/app/services/rails_base/mfa/encrypt_token.rb +34 -0
- data/app/services/rails_base/mfa/sms/remove.rb +35 -0
- data/app/services/rails_base/{authentication/send_login_mfa_to_user.rb → mfa/sms/send.rb} +19 -13
- data/app/services/rails_base/mfa/sms/validate.rb +105 -0
- data/app/services/rails_base/mfa/strategy/base.rb +44 -0
- data/app/services/rails_base/mfa/strategy/every_request.rb +14 -0
- data/app/services/rails_base/mfa/strategy/skip_every_request.rb +14 -0
- data/app/services/rails_base/mfa/strategy/time_based.rb +24 -0
- data/app/services/rails_base/mfa/totp/helper.rb +21 -0
- data/app/services/rails_base/mfa/totp/otp_metadata.rb +19 -0
- data/app/services/rails_base/mfa/totp/remove.rb +40 -0
- data/app/services/rails_base/mfa/totp/validate_code.rb +52 -0
- data/app/services/rails_base/mfa/totp/validate_temporary_code.rb +37 -0
- data/app/services/rails_base/mfa.rb +18 -0
- data/app/services/rails_base/name_change.rb +3 -3
- data/app/views/layouts/rails_base/application.html.erb +22 -6
- data/app/views/rails_base/devise/passwords/new.html.erb +1 -1
- data/app/views/rails_base/mfa/_switch_mfa_type.html.erb +17 -0
- data/app/views/rails_base/mfa/validate/sms/sms_event_input.html.erb +2 -0
- data/app/views/rails_base/mfa/validate/totp/totp_event_input.html.erb +1 -0
- data/app/views/rails_base/secondary_authentication/reset_password_input.html.erb +4 -0
- data/app/views/rails_base/shared/_enable_mfa_auth_modal.html.erb +1 -1
- data/app/views/rails_base/shared/_logged_in_header.html.erb +1 -25
- data/app/views/rails_base/shared/_modify_mfa_auth_modal.html.erb +102 -3
- data/app/views/rails_base/shared/mfa/sms/_login_input.html.erb +13 -0
- data/app/views/rails_base/shared/mfa/totp/_login_input.html.erb +22 -0
- data/app/views/rails_base/shared/totp/_add_authenticator.html.erb +76 -0
- data/app/views/rails_base/shared/totp/_add_authenticator_modal.html.erb +25 -0
- data/app/views/rails_base/shared/totp/_confirm_code.html.erb +31 -0
- data/app/views/rails_base/shared/totp/_confirm_code_ajax.html.erb +3 -0
- data/app/views/rails_base/shared/totp/_confirm_code_rest.html.erb +5 -0
- data/app/views/rails_base/shared/totp/_remove_authenticator_modal.html.erb +50 -0
- data/app/views/rails_base/user_settings/index.html.erb +84 -1
- data/config/initializers/admin_action_helper.rb +44 -8
- data/config/routes.rb +42 -7
- data/db/migrate/20240808013706_add_totp_to_users.rb +9 -0
- data/db/migrate/20240825012724_reconfigure_mfa_variable_names.rb +10 -0
- data/lib/rails_base/admin/action_helper.rb +0 -1
- data/lib/rails_base/admin/default_index_tile.rb +3 -3
- data/lib/rails_base/config.rb +26 -22
- data/lib/rails_base/configuration/admin.rb +5 -5
- data/lib/rails_base/configuration/base.rb +1 -0
- data/lib/rails_base/configuration/mfa.rb +27 -60
- data/lib/rails_base/configuration/totp.rb +82 -0
- data/lib/rails_base/configuration/twilio.rb +85 -0
- data/lib/rails_base/mfa_event.rb +186 -0
- data/lib/rails_base/version.rb +3 -3
- data/lib/rails_base.rb +1 -0
- data/lib/twilio_helper.rb +3 -3
- metadata +129 -64
- data/app/controllers/rails_base/mfa_auth_controller.rb +0 -50
- data/app/services/rails_base/authentication/mfa_set_encrypt_token.rb +0 -32
- data/app/services/rails_base/authentication/mfa_validator.rb +0 -88
- data/app/views/rails_base/mfa_auth/mfa_code.html.erb +0 -11
- 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::
|
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
|
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::
|
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
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
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
|
-
|
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::
|
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
|
data/app/jobs/twilio_job.rb
CHANGED
@@ -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
|
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
|
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
|
17
|
-
|
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
|
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
|
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
|
-
|
11
|
+
mfa_sms_enabled: false,
|
12
|
+
mfa_otp_enabled: false,
|
12
13
|
email_validated: false,
|
13
|
-
|
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
|
-
|
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
|
-
#
|
9
|
+
# last_mfa_sms_login :datetime
|
10
10
|
# email_validated :boolean default(FALSE)
|
11
|
-
#
|
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
|
-
####
|
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.
|
69
|
-
|
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
|
81
|
-
|
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
|
87
|
-
update(
|
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
|
-
|
104
|
+
User.masked_number(phone_number)
|
105
|
+
end
|
92
106
|
|
93
|
-
|
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.
|
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
|