app/controllers/users/two_factor_authentication_controller.rb
# frozen_string_literal: true
module Users
class TwoFactorAuthenticationController < ApplicationController
include TwoFactorAuthenticatable
include ApplicationHelper
include ActionView::Helpers::DateHelper
before_action :check_remember_device_preference
before_action :redirect_to_vendor_outage_if_phone_only, only: [:show]
before_action :redirect_if_blank_phone, only: [:send_code]
def show
service_provider_mfa_requirement_redirect ||
non_phone_redirect ||
phone_redirect ||
backup_code_redirect ||
redirect_on_nothing_enabled
end
def send_code
result = otp_delivery_selection_form.submit(delivery_params)
analytics.otp_delivery_selection(**result.to_h)
if result.success?
handle_valid_otp_params(
result,
user_select_delivery_preference,
user_selected_default_number,
)
update_otp_delivery_preference_if_needed
else
handle_invalid_otp_delivery_preference(result)
end
end
private
def service_provider_mfa_requirement_redirect
return unless service_provider_mfa_policy.user_needs_sp_auth_method_verification?
redirect_to sp_required_mfa_verification_url
end
def non_phone_redirect
url = redirect_url
redirect_to url if url.present?
end
def phone_redirect
return unless phone_enabled? && !OutageStatus.new.any_phone_vendor_outage?
validate_otp_delivery_preference_and_send_code
true
end
def backup_code_redirect
return unless TwoFactorAuthentication::BackupCodePolicy.new(current_user).configured?
redirect_to login_two_factor_backup_code_url
end
def redirect_on_nothing_enabled
# "Nothing enabled" can mean one of two things:
# 1. The user hasn't yet set up MFA, and should be redirected to setup path.
# 2. The user has set up MFA, but none of the redirect options are currently available (e.g.
# vendor outage), and they should be sent to the MFA selection path.
if MfaPolicy.new(current_user).two_factor_enabled?
redirect_to login_two_factor_options_path
else
redirect_to authentication_methods_setup_url
end
end
def phone_enabled?
phone_configuration&.mfa_enabled?
end
def phone_configuration
return @phone_configuration if defined?(@phone_configuration)
@phone_configuration =
MfaContext.new(current_user).phone_configuration(user_session[:phone_id])
end
def validate_otp_delivery_preference_and_send_code
result = otp_delivery_selection_form.submit(otp_delivery_preference: delivery_preference)
analytics.otp_delivery_selection(**result.to_h)
phone_is_confirmed = UserSessionContext.authentication_or_reauthentication_context?(context)
phone_capabilities = PhoneNumberCapabilities.new(
parsed_phone,
phone_confirmed: phone_is_confirmed,
)
if result.success?
handle_valid_otp_params(result, delivery_preference)
elsif phone_capabilities.supports_sms?
handle_valid_otp_params(result, 'sms')
flash[:error] = result.errors[:phone].first
else
handle_invalid_otp_delivery_preference(result)
end
end
def delivery_preference
phone_configuration&.delivery_preference || current_user.otp_delivery_preference
end
def update_otp_delivery_preference_if_needed
return if otp_failed_to_send?
OtpPreferenceUpdater.new(
user: current_user,
preference: delivery_params[:otp_delivery_preference],
phone_id: user_session[:phone_id],
).call
end
def otp_failed_to_send?
return true unless user_signed_in?
!@telephony_result&.success?
end
def handle_invalid_otp_delivery_preference(result)
flash[:error] = result.errors[:phone].first
redirect_to login_two_factor_url(otp_delivery_preference: delivery_preference)
end
def invalid_phone_number(telephony_error, action:)
capture_analytics_for_exception(telephony_error)
if action == 'show'
redirect_to_otp_verification_with_error
else
flash[:error] = telephony_error.friendly_message
redirect_back(fallback_location: account_url, allow_other_host: false)
end
end
def redirect_to_otp_verification_with_error
flash[:error] = t('errors.messages.phone_unsupported')
redirect_to login_two_factor_url(
otp_delivery_preference: phone_configuration.delivery_preference,
)
end
def redirect_if_blank_phone
return if phone_to_deliver_to.present?
flash[:error] = t('errors.messages.phone_required')
redirect_to login_two_factor_options_path
end
def redirect_to_vendor_outage_if_phone_only
return unless OutageStatus.new.all_phone_vendor_outage? &&
phone_enabled? &&
!MfaPolicy.new(current_user).multiple_factors_enabled?
redirect_to vendor_outage_path(from: :two_factor_authentication)
end
def capture_analytics_for_exception(telephony_error)
analytics.otp_phone_validation_failed(
error: telephony_error.class.to_s,
message: telephony_error.message,
context: context,
country: parsed_phone.country,
)
end
def parsed_phone
@parsed_phone ||= Phonelib.parse(phone_to_deliver_to)
end
def otp_delivery_selection_form
@otp_delivery_selection_form ||= OtpDeliverySelectionForm.new(
current_user, phone_to_deliver_to, context
)
end
def handle_valid_otp_params(otp_delivery_selection_result, method, default = nil)
otp_rate_limiter.reset_count_and_otp_last_sent_at if current_user.no_longer_locked_out?
if exceeded_otp_send_limit?
return handle_too_many_otp_sends
end
otp_rate_limiter.increment
if exceeded_otp_send_limit?
return handle_too_many_otp_sends
end
if exceeded_short_term_otp_rate_limit?
return handle_too_many_short_term_otp_sends(method: method, default: default)
end
return handle_too_many_confirmation_sends if exceeded_phone_confirmation_limit?
@telephony_result = send_user_otp(method)
handle_telephony_result(
method: method,
default: default,
otp_delivery_selection_result: otp_delivery_selection_result,
)
end
def handle_telephony_result(method:, default:, otp_delivery_selection_result:)
track_events(
otp_delivery_preference: method,
otp_delivery_selection_result: otp_delivery_selection_result,
)
if @telephony_result.success?
redirect_to login_two_factor_url(
otp_delivery_preference: method,
otp_make_default_number: default,
)
elsif @telephony_result.error.is_a?(Telephony::OptOutError)
opt_out = PhoneNumberOptOut.mark_opted_out(phone_to_deliver_to)
redirect_to login_two_factor_sms_opt_in_path(opt_out_uuid: opt_out)
else
invalid_phone_number(@telephony_result.error, action: action_name)
end
end
def track_events(otp_delivery_preference:, otp_delivery_selection_result:)
analytics.telephony_otp_sent(
area_code: parsed_phone.area_code,
country_code: parsed_phone.country,
phone_fingerprint: Pii::Fingerprinter.fingerprint(parsed_phone.e164),
context: context,
otp_delivery_preference: otp_delivery_preference,
resend: otp_delivery_selection_result.extra[:resend],
adapter: Telephony.config.adapter,
telephony_response: @telephony_result.to_h,
success: @telephony_result.success?,
recaptcha_annotation: RecaptchaAnnotator.annotate(
assessment_id: user_session[:phone_recaptcha_assessment_id],
reason: RecaptchaAnnotator::AnnotationReasons::INITIATED_TWO_FACTOR,
),
)
end
def exceeded_otp_send_limit?
return otp_rate_limiter.lock_out_user if otp_rate_limiter.exceeded_otp_send_limit?
end
def phone_confirmation_rate_limiter
@phone_confirmation_rate_limiter ||= RateLimiter.new(
user: current_user,
rate_limit_type: :phone_confirmation,
)
end
def short_term_otp_rate_limiter
@short_term_otp_rate_limiter ||= RateLimiter.new(
user: current_user,
rate_limit_type: :short_term_phone_otp,
)
end
def exceeded_short_term_otp_rate_limit?
short_term_otp_rate_limiter.increment!
short_term_otp_rate_limiter.limited?
end
def exceeded_phone_confirmation_limit?
return false unless UserSessionContext.confirmation_context?(context)
phone_confirmation_rate_limiter.increment!
phone_confirmation_rate_limiter.limited?
end
def send_user_otp(method)
if PhoneNumberOptOut.find_with_phone(phone_to_deliver_to)
return Telephony::Response.new(
success: false,
error: Telephony::OptOutError.new,
)
end
current_user.create_direct_otp
otp_params = {
to: phone_to_deliver_to,
otp: current_user.direct_otp,
expiration: TwoFactorAuthenticatable::DIRECT_OTP_VALID_FOR_MINUTES,
otp_format: t('telephony.format_type.digit'),
channel: method.to_sym,
domain: IdentityConfig.store.domain_name,
country_code: parsed_phone.country,
extra_metadata: {
area_code: parsed_phone.area_code,
phone_fingerprint: Pii::Fingerprinter.fingerprint(parsed_phone.e164),
resend: params.dig(:otp_delivery_selection_form, :resend),
},
}
if UserSessionContext.authentication_or_reauthentication_context?(context)
Telephony.send_authentication_otp(**otp_params)
else
Telephony.send_confirmation_otp(**otp_params, otp_length: otp_length)
end
end
def otp_length
configured_length = TwoFactorAuthenticatable::DIRECT_OTP_LENGTH
if configured_length == 6
I18n.t('telephony.format_length.six')
elsif configured_length == 10
I18n.t('telephony.format_length.ten')
else
raise "Missing translation for OTP length: #{configured_length}"
end
end
def user_selected_default_number
delivery_params[:otp_make_default_number]
end
def user_select_delivery_preference
delivery_params[:otp_delivery_preference]
end
def delivery_params
params.require(:otp_delivery_selection_form).permit(
:otp_delivery_preference,
:otp_make_default_number,
:resend,
)
end
def phone_to_deliver_to
if UserSessionContext.authentication_or_reauthentication_context?(context)
return phone_configuration&.phone
end
user_session[:unconfirmed_phone]
end
def otp_rate_limiter
@otp_rate_limiter ||= OtpRateLimiter.new(
phone: phone_to_deliver_to,
user: current_user,
phone_confirmed: UserSessionContext.authentication_or_reauthentication_context?(context),
)
end
def redirect_url
if !mobile? && TwoFactorAuthentication::PivCacPolicy.new(current_user).enabled?
login_two_factor_piv_cac_url
elsif TwoFactorAuthentication::WebauthnPolicy.new(current_user).enabled?
login_two_factor_webauthn_url(webauthn_params)
elsif TwoFactorAuthentication::AuthAppPolicy.new(current_user).enabled?
login_two_factor_authenticator_url
end
end
def webauthn_params
{ platform: current_user.webauthn_configurations.platform_authenticators.present? }
end
def handle_too_many_short_term_otp_sends(method:, default:)
analytics.rate_limit_reached(
limiter_type: short_term_otp_rate_limiter.rate_limit_type,
country_code: parsed_phone.country,
phone_fingerprint: Pii::Fingerprinter.fingerprint(parsed_phone.e164),
context: context,
otp_delivery_preference: method,
)
flash[:error] = t(
'errors.messages.phone_confirmation_limited',
timeout: distance_of_time_in_words(
Time.zone.now,
[short_term_otp_rate_limiter.expires_at, Time.zone.now].compact.max,
),
)
redirect_to login_two_factor_url(
otp_delivery_preference: method,
otp_make_default_number: default,
)
end
def handle_too_many_confirmation_sends
analytics.rate_limit_reached(
limiter_type: phone_confirmation_rate_limiter.rate_limit_type,
country_code: parsed_phone.country,
phone_fingerprint: Pii::Fingerprinter.fingerprint(parsed_phone.e164),
)
flash[:error] = t(
'errors.messages.phone_confirmation_limited',
timeout: distance_of_time_in_words(
Time.zone.now,
[phone_confirmation_rate_limiter.expires_at, Time.zone.now].compact.max,
except: :seconds,
),
)
if user_fully_authenticated?
redirect_to account_url
else
redirect_to authentication_methods_setup_url
end
end
end
end