diff --git a/.codeclimate.yml b/.codeclimate.yml index 0cefeeb427a..88d30880bd7 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -67,7 +67,7 @@ plugins: - '.codeclimate.yml' config: strings: - # Removed TODO from this list, as we want to allow TODOs in the codebase + - TODO - FIXME - HACK - BUG @@ -94,6 +94,5 @@ exclude_patterns: - 'lib/tasks/create_test_accounts.rb' - 'lib/user_flow_exporter.rb' - 'scripts/load_testing/' - - 'spec/' - 'tmp/' - 'config/initializers/jwt.rb' diff --git a/.reek b/.reek index d83f1dc79b1..d69ec14623d 100644 --- a/.reek +++ b/.reek @@ -6,6 +6,7 @@ ControlParameter: - OpenidConnectRedirector#initialize - NoRetryJobs#call - PhoneFormatter#self.format + - Users::TwoFactorAuthenticationController#invalid_phone_number DuplicateMethodCall: exclude: - ApplicationController#disable_caching @@ -19,6 +20,7 @@ DuplicateMethodCall: - fallback_to_english - Idv::Proofer#load_vendors! - Upaya::RandomTools#self.random_weighted_sample + - SmsController#authenticate FeatureEnvy: exclude: - ActiveJob::Logging::LogSubscriber#json_for @@ -46,6 +48,8 @@ FeatureEnvy: - Utf8Sanitizer#remote_ip - Idv::Proofer#validate_vendors - PersonalKeyGenerator#create_legacy_recovery_code + - TwoFactorAuthenticationController#capture_analytics_for_exception + - Users::SessionsController#configure_permitted_parameters InstanceVariableAssumption: exclude: - User @@ -56,10 +60,11 @@ ManualDispatch: exclude: - EncryptedSidekiqRedis#respond_to_missing? - CloudhsmKeyGenerator#initialize_settings + - Users::SessionsController#configure_permitted_parameters NestedIterators: exclude: - UserFlowExporter#self.massage_html - - TwilioService#sanitize_phone_number + - TwilioService::Utils#sanitize_phone_number - ServiceProviderSeeder#run NilCheck: enabled: false @@ -104,6 +109,7 @@ TooManyStatements: - Upaya::RandomTools#self.random_weighted_sample - UserFlowFormatter#stop - Upaya::QueueConfig#self.choose_queue_adapter + - Users::TwoFactorAuthenticationController#send_code TooManyMethods: exclude: - Users::ConfirmationsController @@ -157,6 +163,7 @@ UtilityFunction: - LocaleHelper#locale_url_param - IdvSession#timed_out_vendor_error - JWT::Signature#sign + - SmsAccountResetCancellationNotifierJob#perform 'app/controllers': InstanceVariableAssumption: enabled: false diff --git a/.rubocop.yml b/.rubocop.yml index 9cb21bca7e5..9af4903ae03 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -62,7 +62,7 @@ Metrics/ClassLength: - app/controllers/openid_connect/authorization_controller.rb - app/controllers/users/confirmations_controller.rb - app/controllers/users/sessions_controller.rb - - app/controllers/devise/two_factor_authentication_controller.rb + - app/controllers/users/two_factor_authentication_controller.rb - app/decorators/service_provider_session_decorator.rb - app/decorators/user_decorator.rb - app/services/analytics.rb diff --git a/Gemfile b/Gemfile index f342eca5aca..7d125bcef3c 100644 --- a/Gemfile +++ b/Gemfile @@ -32,7 +32,7 @@ gem 'pg' gem 'phonelib' gem 'pkcs11' gem 'premailer-rails' -gem 'proofer', github: '18F/identity-proofer-gem', tag: 'v2.5.0' +gem 'proofer', github: '18F/identity-proofer-gem', tag: 'v2.6.1' gem 'rack-attack' gem 'rack-cors', require: 'rack/cors' gem 'rack-headers_filter' @@ -112,7 +112,7 @@ group :test do end group :production do - gem 'aamva', git: 'git@github.com:18F/identity-aamva-api-client-gem', tag: 'v3.0.1' + gem 'aamva', git: 'git@github.com:18F/identity-aamva-api-client-gem', tag: 'v3.1.0' gem 'equifax', git: 'git@github.com:18F/identity-equifax-api-client-gem.git', tag: 'v1.1.0' - gem 'lexisnexis', git: 'git@github.com:18F/identity-lexisnexis-api-client-gem', tag: 'v1.0.0' + gem 'lexisnexis', git: 'git@github.com:18F/identity-lexisnexis-api-client-gem', tag: 'v1.1.0' end diff --git a/Gemfile.lock b/Gemfile.lock index 2aca7ec3c71..9a4caf8e45d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,9 +1,9 @@ GIT remote: git@github.com:18F/identity-aamva-api-client-gem - revision: 015186dd86691294404229ee051cfcf9e87fb6c7 - tag: v3.0.1 + revision: f69b0295933809057292736ed173a5a5e11b668c + tag: v3.1.0 specs: - aamva (3.0.1) + aamva (3.1.0) dotenv hashie httpi @@ -24,10 +24,10 @@ GIT GIT remote: git@github.com:18F/identity-lexisnexis-api-client-gem - revision: 2cf954c312a7e66cd24c48ccc7af8bdc72339525 - tag: v1.0.0 + revision: d17049ab1a03d50c0cc8a272d86cf2144192fab5 + tag: v1.1.0 specs: - lexisnexis (1.0.0) + lexisnexis (1.1.0) dotenv typhoeus @@ -41,10 +41,10 @@ GIT GIT remote: https://github.com/18F/identity-proofer-gem.git - revision: 55191ec2124fb2b36111adf15d626d483436b74d - tag: v2.5.0 + revision: 875246d603bbd9b29cbc82493513f948d4e8689b + tag: v2.6.1 specs: - proofer (2.5.0) + proofer (2.6.1) GIT remote: https://github.com/18F/redis-session-store.git diff --git a/app/assets/stylesheets/components/_intl-tel-input.scss b/app/assets/stylesheets/components/_intl-tel-input.scss index 9ce937d9b8b..680f30d7ec8 100644 --- a/app/assets/stylesheets/components/_intl-tel-input.scss +++ b/app/assets/stylesheets/components/_intl-tel-input.scss @@ -2,6 +2,10 @@ display: none; } +.intl-tel-input { + width: 100%; +} + .no-js { .js-intl-tel-code-select { display: block; diff --git a/app/controllers/account_reset/cancel_controller.rb b/app/controllers/account_reset/cancel_controller.rb index 7c12994e22e..8cbe8659e7f 100644 --- a/app/controllers/account_reset/cancel_controller.rb +++ b/app/controllers/account_reset/cancel_controller.rb @@ -1,8 +1,9 @@ module AccountReset class CancelController < ApplicationController def cancel - if AccountResetService.cancel_request(params[:token]) - handle_success + account_reset = AccountResetService.cancel_request(params[:token]) + if account_reset + handle_success(account_reset.user) else handle_failure end @@ -11,9 +12,13 @@ def cancel private - def handle_success - analytics.track_event(Analytics::ACCOUNT_RESET, event: :cancel, token_valid: true) + def handle_success(user) + analytics.track_event(Analytics::ACCOUNT_RESET, + event: :cancel, token_valid: true, user_id: user.uuid) sign_out if current_user + UserMailer.account_reset_cancel(user.email).deliver_later + phone = user.phone + SmsAccountResetCancellationNotifierJob.perform_now(phone: phone) if phone.present? flash[:success] = t('devise.two_factor_authentication.account_reset.successful_cancel') end diff --git a/app/controllers/account_reset/delete_account_controller.rb b/app/controllers/account_reset/delete_account_controller.rb index b97d9c5a2b3..d6a13530dc8 100644 --- a/app/controllers/account_reset/delete_account_controller.rb +++ b/app/controllers/account_reset/delete_account_controller.rb @@ -7,8 +7,10 @@ class DeleteAccountController < ApplicationController def show; end def delete - analytics.track_event(Analytics::ACCOUNT_RESET, event: :delete, token_valid: true) - email = reset_session_and_set_email + user = @account_reset_request.user + analytics.track_event(Analytics::ACCOUNT_RESET, + event: :delete, token_valid: true, user_id: user.uuid) + email = reset_session_and_set_email(user) UserMailer.account_reset_complete(email).deliver_later redirect_to account_reset_confirm_delete_account_url end @@ -19,8 +21,7 @@ def check_feature_enabled redirect_to root_url unless FeatureManagement.account_reset_enabled? end - def reset_session_and_set_email - user = @account_reset_request.user + def reset_session_and_set_email(user) email = user.email user.destroy! sign_out diff --git a/app/controllers/account_reset/request_controller.rb b/app/controllers/account_reset/request_controller.rb index 0b99bc2ace5..0eb7e428faa 100644 --- a/app/controllers/account_reset/request_controller.rb +++ b/app/controllers/account_reset/request_controller.rb @@ -28,10 +28,13 @@ def reset_session_with_email end def send_notifications - SmsAccountResetNotifierJob.perform_now( - phone: current_user.phone, - cancel_token: current_user.account_reset_request.request_token - ) + phone = current_user.phone + if phone + SmsAccountResetNotifierJob.perform_now( + phone: phone, + cancel_token: current_user.account_reset_request.request_token + ) + end UserMailer.account_reset_request(current_user).deliver_later end diff --git a/app/controllers/concerns/user_session_context.rb b/app/controllers/concerns/user_session_context.rb index e7f58b53970..ed40c55796b 100644 --- a/app/controllers/concerns/user_session_context.rb +++ b/app/controllers/concerns/user_session_context.rb @@ -5,7 +5,6 @@ def context user_session[:context] || DEFAULT_CONTEXT end - # TODO: Figure out better names for this and the method below def initial_authentication_context? context == DEFAULT_CONTEXT end diff --git a/app/controllers/sms_controller.rb b/app/controllers/sms_controller.rb new file mode 100644 index 00000000000..2d11844de64 --- /dev/null +++ b/app/controllers/sms_controller.rb @@ -0,0 +1,74 @@ +class SmsController < ApplicationController + include ActionController::HttpAuthentication::Basic::ControllerMethods + include SecureHeadersConcern + + # Twilio supports HTTP Basic Auth for request URL + # https://www.twilio.com/docs/usage/security + before_action :authenticate + + # Disable CSRF check + skip_before_action :verify_authenticity_token, only: [:receive] + + def receive + signature = request.headers[TwilioService::Sms::Request::SIGNATURE_HEADER] + message = TwilioService::Sms::Request.new(request.url, params, signature) + + handle_result(message, SmsForm.new(message).submit) + end + + private + + def handle_result(message, result) + if result.success? + process_success(message, result) + else + process_failure(result) + end + end + + def process_success(message, result) + response = TwilioService::Sms::Response.new(message) + SmsReplySenderJob.perform_later(response.reply) + + analytics.track_event( + Analytics::TWILIO_SMS_INBOUND_MESSAGE_RECEIVED, + result.to_h + ) + + head :accepted + end + + def process_failure(result) + analytics.track_event( + Analytics::TWILIO_SMS_INBOUND_MESSAGE_VALIDATION_FAILED, + result.to_h + ) + + head :forbidden + end + + # `http_basic_authenticate_with name` had issues related to testing, so using + # this method with a before action instead. (The former is a shortcut for the + # following, which is called internally by Rails.) + def authenticate + env = Figaro.env + + head :unauthorized unless auth_configured?(env) + + authenticate_or_request_with_http_basic do |username, password| + # This comparison uses & so that it doesn't short circuit and + # uses `secure_compare` so that length information + # isn't leaked. + ActiveSupport::SecurityUtils.secure_compare( + username, env.twilio_http_basic_auth_username + ) & ActiveSupport::SecurityUtils.secure_compare( + password, env.twilio_http_basic_auth_password + ) + end + end + + def auth_configured?(env) + env.twilio_http_basic_auth_username.present? && + env.twilio_http_basic_auth_password.present? + end +end diff --git a/app/controllers/two_factor_authentication/options_controller.rb b/app/controllers/two_factor_authentication/options_controller.rb new file mode 100644 index 00000000000..87bf581bdc7 --- /dev/null +++ b/app/controllers/two_factor_authentication/options_controller.rb @@ -0,0 +1,46 @@ +module TwoFactorAuthentication + class OptionsController < ApplicationController + include TwoFactorAuthenticatable + + def index + @two_factor_options_form = TwoFactorLoginOptionsForm.new(current_user) + @presenter = two_factor_options_presenter + analytics.track_event(Analytics::MULTI_FACTOR_AUTH_OPTION_LIST_VISIT) + end + + def create + @two_factor_options_form = TwoFactorLoginOptionsForm.new(current_user) + result = @two_factor_options_form.submit(two_factor_options_form_params) + analytics.track_event(Analytics::MULTI_FACTOR_AUTH_OPTION_LIST, result.to_h) + + if result.success? + process_valid_form + else + @presenter = two_factor_options_presenter + render :index + end + end + + private + + def two_factor_options_presenter + TwoFactorLoginOptionsPresenter.new(current_user, view_context, current_sp) + end + + def process_valid_form + factor_to_url = { + 'voice' => otp_send_url(otp_delivery_selection_form: { otp_delivery_preference: 'voice' }), + 'personal_key' => login_two_factor_personal_key_url, + 'sms' => otp_send_url(otp_delivery_selection_form: { otp_delivery_preference: 'sms' }), + 'auth_app' => login_two_factor_authenticator_url, + 'piv_cac' => login_two_factor_piv_cac_url, + } + url = factor_to_url[@two_factor_options_form.selection] + redirect_to url if url + end + + def two_factor_options_form_params + params.require(:two_factor_options_form).permit(:selection) + end + end +end diff --git a/app/controllers/two_factor_authentication/personal_key_verification_controller.rb b/app/controllers/two_factor_authentication/personal_key_verification_controller.rb index 8811fcaf4ed..033cbf37d18 100644 --- a/app/controllers/two_factor_authentication/personal_key_verification_controller.rb +++ b/app/controllers/two_factor_authentication/personal_key_verification_controller.rb @@ -8,7 +8,7 @@ def show analytics.track_event( Analytics::MULTI_FACTOR_AUTH_ENTER_PERSONAL_KEY_VISIT, context: context ) - + @presenter = TwoFactorAuthCode::PersonalKeyPresenter.new @personal_key_form = PersonalKeyForm.new(current_user) end diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index 31a53946858..3372aae0914 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -9,6 +9,7 @@ class SessionsController < Devise::SessionsController skip_before_action :require_no_authentication, only: [:new] before_action :check_user_needs_redirect, only: [:new] before_action :apply_secure_headers_override, only: [:new] + before_action :configure_permitted_parameters, only: [:new] def new analytics.track_event( @@ -48,6 +49,12 @@ def timeout private + def configure_permitted_parameters + devise_parameter_sanitizer.permit(:sign_in) do |user_params| + user_params.permit(:email) if user_params.respond_to?(:permit) + end + end + def redirect_to_signin controller_info = 'users/sessions#create' analytics.track_event(Analytics::INVALID_AUTHENTICITY_TOKEN, controller: controller_info) diff --git a/app/controllers/users/two_factor_authentication_controller.rb b/app/controllers/users/two_factor_authentication_controller.rb index 2c93a0fd861..b24e0eeaf25 100644 --- a/app/controllers/users/two_factor_authentication_controller.rb +++ b/app/controllers/users/two_factor_authentication_controller.rb @@ -4,6 +4,7 @@ class TwoFactorAuthenticationController < ApplicationController before_action :check_remember_device_preference + # rubocop:disable Metrics/MethodLength def show if current_user.piv_cac_enabled? redirect_to login_two_factor_piv_cac_url @@ -14,7 +15,10 @@ def show else redirect_to two_factor_options_url end + rescue Twilio::REST::RestError, PhoneVerification::VerifyError => exception + invalid_phone_number(exception, action: 'show') end + # rubocop:enable Metrics/MethodLength def send_code result = otp_delivery_selection_form.submit(delivery_params) @@ -22,11 +26,12 @@ def send_code if result.success? handle_valid_otp_delivery_preference(user_selected_otp_delivery_preference) + update_otp_delivery_preference_if_needed else handle_invalid_otp_delivery_preference(result) end rescue Twilio::REST::RestError, PhoneVerification::VerifyError => exception - invalid_phone_number(exception) + invalid_phone_number(exception, action: 'send_code') end private @@ -44,19 +49,56 @@ def validate_otp_delivery_preference_and_send_code end end + def update_otp_delivery_preference_if_needed + OtpDeliveryPreferenceUpdater.new( + user: current_user, + preference: delivery_params[:otp_delivery_preference], + context: otp_delivery_selection_form.context + ).call + end + def handle_invalid_otp_delivery_preference(result) flash[:error] = result.errors[:phone].first preference = current_user.otp_delivery_preference redirect_to login_two_factor_url(otp_delivery_preference: preference) end - def invalid_phone_number(exception) - code = exception.code - analytics.track_event( - Analytics::TWILIO_PHONE_VALIDATION_FAILED, error: exception.message, code: code + def invalid_phone_number(exception, action:) + capture_analytics_for_exception(exception) + + if action == 'show' + redirect_to_otp_verification_with_error + else + flash[:error] = error_message(exception.code) + redirect_back(fallback_location: account_url) + 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: current_user.otp_delivery_preference, reauthn: reauthn? ) - flash[:error] = error_message(code) - redirect_back(fallback_location: account_url) + end + + # rubocop:disable Metrics/MethodLength + def capture_analytics_for_exception(exception) + attributes = { + error: exception.message, + code: exception.code, + context: context, + country: parsed_phone.country, + } + if exception.is_a?(PhoneVerification::VerifyError) + attributes[:status] = exception.status + attributes[:response] = exception.response + end + analytics.track_event(Analytics::TWILIO_PHONE_VALIDATION_FAILED, attributes) + end + # rubocop:enable Metrics/MethodLength + + def parsed_phone + @parsed_phone ||= Phonelib.parse(phone_to_deliver_to) end def error_message(code) @@ -68,7 +110,9 @@ def twilio_errors end def otp_delivery_selection_form - OtpDeliverySelectionForm.new(current_user, phone_to_deliver_to, context) + @otp_delivery_selection_form ||= OtpDeliverySelectionForm.new( + current_user, phone_to_deliver_to, context + ) end def reauthn_param diff --git a/app/forms/openid_connect_token_form.rb b/app/forms/openid_connect_token_form.rb index 4d690e2fa13..3afd4f57718 100644 --- a/app/forms/openid_connect_token_form.rb +++ b/app/forms/openid_connect_token_form.rb @@ -96,7 +96,6 @@ def validate_client_assertion sub: client_id, verify_sub: true) validate_aud_claim(payload) rescue JWT::DecodeError => err - # TODO: i18n these JWT gem error messages errors.add(:client_assertion, err.message) end diff --git a/app/forms/otp_delivery_selection_form.rb b/app/forms/otp_delivery_selection_form.rb index 4971439a99f..75b34f515f2 100644 --- a/app/forms/otp_delivery_selection_form.rb +++ b/app/forms/otp_delivery_selection_form.rb @@ -2,7 +2,7 @@ class OtpDeliverySelectionForm include ActiveModel::Model include OtpDeliveryPreferenceValidator - attr_reader :otp_delivery_preference, :phone + attr_reader :otp_delivery_preference, :phone, :context validates :otp_delivery_preference, inclusion: { in: %w[sms voice] } validates :phone, presence: true @@ -20,7 +20,6 @@ def submit(params) @success = valid? change_otp_delivery_preference_to_sms if unsupported_phone? - update_otp_delivery_preference if should_update_user? FormResponse.new(success: success, errors: errors.messages, extra: extra_analytics_attributes) end @@ -29,7 +28,7 @@ def submit(params) attr_writer :otp_delivery_preference attr_accessor :resend - attr_reader :success, :user, :context + attr_reader :success, :user def change_otp_delivery_preference_to_sms user_attributes = { otp_delivery_preference: 'sms' } @@ -43,24 +42,6 @@ def unsupported_phone? error_messages[:phone].first != I18n.t('errors.messages.missing_field') end - def update_otp_delivery_preference - user_attributes = { otp_delivery_preference: otp_delivery_preference } - UpdateUser.new(user: user, attributes: user_attributes).call - end - - def idv_context? - context == 'idv' - end - - def otp_delivery_preference_changed? - otp_delivery_preference != user.otp_delivery_preference - end - - def should_update_user? - return false if unsupported_phone? - success && otp_delivery_preference_changed? && !idv_context? - end - def extra_analytics_attributes { otp_delivery_preference: otp_delivery_preference, diff --git a/app/forms/sms_form.rb b/app/forms/sms_form.rb new file mode 100644 index 00000000000..830e84bc32c --- /dev/null +++ b/app/forms/sms_form.rb @@ -0,0 +1,28 @@ +class SmsForm + include ActiveModel::Model + + validate :validate_message + + def initialize(message) + @message = message + end + + def submit + success = valid? + + FormResponse.new( + success: success, + errors: errors.messages, + extra: message.extra_analytics_attributes + ) + end + + private + + attr_reader :message + + def validate_message + return if message.valid? + errors.add :base, :twilio_inbound_sms_invalid + end +end diff --git a/app/forms/two_factor_login_options_form.rb b/app/forms/two_factor_login_options_form.rb new file mode 100644 index 00000000000..b37f8afb9b7 --- /dev/null +++ b/app/forms/two_factor_login_options_form.rb @@ -0,0 +1,30 @@ +class TwoFactorLoginOptionsForm + include ActiveModel::Model + + attr_reader :selection + + validates :selection, inclusion: { in: %w[voice sms auth_app piv_cac personal_key] } + + def initialize(user) + self.user = user + end + + def submit(params) + self.selection = params[:selection] + + success = valid? + + FormResponse.new(success: success, errors: errors.messages, extra: extra_analytics_attributes) + end + + private + + attr_accessor :user + attr_writer :selection + + def extra_analytics_attributes + { + selection: selection, + } + end +end diff --git a/app/javascript/packs/personal-key-page-controller.js b/app/javascript/packs/personal-key-page-controller.js index c3122b67f7c..3e0f67e42b5 100644 --- a/app/javascript/packs/personal-key-page-controller.js +++ b/app/javascript/packs/personal-key-page-controller.js @@ -24,7 +24,6 @@ const personalKey = scrapePersonalKey(); // The following methods are strictly fallbacks for IE < 11. There is limited // support for HTML5 validation attributes in those browsers -// TODO: Potentially investigate readding client-side JS errors in a robust way function setInvalidHTML() { if (isInvalidForm) return; diff --git a/app/jobs/sms_account_reset_cancellation_notifier_job.rb b/app/jobs/sms_account_reset_cancellation_notifier_job.rb new file mode 100644 index 00000000000..d8f96b51e87 --- /dev/null +++ b/app/jobs/sms_account_reset_cancellation_notifier_job.rb @@ -0,0 +1,13 @@ +class SmsAccountResetCancellationNotifierJob < ApplicationJob + queue_as :sms + + def perform(phone:) + TwilioService::Utils.new.send_sms( + to: phone, + body: I18n.t( + 'jobs.sms_account_reset_cancel_job.message', + app: APP_NAME + ) + ) + end +end diff --git a/app/jobs/sms_account_reset_notifier_job.rb b/app/jobs/sms_account_reset_notifier_job.rb index f4584572f27..43d5045c07e 100644 --- a/app/jobs/sms_account_reset_notifier_job.rb +++ b/app/jobs/sms_account_reset_notifier_job.rb @@ -3,7 +3,7 @@ class SmsAccountResetNotifierJob < ApplicationJob include Rails.application.routes.url_helpers def perform(phone:, cancel_token:) - TwilioService.new.send_sms( + TwilioService::Utils.new.send_sms( to: phone, body: I18n.t( 'jobs.sms_account_reset_notifier_job.message', diff --git a/app/jobs/sms_otp_sender_job.rb b/app/jobs/sms_otp_sender_job.rb index 073363f2529..157e8d90c99 100644 --- a/app/jobs/sms_otp_sender_job.rb +++ b/app/jobs/sms_otp_sender_job.rb @@ -5,7 +5,7 @@ class SmsOtpSenderJob < ApplicationJob def perform(code:, phone:, otp_created_at:, locale: nil) return unless otp_valid?(otp_created_at) - if us_number? + if programmable_sms_number? send_sms_via_twilio_rest_api else send_sms_via_twilio_verify_api(locale) @@ -24,7 +24,7 @@ def phone end def send_sms_via_twilio_rest_api - TwilioService.new.send_sms( + TwilioService::Utils.new.send_sms( to: phone, body: I18n.t( 'jobs.sms_otp_sender_job.message', @@ -37,8 +37,9 @@ def send_sms_via_twilio_verify_api(locale) PhoneVerification.new(phone: phone, locale: locale, code: code).send_sms end - def us_number? - Phonelib.parse(phone).country == 'US' + def programmable_sms_number? + programmable_sms_countries = Figaro.env.programmable_sms_countries.split(',') + programmable_sms_countries.include?(Phonelib.parse(phone).country) end def otp_valid?(otp_created_at) diff --git a/app/jobs/sms_reply_sender_job.rb b/app/jobs/sms_reply_sender_job.rb new file mode 100644 index 00000000000..b1d4b6d7643 --- /dev/null +++ b/app/jobs/sms_reply_sender_job.rb @@ -0,0 +1,13 @@ +class SmsReplySenderJob < ApplicationJob + queue_as :sms + + def perform(params) + send_reply(params) + end + + private + + def send_reply(params) + TwilioService::Utils.new.send_sms(params) + end +end diff --git a/app/jobs/voice_otp_sender_job.rb b/app/jobs/voice_otp_sender_job.rb index b9d17f696cb..65aad329926 100644 --- a/app/jobs/voice_otp_sender_job.rb +++ b/app/jobs/voice_otp_sender_job.rb @@ -10,7 +10,7 @@ class VoiceOtpSenderJob < ApplicationJob # writing, we are only using Verify for non-US SMS, but we might expand # to Voice later. def perform(code:, phone:, otp_created_at:, locale: nil) - send_otp(TwilioService.new, code, phone) if otp_valid?(otp_created_at) + send_otp(TwilioService::Utils.new, code, phone) if otp_valid?(otp_created_at) end # rubocop:enable Lint/UnusedMethodArgument diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 72ff09e8954..2c6aa0e24e2 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -42,4 +42,8 @@ def account_reset_granted(user, account_reset) def account_reset_complete(email) mail(to: email, subject: t('user_mailer.account_reset_complete.subject')) end + + def account_reset_cancel(email) + mail(to: email, subject: t('user_mailer.account_reset_cancel.subject')) + end end diff --git a/app/models/anonymous_user.rb b/app/models/anonymous_user.rb index 95582e98bae..83eab07e127 100644 --- a/app/models/anonymous_user.rb +++ b/app/models/anonymous_user.rb @@ -6,4 +6,8 @@ def uuid def second_factor_locked_at nil end + + def phone + nil + end end diff --git a/app/models/user.rb b/app/models/user.rb index 3208419e1d5..d7011b09973 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -101,12 +101,7 @@ def last_identity end def active_identities - identities.where( - 'session_uuid IS NOT ?', - nil - ).order( - last_authenticated_at: :asc - ) || [] + identities.where('session_uuid IS NOT ?', nil).order(last_authenticated_at: :asc) || [] end def multiple_identities? diff --git a/app/policies/auth_app_login_option_policy.rb b/app/policies/auth_app_login_option_policy.rb new file mode 100644 index 00000000000..93a07ee4fd0 --- /dev/null +++ b/app/policies/auth_app_login_option_policy.rb @@ -0,0 +1,13 @@ +class AuthAppLoginOptionPolicy + def initialize(user) + @user = user + end + + def configured? + !user.otp_secret_key.nil? + end + + private + + attr_reader :user +end diff --git a/app/policies/personal_key_login_option_policy.rb b/app/policies/personal_key_login_option_policy.rb new file mode 100644 index 00000000000..05418eee4ed --- /dev/null +++ b/app/policies/personal_key_login_option_policy.rb @@ -0,0 +1,13 @@ +class PersonalKeyLoginOptionPolicy + def initialize(user) + @user = user + end + + def configured? + user.personal_key.present? + end + + private + + attr_reader :user +end diff --git a/app/policies/piv_cac_login_option_policy.rb b/app/policies/piv_cac_login_option_policy.rb new file mode 100644 index 00000000000..aca26fe7c5d --- /dev/null +++ b/app/policies/piv_cac_login_option_policy.rb @@ -0,0 +1,13 @@ +class PivCacLoginOptionPolicy + def initialize(user) + @user = user + end + + def configured? + FeatureManagement.piv_cac_enabled? && user.x509_dn_uuid.present? + end + + private + + attr_reader :user +end diff --git a/app/policies/sms_login_option_policy.rb b/app/policies/sms_login_option_policy.rb new file mode 100644 index 00000000000..53657192910 --- /dev/null +++ b/app/policies/sms_login_option_policy.rb @@ -0,0 +1,13 @@ +class SmsLoginOptionPolicy + def initialize(user) + @user = user + end + + def configured? + user.phone.present? + end + + private + + attr_reader :user +end diff --git a/app/policies/voice_login_option_policy.rb b/app/policies/voice_login_option_policy.rb new file mode 100644 index 00000000000..23ca5d4b8c4 --- /dev/null +++ b/app/policies/voice_login_option_policy.rb @@ -0,0 +1,18 @@ +class VoiceLoginOptionPolicy + def initialize(user) + @user = user + end + + def configured? + user_has_a_phone_number_that_we_can_call? + end + + private + + attr_reader :user + + def user_has_a_phone_number_that_we_can_call? + phone = user.phone + phone.present? && !PhoneNumberCapabilities.new(phone).sms_only? + end +end diff --git a/app/presenters/two_factor_auth_code/authenticator_delivery_presenter.rb b/app/presenters/two_factor_auth_code/authenticator_delivery_presenter.rb index ee9e22e0f5c..082527b1dd2 100644 --- a/app/presenters/two_factor_auth_code/authenticator_delivery_presenter.rb +++ b/app/presenters/two_factor_auth_code/authenticator_delivery_presenter.rb @@ -11,12 +11,8 @@ def help_text tooltip: view.tooltip(t('tooltips.authentication_app'))) end - def fallback_links - [ - otp_fallback_options, - piv_cac_link, - personal_key_link, - ].compact + def fallback_question + t('two_factor_authentication.totp_fallback.question') end def cancel_link @@ -31,30 +27,5 @@ def cancel_link private attr_reader :user_email, :two_factor_authentication_method, :phone_enabled - - def otp_fallback_options - return unless phone_enabled - t( - 'devise.two_factor_authentication.totp_fallback.text_html', - sms_link: sms_link, - voice_link: voice_link - ) - end - - def sms_link - view.link_to( - t('devise.two_factor_authentication.totp_fallback.sms_link_text'), - otp_send_path(locale: LinkLocaleResolver.locale, otp_delivery_selection_form: - { otp_delivery_preference: 'sms' }) - ) - end - - def voice_link - view.link_to( - t('devise.two_factor_authentication.totp_fallback.voice_link_text'), - otp_send_path(locale: LinkLocaleResolver.locale, otp_delivery_selection_form: - { otp_delivery_preference: 'voice' }) - ) - end end end diff --git a/app/presenters/two_factor_auth_code/personal_key_presenter.rb b/app/presenters/two_factor_auth_code/personal_key_presenter.rb new file mode 100644 index 00000000000..428307a1095 --- /dev/null +++ b/app/presenters/two_factor_auth_code/personal_key_presenter.rb @@ -0,0 +1,13 @@ +module TwoFactorAuthCode + class PersonalKeyPresenter < TwoFactorAuthCode::GenericDeliveryPresenter + def initialize; end + + def help_text + '' + end + + def fallback_question + t('two_factor_authentication.personal_key_fallback.question') + end + end +end diff --git a/app/presenters/two_factor_auth_code/phone_delivery_presenter.rb b/app/presenters/two_factor_auth_code/phone_delivery_presenter.rb index 47c4708c4a1..286f71eb134 100644 --- a/app/presenters/two_factor_auth_code/phone_delivery_presenter.rb +++ b/app/presenters/two_factor_auth_code/phone_delivery_presenter.rb @@ -1,5 +1,9 @@ module TwoFactorAuthCode class PhoneDeliveryPresenter < TwoFactorAuthCode::GenericDeliveryPresenter + attr_reader( + :otp_delivery_preference + ) + def header t('devise.two_factor_authentication.header_text') end @@ -10,19 +14,19 @@ def phone_number_message expiration: Figaro.env.otp_valid_for) end + def fallback_question + t('two_factor_authentication.phone_fallback.question') + end + def help_text - t("instructions.mfa.#{otp_delivery_preference}.confirm_code_html", - resend_code_link: resend_code_link) + '' end - def fallback_links - [ - otp_fallback_options, - update_phone_link, - piv_cac_option, - personal_key_link, - account_reset_link, - ].compact + def update_phone_link + return unless unconfirmed_phone + + link = view.link_to(t('forms.two_factor.try_again'), reenter_phone_number_path) + t('instructions.mfa.wrong_number_html', link: link) end def cancel_link @@ -43,95 +47,15 @@ def cancel_link :reenter_phone_number_path, :phone_number, :unconfirmed_phone, - :otp_delivery_preference, :account_reset_token, :confirmation_for_phone_change, :voice_otp_delivery_unsupported, :confirmation_for_idv ) - def otp_fallback_options - if totp_enabled - otp_fallback_options_with_totp - elsif !voice_otp_delivery_unsupported - safe_join([phone_fallback_link, '.']) - end - end - - def otp_fallback_options_with_totp - if voice_otp_delivery_unsupported - safe_join([auth_app_fallback_tag, '.']) - else - safe_join([phone_fallback_link, auth_app_fallback_link]) - end - end - - def update_phone_link - return unless unconfirmed_phone - - link = view.link_to(t('forms.two_factor.try_again'), reenter_phone_number_path) - t('instructions.mfa.wrong_number_html', link: link) - end - def account_reset_link return if unconfirmed_phone || !FeatureManagement.account_reset_enabled? account_reset_or_cancel_link end - - def account_reset_or_cancel_link - if account_reset_token - t('devise.two_factor_authentication.account_reset.pending_html', cancel_link: - view.link_to(t('devise.two_factor_authentication.account_reset.cancel_link'), - account_reset_cancel_url(token: account_reset_token))) - else - t('devise.two_factor_authentication.account_reset.text_html', link: - view.link_to(t('devise.two_factor_authentication.account_reset.link'), - account_reset_request_path(locale: LinkLocaleResolver.locale))) - end - end - - def phone_fallback_link - t(fallback_instructions, link: phone_link_tag) - end - - def phone_link_tag - view.link_to( - t("links.two_factor_authentication.#{fallback_method}"), - otp_send_path(locale: LinkLocaleResolver.locale, otp_delivery_selection_form: - { otp_delivery_preference: fallback_method }) - ) - end - - def auth_app_fallback_link - t('links.phone_confirmation.auth_app_fallback_html', link: auth_app_fallback_tag) - end - - def auth_app_fallback_tag - view.link_to( - t('links.two_factor_authentication.app'), - login_two_factor_authenticator_path(locale: LinkLocaleResolver.locale) - ) - end - - def fallback_instructions - "instructions.mfa.#{otp_delivery_preference}.fallback_html" - end - - def fallback_method - if otp_delivery_preference == 'voice' - 'sms' - elsif otp_delivery_preference == 'sms' - 'voice' - end - end - - def resend_code_link - view.link_to( - t("links.two_factor_authentication.resend_code.#{otp_delivery_preference}"), - otp_send_path(locale: LinkLocaleResolver.locale, - otp_delivery_selection_form: - { otp_delivery_preference: otp_delivery_preference, resend: true }) - ) - end end end diff --git a/app/presenters/two_factor_auth_code/piv_cac_authentication_presenter.rb b/app/presenters/two_factor_auth_code/piv_cac_authentication_presenter.rb index 8efbed8b36f..719781d08ba 100644 --- a/app/presenters/two_factor_auth_code/piv_cac_authentication_presenter.rb +++ b/app/presenters/two_factor_auth_code/piv_cac_authentication_presenter.rb @@ -17,14 +17,6 @@ def piv_cac_capture_text t('forms.piv_cac_mfa.submit') end - def fallback_links - [ - otp_fallback_options, - auth_app_fallback, - personal_key_link, - ].compact - end - def cancel_link locale = LinkLocaleResolver.locale if reauthn @@ -38,42 +30,12 @@ def piv_cac_service_link redirect_to_piv_cac_service_url end - private - - attr_reader :user_email, :two_factor_authentication_method, :totp_enabled, :phone_enabled - - def otp_fallback_options - return unless phone_enabled - t( - 'devise.two_factor_authentication.totp_fallback.text_html', - sms_link: sms_link, - voice_link: voice_link - ) - end - - def sms_link - view.link_to( - t('devise.two_factor_authentication.totp_fallback.sms_link_text'), - login_two_factor_path(locale: LinkLocaleResolver.locale, otp_delivery_preference: 'sms') - ) - end - - def voice_link - view.link_to( - t('devise.two_factor_authentication.totp_fallback.voice_link_text'), - login_two_factor_path(locale: LinkLocaleResolver.locale, otp_delivery_preference: 'voice') - ) + def fallback_question + t('two_factor_authentication.piv_cac_fallback.question') end - def auth_app_fallback - safe_join([auth_app_fallback_tag, '.']) if totp_enabled - end + private - def auth_app_fallback_tag - view.link_to( - t('links.two_factor_authentication.app'), - login_two_factor_authenticator_path(locale: LinkLocaleResolver.locale) - ) - end + attr_reader :user_email, :two_factor_authentication_method, :totp_enabled, :phone_enabled end end diff --git a/app/presenters/two_factor_login_options_presenter.rb b/app/presenters/two_factor_login_options_presenter.rb new file mode 100644 index 00000000000..0775f3b99c1 --- /dev/null +++ b/app/presenters/two_factor_login_options_presenter.rb @@ -0,0 +1,79 @@ +class TwoFactorLoginOptionsPresenter < TwoFactorAuthCode::GenericDeliveryPresenter + include ActionView::Helpers::TranslationHelper + + POSSIBLE_OPTIONS = %i[sms voice auth_app piv_cac personal_key].freeze + POLICIES = { + sms: SmsLoginOptionPolicy, + voice: VoiceLoginOptionPolicy, + auth_app: AuthAppLoginOptionPolicy, + piv_cac: PivCacLoginOptionPolicy, + personal_key: PersonalKeyLoginOptionPolicy, + }.freeze + + attr_reader :current_user + + def initialize(current_user, view, service_provider) + @current_user = current_user + @view = view + @service_provider = service_provider + end + + def title + t('two_factor_authentication.login_options_title') + end + + def heading + t('two_factor_authentication.login_options_title') + end + + def info + t('two_factor_authentication.login_intro') + end + + def label + '' + end + + def options + configured_2fa_types.map do |type| + OpenStruct.new( + type: type, + label: t("two_factor_authentication.login_options.#{type}"), + info: t("two_factor_authentication.login_options.#{type}_info"), + selected: type == configured_2fa_types[0] + ) + end + end + + def account_reset_or_cancel_link + account_reset_token ? account_reset_cancel_link : account_reset_link + end + + private + + def account_reset_link + t('devise.two_factor_authentication.account_reset.text_html', + link: @view.link_to( + t('devise.two_factor_authentication.account_reset.link'), + account_reset_request_path(locale: LinkLocaleResolver.locale) + )) + end + + def account_reset_cancel_link + t('devise.two_factor_authentication.account_reset.pending_html', + cancel_link: @view.link_to( + t('devise.two_factor_authentication.account_reset.cancel_link'), + account_reset_cancel_url(token: account_reset_token) + )) + end + + def account_reset_token + current_user&.account_reset_request&.request_token + end + + def configured_2fa_types + POSSIBLE_OPTIONS.each_with_object([]) do |option, result| + result << option if POLICIES[option].new(@current_user).configured? + end + end +end diff --git a/app/services/account_reset_service.rb b/app/services/account_reset_service.rb index 5bb429cb16d..901bdc5ee18 100644 --- a/app/services/account_reset_service.rb +++ b/app/services/account_reset_service.rb @@ -18,6 +18,7 @@ def self.cancel_request(token) account_reset.update(cancelled_at: Time.zone.now, request_token: nil, granted_token: nil) + account_reset end def self.report_fraud(token) @@ -66,10 +67,6 @@ def self.send_notifications_with_sql(users_sql) def self.reset_and_notify(arr) user = arr.user return false unless AccountResetService.new(user).grant_request - SmsAccountResetNotifierJob.perform_now( - phone: user.phone, - cancel_token: arr.request_token - ) UserMailer.account_reset_granted(user, arr).deliver_later true end diff --git a/app/services/analytics.rb b/app/services/analytics.rb index afca14abc63..485e9f24b74 100644 --- a/app/services/analytics.rb +++ b/app/services/analytics.rb @@ -78,6 +78,8 @@ def browser MULTI_FACTOR_AUTH_ENTER_OTP_VISIT = 'Multi-Factor Authentication: enter OTP visited'.freeze MULTI_FACTOR_AUTH_ENTER_PERSONAL_KEY_VISIT = 'Multi-Factor Authentication: enter personal key visited'.freeze MULTI_FACTOR_AUTH_MAX_ATTEMPTS = 'Multi-Factor Authentication: max attempts reached'.freeze + MULTI_FACTOR_AUTH_OPTION_LIST = 'Multi-Factor Authentication: option list'.freeze + MULTI_FACTOR_AUTH_OPTION_LIST_VISIT = 'Multi-Factor Authentication: option list visited'.freeze MULTI_FACTOR_AUTH_PHONE_SETUP = 'Multi-Factor Authentication: phone setup'.freeze MULTI_FACTOR_AUTH_MAX_SENDS = 'Multi-Factor Authentication: max otp sends reached'.freeze OPENID_CONNECT_BEARER_TOKEN = 'OpenID Connect: bearer token authentication'.freeze @@ -103,6 +105,8 @@ def browser TOTP_SETUP_VISIT = 'TOTP Setup Visited'.freeze TOTP_USER_DISABLED = 'TOTP: User Disabled TOTP'.freeze TWILIO_PHONE_VALIDATION_FAILED = 'Twilio Phone Validation Failed'.freeze + TWILIO_SMS_INBOUND_MESSAGE_RECEIVED = 'Twilio SMS Inbound Message Received'.freeze + TWILIO_SMS_INBOUND_MESSAGE_VALIDATION_FAILED = 'Twilio SMS Inbound Validation Failed'.freeze USER_REGISTRATION_AGENCY_HANDOFF_PAGE_VISIT = 'User registration: agency handoff visited'.freeze USER_REGISTRATION_AGENCY_HANDOFF_COMPLETE = 'User registration: agency handoff complete'.freeze USER_REGISTRATION_EMAIL = 'User Registration: Email Submitted'.freeze diff --git a/app/services/otp_delivery_preference_updater.rb b/app/services/otp_delivery_preference_updater.rb new file mode 100644 index 00000000000..2713dbbb2a2 --- /dev/null +++ b/app/services/otp_delivery_preference_updater.rb @@ -0,0 +1,29 @@ +class OtpDeliveryPreferenceUpdater + def initialize(user:, preference:, context:) + @user = user + @preference = preference + @context = context + end + + def call + user_attributes = { otp_delivery_preference: preference } + UpdateUser.new(user: user, attributes: user_attributes).call if should_update_user? + end + + private + + attr_reader :user, :preference, :context + + def should_update_user? + return false unless user + otp_delivery_preference_changed? && !idv_context? + end + + def otp_delivery_preference_changed? + preference != user.otp_delivery_preference + end + + def idv_context? + context == 'idv' + end +end diff --git a/app/services/phone_verification.rb b/app/services/phone_verification.rb index 94abe87be27..1ecc430869a 100644 --- a/app/services/phone_verification.rb +++ b/app/services/phone_verification.rb @@ -18,9 +18,18 @@ def initialize(phone:, code:, locale: nil) @locale = locale end + # rubocop:disable Style/GuardClause def send_sms - raise VerifyError.new(code: error_code, message: error_message) unless start_request.success? + unless start_request.success? + raise VerifyError.new( + code: error_code, + message: error_message, + status: start_request.response_code, + response: start_request.response_body + ) + end end + # rubocop:enable Style/GuardClause private @@ -36,6 +45,8 @@ def error_message def response_body @response_body ||= JSON.parse(start_request.response_body) + rescue JSON::ParserError + {} end def start_request @@ -73,11 +84,13 @@ def country_code end class VerifyError < StandardError - attr_reader :code, :message + attr_reader :code, :message, :status, :response - def initialize(code:, message:) + def initialize(code:, message:, status:, response:) @code = code @message = message + @status = status + @response = response end end end diff --git a/app/services/piv_cac_service.rb b/app/services/piv_cac_service.rb index facd772de2d..6de250dc710 100644 --- a/app/services/piv_cac_service.rb +++ b/app/services/piv_cac_service.rb @@ -75,8 +75,6 @@ def decode_request(uri, token) end def authenticate(token) - # TODO: make this secret required once we have everything deployed and configured - # The piv/cac service side is pending, so this is not critical yet. secret = Figaro.env.piv_cac_verify_token_secret return '' if secret.blank? nonce = SecureRandom.hex(10) diff --git a/app/services/twilio_service.rb b/app/services/twilio_service.rb index 53e4e52667c..fb866c21b61 100644 --- a/app/services/twilio_service.rb +++ b/app/services/twilio_service.rb @@ -1,71 +1,75 @@ require 'typhoeus/adapters/faraday' -class TwilioService - cattr_accessor :telephony_service do - Twilio::REST::Client - end +module TwilioService + class Utils + cattr_accessor :telephony_service do + Twilio::REST::Client + end - def initialize - @client = if FeatureManagement.telephony_disabled? - NullTwilioClient.new - else - twilio_client - end - @client.http_client.adapter = :typhoeus - end + def initialize + @client = if FeatureManagement.telephony_disabled? + NullTwilioClient.new + else + twilio_client + end + @client.http_client.adapter = :typhoeus + end - def place_call(params = {}) - sanitize_errors do - params = params.reverse_merge(from: from_number) - client.calls.create(params) + def place_call(params = {}) + sanitize_errors do + params = params.reverse_merge(from: from_number) + client.calls.create(params) + end end - end - def send_sms(params = {}) - sanitize_errors do - params = params.reverse_merge(messaging_service_sid: Figaro.env.twilio_messaging_service_sid) - client.messages.create(params) + def send_sms(params = {}) + sanitize_errors do + params = params.reverse_merge( + messaging_service_sid: Figaro.env.twilio_messaging_service_sid + ) + client.messages.create(params) + end end - end - def phone_number - @phone_number ||= random_phone_number - end + def phone_number + @phone_number ||= random_phone_number + end - def from_number - "+1#{phone_number}" - end + def from_number + "+1#{phone_number}" + end - private + private - attr_reader :client + attr_reader :client - def twilio_client - telephony_service.new( - TWILIO_SID, - TWILIO_AUTH_TOKEN - ) - end + def twilio_client + telephony_service.new( + TWILIO_SID, + TWILIO_AUTH_TOKEN + ) + end - def random_phone_number - TWILIO_NUMBERS.sample - end + def random_phone_number + TWILIO_NUMBERS.sample + end - def sanitize_errors - yield - rescue Twilio::REST::RestError => error - sanitize_phone_number(error.message) - raise - end + def sanitize_errors + yield + rescue Twilio::REST::RestError => error + sanitize_phone_number(error.message) + raise + end - DIGITS_TO_PRESERVE = 5 + DIGITS_TO_PRESERVE = 5 - def sanitize_phone_number(str) - str.gsub!(/\+[\d\(\)\- ]+/) do |match| - digits_preserved = 0 + def sanitize_phone_number(str) + str.gsub!(/\+[\d\(\)\- ]+/) do |match| + digits_preserved = 0 - match.gsub(/\d/) do |chr| - (digits_preserved += 1) <= DIGITS_TO_PRESERVE ? chr : '#' + match.gsub(/\d/) do |chr| + (digits_preserved += 1) <= DIGITS_TO_PRESERVE ? chr : '#' + end end end end diff --git a/app/services/twilio_service/sms/request.rb b/app/services/twilio_service/sms/request.rb new file mode 100644 index 00000000000..a02e2046c95 --- /dev/null +++ b/app/services/twilio_service/sms/request.rb @@ -0,0 +1,56 @@ +module TwilioService + module Sms + class Request + MESSAGE_PARAM_BODY = 'Body'.freeze + MESSAGE_PARAM_FROM = 'From'.freeze + MESSAGE_PARAM_FROM_COUNTRY = 'FromCountry'.freeze + MESSAGE_PARAM_MESSAGE_SID = 'MessageSid'.freeze # Twilio ID + SIGNATURE_HEADER = 'HTTP_X_TWILIO_SIGNATURE'.freeze + + def initialize(url, params, signature) + @url = url + @params = params.reject { |key| key.downcase == key } + @signature = signature + end + + def valid? + signature_valid? && message_valid? + end + + def message + @message ||= params[Request::MESSAGE_PARAM_BODY.to_sym]&.downcase + end + + def from + @from = params[Request::MESSAGE_PARAM_FROM.to_sym] + end + + def extra_analytics_attributes + { + message_sid: params[Request::MESSAGE_PARAM_MESSAGE_SID.to_sym], + from_country: params[Request::MESSAGE_PARAM_FROM_COUNTRY.to_sym], + } + end + + private + + attr_reader :url, :params, :signature + + # First, validate the message signature using Twilio's library: + # https://github.com/twilio/twilio-ruby/wiki/Request-Validator + def signature_valid? + Twilio::Security::RequestValidator.new( + Figaro.env.twilio_auth_token + ).validate(url, params, signature) + end + + def message_valid? + message.present? && Response::MESSAGE_TYPES.include?(message) + + # We may also want to validate the 'From' number against existing Users + # before processing the submission; however this is on-hold during the + # initial phase. + end + end + end +end diff --git a/app/services/twilio_service/sms/response.rb b/app/services/twilio_service/sms/response.rb new file mode 100644 index 00000000000..f217e069ba3 --- /dev/null +++ b/app/services/twilio_service/sms/response.rb @@ -0,0 +1,39 @@ +module TwilioService + module Sms + class Response + MESSAGE_STOP_VARIANTS = %w[cancel end quit unsubscribe].freeze + MESSAGE_TYPES = %w[help stop].concat(MESSAGE_STOP_VARIANTS).freeze + SIGNATURE_HEADER = 'HTTP_X_TWILIO_SIGNATURE'.freeze + + # CTIA short code guidelines require support for multiple stop words + MESSAGE_STOP_VARIANTS.each { |msg| define_method(msg) { send('stop') } } + + delegate :extra_analytics_attributes, to: :request + + def initialize(request) + @request = request + end + + def reply + return unless request.valid? + + { + to: request.from, + body: send(request.message), + } + end + + private + + attr_reader :request + + def help + I18n.t('sms.help.message') + end + + def stop + I18n.t('sms.stop.message') + end + end + end +end diff --git a/app/views/devise/sessions/new.html.slim b/app/views/devise/sessions/new.html.slim index 36288c27330..cff0c8f1d01 100644 --- a/app/views/devise/sessions/new.html.slim +++ b/app/views/devise/sessions/new.html.slim @@ -11,7 +11,8 @@ h1.h3.my0 = decorated_session.new_session_heading = f.input :email, label: t('account.index.email'), required: true, input_html: { class: 'mb4' } = f.input :password, label: t('account.index.password'), required: true = f.input :request_id, as: :hidden, input_html: { value: request_id } - = f.button :submit, t('links.next'), class: 'btn-wide' + div + = f.button :submit, t('links.next'), class: 'sm-col-6 col-12 btn-wide mb3' p.my3 = link_to t('notices.terms_of_service.link'), MarketingSite.privacy_url, target: '_blank' diff --git a/app/views/exception_notifier/_session.text.erb b/app/views/exception_notifier/_session.text.erb index 65583037bfc..496e1aa9abc 100644 --- a/app/views/exception_notifier/_session.text.erb +++ b/app/views/exception_notifier/_session.text.erb @@ -16,6 +16,8 @@ Referer: <%= @request.referer %> <% session['user_return_to'] = session['user_return_to']&.split('?')&.first %> Session: <%= session %> -User UUID: <%= @kontroller.analytics_user.uuid %> +<% user = @kontroller.analytics_user %> +User UUID: <%= user.uuid %> +User's Country (based on phone): <%= Phonelib.parse(user.phone).country %> Visitor ID: <%= @request.cookies['ahoy_visitor'] %> diff --git a/app/views/idv/jurisdiction/new.html.slim b/app/views/idv/jurisdiction/new.html.slim index a03462cae6e..5a1303709ef 100644 --- a/app/views/idv/jurisdiction/new.html.slim +++ b/app/views/idv/jurisdiction/new.html.slim @@ -20,6 +20,7 @@ p.mt4.mb0 id='jurisdiction-label' = t('idv.messages.jurisdiction.where') p = link_to t('idv.messages.jurisdiction.no_id'), idv_jurisdiction_fail_path(:no_id) .mt4 - button type='submit' class='btn btn-primary btn-wide' = t('forms.buttons.continue') + button type='submit' class='btn btn-primary btn-wide sm-col-6 col-12' + = t('forms.buttons.continue') = render 'shared/cancel', link: idv_cancel_path diff --git a/app/views/idv/otp_delivery_method/new.html.slim b/app/views/idv/otp_delivery_method/new.html.slim index b8f57fc798b..905bc44406c 100644 --- a/app/views/idv/otp_delivery_method/new.html.slim +++ b/app/views/idv/otp_delivery_method/new.html.slim @@ -1,33 +1,52 @@ h1.h3.my0 = t('idv.titles.otp_delivery_method') -p = t('idv.messages.otp_delivery_method.phone_number_html', +p.mt1 = t('idv.messages.otp_delivery_method.phone_number_html', phone: @set_otp_delivery_method_presenter.phone) = simple_form_for(@otp_delivery_selection_form, url: idv_otp_delivery_method_url, - html: { autocomplete: 'off', method: 'put', role: 'form', class: 'mt2' }) do |f| - fieldset.mb3.p0.border-none - legend.mb1.h4.serif.bold = t('devise.two_factor_authentication.otp_delivery_preference.title') - label.btn-border.col-12.sm-col-5.sm-mr2.mb2.sm-mb0 - .radio - = radio_button_tag 'otp_delivery_selection_form[otp_delivery_preference]', :sms, true, - class: :otp_delivery_preference_sms - span.indicator - = t('devise.two_factor_authentication.otp_delivery_preference.sms') - - if @set_otp_delivery_method_presenter.sms_only? - label.btn-border.col-12.sm-col-5.mb0.btn-disabled + html: { autocomplete: 'off', method: 'put', role: 'form', class: 'mt3' }) do |f| + fieldset.mb3.p0.border-none + label.btn-border.col-12.mb1 .radio - = radio_button_tag 'otp_delivery_selection_form[otp_delivery_preference]', :voice, false, - disabled: true, - class: :otp_delivery_preference_voice - span.indicator - = t('devise.two_factor_authentication.otp_delivery_preference.voice') - p.mt2.mb0 = @set_otp_delivery_method_presenter.phone_unsupported_message - - else - label.btn-border.col-12.sm-col-5.mb0 - .radio - = radio_button_tag 'otp_delivery_selection_form[otp_delivery_preference]', :voice, false, - class: :otp_delivery_preference_voice - span.indicator - = t('devise.two_factor_authentication.otp_delivery_preference.voice') - = f.submit t('idv.buttons.send_confirmation_code'), type: :submit, class: 'btn btn-primary' -.mt2.pt1.border-top - = t('instructions.mfa.wrong_number_html', - link: link_to(t('forms.two_factor.try_again'), idv_phone_path)) + = radio_button_tag 'otp_delivery_selection_form[otp_delivery_preference]', :sms, true, + class: :otp_delivery_preference_sms + span.indicator.mt-tiny + span.blue.bold.fs-20p + = t('devise.two_factor_authentication.otp_delivery_preference.sms') + .regular.gray-dark.fs-10p.mb-tiny + = t('devise.two_factor_authentication.two_factor_choice_options.sms_info') + - if @set_otp_delivery_method_presenter.sms_only? + label.btn-border.col-12.mb0.btn-disabled + .radio + = radio_button_tag 'otp_delivery_selection_form[otp_delivery_preference]', + :voice, false, + disabled: true, + class: :otp_delivery_preference_voice + span.indicator.mt-tiny + span.blue.bold.fs-20p + = t('devise.two_factor_authentication.otp_delivery_preference.voice') + .regular.gray-dark.fs-10p.mb-tiny + = t('devise.two_factor_authentication.two_factor_choice_options.voice_info') + p.mt2.mb0 = @set_otp_delivery_method_presenter.phone_unsupported_message + - else + label.btn-border.col-12.mb0 + .radio + = radio_button_tag 'otp_delivery_selection_form[otp_delivery_preference]', + :voice, false, + class: :otp_delivery_preference_voice + span.indicator.mt-tiny + span.blue.bold.fs-20p + = t('devise.two_factor_authentication.otp_delivery_preference.voice') + .regular.gray-dark.fs-10p.mb-tiny + = t('devise.two_factor_authentication.two_factor_choice_options.voice_info') + .mt3 + = t('idv.form.no_alternate_phone_html', + link: link_to(t('idv.form.activate_by_mail'), idv_usps_path)) + .mt2 + = t('instructions.mfa.wrong_number_html', + link: link_to(t('forms.two_factor.try_again'), idv_phone_path)) + .mt3 + = f.submit t('idv.buttons.send_confirmation_code'), + type: :submit, + class: 'sm-col-6 col-12 btn btn-primary' +.mt3.border-top + .mt1 + = link_to t('links.cancel'), idv_phone_path diff --git a/app/views/idv/phone/new.html.slim b/app/views/idv/phone/new.html.slim index 95d85af95e8..034afb93361 100644 --- a/app/views/idv/phone/new.html.slim +++ b/app/views/idv/phone/new.html.slim @@ -18,7 +18,7 @@ ul.py1.m0 - if FeatureManagement.enable_usps_verification? = render 'verification_options' - = f.button :submit, t('forms.buttons.continue'), class: 'btn-wide mt6' + = f.button :submit, t('forms.buttons.continue'), class: 'btn-wide mt6 sm-col-6 col-12' = render 'shared/cancel', link: idv_cancel_path diff --git a/app/views/idv/sessions/new.html.slim b/app/views/idv/sessions/new.html.slim index e5c465ee9be..7221333b143 100644 --- a/app/views/idv/sessions/new.html.slim +++ b/app/views/idv/sessions/new.html.slim @@ -72,6 +72,7 @@ p = link_to t('links.access_help'), input_html: { class: 'zipcode', value: @view_model.idv_form.zipcode } .mt3 - button type='submit' class='btn btn-primary btn-wide' = t('forms.buttons.continue') + button type='submit' class='btn btn-primary btn-wide sm-col-6 col-12' + = t('forms.buttons.continue') = render 'shared/cancel', link: idv_cancel_path = render @view_model.modal_partial, view_model: @view_model diff --git a/app/views/idv/sessions/success.html.slim b/app/views/idv/sessions/success.html.slim index 00c5607940c..1c29f42756d 100644 --- a/app/views/idv/sessions/success.html.slim +++ b/app/views/idv/sessions/success.html.slim @@ -10,6 +10,7 @@ h1.h3.mb2.mt3.my0 = t('idv.titles.session.success') h2.h3.mb6.my0 = t('idv.messages.sessions.success') -= link_to t('forms.buttons.continue'), idv_phone_path, class: 'btn btn-primary btn-wide' += link_to t('forms.buttons.continue'), idv_phone_path, + class: 'btn btn-primary btn-wide sm-col-6 col-12' = render 'shared/cancel', link: idv_cancel_path diff --git a/app/views/shared/_cancel_or_back_to_options.html.slim b/app/views/shared/_cancel_or_back_to_options.html.slim index 82652316b46..2b00c63cb62 100644 --- a/app/views/shared/_cancel_or_back_to_options.html.slim +++ b/app/views/shared/_cancel_or_back_to_options.html.slim @@ -2,4 +2,4 @@ - if user_fully_authenticated? = link_to cancel_link_text, account_path, class: 'h5' - else - = link_to t('devise.two_factor_authentication.two_factor_choice_cancel'), two_factor_options_path + = link_to t('two_factor_authentication.choose_another_option'), two_factor_options_path diff --git a/app/views/shared/_fallback_links.html.slim b/app/views/shared/_fallback_links.html.slim index 6538b7c9438..5ca036347c9 100644 --- a/app/views/shared/_fallback_links.html.slim +++ b/app/views/shared/_fallback_links.html.slim @@ -1,3 +1,6 @@ p.mt3#code-instructs = @presenter.help_text -- presenter.fallback_links.each do |link| - p = link +h4.my0 = @presenter.fallback_question += link_to(t('two_factor_authentication.login_options_link_text'), \ + login_two_factor_options_path) +br +br diff --git a/app/views/shared/_personal_key.html.slim b/app/views/shared/_personal_key.html.slim index 505a25d8f31..32b4d73434f 100644 --- a/app/views/shared/_personal_key.html.slim +++ b/app/views/shared/_personal_key.html.slim @@ -19,9 +19,10 @@ p.mt-tiny.mb0 = accordion('personal-key-info', t('users.personal_key.help_text_header')) do = simple_format(t('users.personal_key.help_text')) -= button_to t('forms.buttons.continue'), update_path, - class: 'btn btn-primary btn-wide mb1 personal-key-continue', - 'data-toggle': 'modal' +div + = button_to t('forms.buttons.continue'), update_path, + class: 'btn btn-primary btn-wide mb1 personal-key-continue sm-col-6 col-12 mb3', + 'data-toggle': 'modal' = render 'shared/personal_key_confirmation_modal', code: code, update_path: update_path diff --git a/app/views/shared/_user_verify_password.html.slim b/app/views/shared/_user_verify_password.html.slim index 6b67395e16c..bdffdb4d867 100644 --- a/app/views/shared/_user_verify_password.html.slim +++ b/app/views/shared/_user_verify_password.html.slim @@ -3,4 +3,4 @@ p = t('idv.messages.sessions.review_message') = simple_form_for(current_user, url: update_path, html: { autocomplete: 'off', method: :put, role: 'form' }) do |f| = f.input :password, label: t('idv.form.password'), required: true - = f.button :submit, t('forms.buttons.continue'), class: 'btn btn-primary btn-wide' + = f.button :submit, t('forms.buttons.continue'), class: 'btn btn-primary btn-wide sm-col-6 col-12' diff --git a/app/views/sign_up/passwords/new.html.slim b/app/views/sign_up/passwords/new.html.slim index 132231e19c4..a3ea86ba938 100644 --- a/app/views/sign_up/passwords/new.html.slim +++ b/app/views/sign_up/passwords/new.html.slim @@ -12,7 +12,8 @@ p.mt2.mb0#password-description = render 'devise/shared/password_strength' = hidden_field_tag :confirmation_token, @confirmation_token, id: 'confirmation_token' = f.input :request_id, as: :hidden, input_html: { value: params[:request_id] || request_id } - = f.button :submit, t('forms.buttons.continue'), class: 'btn-wide mb3' + div + = f.button :submit, t('forms.buttons.continue'), class: 'sm-col-6 col-12 btn-wide mb3' = render 'shared/password_accordion' diff --git a/app/views/two_factor_authentication/options/index.html.slim b/app/views/two_factor_authentication/options/index.html.slim new file mode 100644 index 00000000000..75c6c1f3ed5 --- /dev/null +++ b/app/views/two_factor_authentication/options/index.html.slim @@ -0,0 +1,27 @@ +- title @presenter.title + +h1.h3.my0 = @presenter.heading +p.mt-tiny.mb3 = @presenter.info + += simple_form_for(@two_factor_options_form, + html: { autocomplete: 'off', role: 'form' }, + method: :post, + url: login_two_factor_options_path) do |f| + .mb3 + fieldset.m0.p0.border-none. + legend.mb1.h4.serif.bold = @presenter.label + - @presenter.options.each do |option| + label.btn-border.col-12.mb1 for="two_factor_options_form_selection_#{option.type}" + .radio + = radio_button_tag('two_factor_options_form[selection]', + option.type, + option.selected) + span.indicator.mt-tiny + span.blue.bold.fs-20p = option.label + .regular.gray-dark.fs-10p.mb-tiny = option.info + + = f.button :submit, t('forms.buttons.continue') + +br +p = @presenter.account_reset_or_cancel_link += render 'shared/cancel', link: destroy_user_session_path diff --git a/app/views/two_factor_authentication/otp_verification/show.html.slim b/app/views/two_factor_authentication/otp_verification/show.html.slim index cc206d8b409..48cb79737bb 100644 --- a/app/views/two_factor_authentication/otp_verification/show.html.slim +++ b/app/views/two_factor_authentication/otp_verification/show.html.slim @@ -14,13 +14,24 @@ p == @presenter.phone_number_message autofocus: true, pattern: '[0-9]*', class: 'col-12 field monospace mfa', 'aria-describedby': 'code-instructs', maxlength: Devise.direct_otp_length, autocomplete: 'off', type: 'tel') - = submit_tag t('forms.buttons.submit.default'), class: 'btn btn-primary align-top' + = submit_tag t('forms.buttons.submit.default'), class: 'btn btn-primary align-top sm-col-6 col-12' + br + br + = link_to(t('links.two_factor_authentication.get_another_code'), \ + otp_send_path(otp_delivery_selection_form: \ + { otp_delivery_preference: @presenter.otp_delivery_preference, resend: true }), + class: 'btn btn-link btn-border ico ico-refresh text-decoration-none', + form_class: 'inline-block') - if @presenter.remember_device_available? - .border.col-9.rounded-lg.mt2 + inline-block.span style="white-space:nowrap;" = check_box_tag 'remember_device', true, false, class: 'my2 ml2 mr1' = label_tag 'remember_device', t('forms.messages.remember_device', duration: Figaro.env.remember_device_expiration_days), class: 'blue' - -= render 'shared/fallback_links', presenter: @presenter +br +- if @presenter.update_phone_link.present? + br + = @presenter.update_phone_link +- else + = render 'shared/fallback_links', presenter: @presenter = render 'shared/cancel', link: @presenter.cancel_link diff --git a/app/views/two_factor_authentication/personal_key_verification/show.html.slim b/app/views/two_factor_authentication/personal_key_verification/show.html.slim index 00dacd73161..7c372eacd9d 100644 --- a/app/views/two_factor_authentication/personal_key_verification/show.html.slim +++ b/app/views/two_factor_authentication/personal_key_verification/show.html.slim @@ -8,4 +8,5 @@ p.mt-tiny.mb0 = t('devise.two_factor_authentication.personal_key_prompt') = render 'partials/personal_key/entry_fields', f: f, attribute_name: :personal_key = f.button :submit, t('forms.buttons.submit.default'), class: 'btn btn-primary' += render 'shared/fallback_links', presenter: @presenter = render 'shared/cancel', link: sign_out_path diff --git a/app/views/user_mailer/account_reset_cancel.html.slim b/app/views/user_mailer/account_reset_cancel.html.slim new file mode 100644 index 00000000000..530ae2ec5f9 --- /dev/null +++ b/app/views/user_mailer/account_reset_cancel.html.slim @@ -0,0 +1,16 @@ +p.lead == t('.intro', app: link_to(APP_NAME, Figaro.env.mailer_domain_name, class: 'gray')) + +table.spacer + tbody + tr + td.s10 height="10px" + |   +table.hr + tr + th + |   + +p == t('.help', + app: link_to(APP_NAME, Figaro.env.mailer_domain_name, class: 'gray'), + help_link: link_to(t('user_mailer.help_link_text'), MarketingSite.help_url), + contact_link: link_to(t('user_mailer.contact_link_text'), MarketingSite.contact_url)) diff --git a/app/views/users/phone_setup/index.html.slim b/app/views/users/phone_setup/index.html.slim index 7728c1797f2..feeef635fc4 100644 --- a/app/views/users/phone_setup/index.html.slim +++ b/app/views/users/phone_setup/index.html.slim @@ -19,10 +19,11 @@ p.mt-tiny.mb0 = @presenter.info strong.left = @presenter.label = f.input :phone, as: :tel, label: false, required: true, input_html: { class: 'phone col-8 mb4' } - = f.button :submit, t('forms.buttons.send_security_code') + div + = f.button :submit, t('forms.buttons.send_security_code'), class: 'sm-col-6 col-12 btn-wide mb3' .mt2.pt1.border-top - path = current_user.piv_cac_enabled? ? account_recovery_setup_path : two_factor_options_path - = link_to t('devise.two_factor_authentication.two_factor_choice_cancel'), path + = link_to t('two_factor_authentication.choose_another_option'), path = stylesheet_link_tag 'intl-tel-number/intlTelInput' = javascript_pack_tag 'intl-tel-input' diff --git a/app/views/users/two_factor_authentication_setup/index.html.slim b/app/views/users/two_factor_authentication_setup/index.html.slim index 85d136b722f..75ebf46d66f 100644 --- a/app/views/users/two_factor_authentication_setup/index.html.slim +++ b/app/views/users/two_factor_authentication_setup/index.html.slim @@ -20,6 +20,7 @@ p.mt-tiny.mb3 = @presenter.info span.blue.bold.fs-20p = option.label .regular.gray-dark.fs-10p.mb-tiny = option.info - = f.button :submit, t('forms.buttons.continue') + div + = f.button :submit, t('forms.buttons.continue'), class: 'sm-col-6 col-12 btn-wide mb3' = render 'shared/cancel', link: destroy_user_session_path diff --git a/config/application.yml.example b/config/application.yml.example index a4dcd267c5a..922451accb1 100644 --- a/config/application.yml.example +++ b/config/application.yml.example @@ -67,8 +67,6 @@ use_dashboard_service_providers: 'false' dashboard_url: 'https://dashboard.demo.login.gov' valid_authn_contexts: '["http://idmanagement.gov/ns/assurance/loa/1", "http://idmanagement.gov/ns/assurance/loa/3"]' -usps_mail_batch_hours: '24' - development: aamva_cert_enabled: 'true' aamva_public_key: '123abc' @@ -82,7 +80,7 @@ development: async_job_refresh_max_wait_seconds: '15' attribute_cost: '4000$8$4$' # SCrypt::Engine.calibrate(max_time: 0.5) attribute_encryption_key: '2086dfbd15f5b0c584f3664422a1d3409a0d2aa6084f65b6ba57d64d4257431c124158670c7655e45cabe64194f7f7b6c7970153c285bdb8287ec0c4f7553e25' - attribute_encryption_key_queue: '[{ "key": "11111111111111111111111111111111", "cost": "4000$8$4$" }, { "key": "key": "22222222222222222222222222222222", "cost": "4000$8$4$" }]' + attribute_encryption_key_queue: '[{ "key": "11111111111111111111111111111111", "cost": "4000$8$4$" }, { "key": "22222222222222222222222222222222", "cost": "4000$8$4$" }]' attribute_encryption_without_kms: 'false' available_locales: 'en es fr' aws_kms_key_id: 'alias/login-dot-gov-development-keymaker' @@ -155,6 +153,7 @@ development: piv_cac_service_url: 'https://localhost:8443/' piv_cac_verify_token_url: 'https://localhost:8443/' pkcs11_lib: '/opt/cloudhsm/lib/libcloudhsm_pkcs11.so' + programmable_sms_countries: 'US,CA,MX' proofer_mock_fallback: 'true' rack_mini_profiler: 'off' reauthn_window: '120' @@ -183,6 +182,8 @@ development: twilio_numbers: '["9999999999","2222222222"]' twilio_sid: 'sid1' twilio_auth_token: 'token1' + twilio_http_basic_auth_username: '' # Twilio console configure Request URL + twilio_http_basic_auth_password: '' # https://www.twilio.com/docs/usage/security twilio_messaging_service_sid: '123abc' twilio_record_voice: 'true' use_dashboard_service_providers: 'true' @@ -199,10 +200,6 @@ development: # These values serve as defaults for all production-like environments, which # includes *.identitysandbox.gov and *.login.gov. # -# TODO: remove empty/fake values from this block, which create the misleading -# impression that these values aren't used. In fact they will be used unless -# they are overriden by keys with the same name in the application.yml in the -# app secrets bucket. production: aamva_cert_enabled: 'true' aamva_public_key: # Base64 encoded public key for AAMVA @@ -276,9 +273,10 @@ production: participate_in_dap: 'false' # pair with google_analytics_key password_pepper: # generate via `rake secret` password_strength_enabled: 'true' - piv_cac_agencies: '["DOD"]' + piv_cac_agencies: '["DOD","NGA","EOP"]' piv_cac_enabled: 'false' pkcs11_lib: '/opt/cloudhsm/lib/libcloudhsm_pkcs11.so' + programmable_sms_countries: 'US,CA,MX' proofer_mock_fallback: 'true' reauthn_window: '120' recaptcha_enabled_percent: '0' @@ -304,6 +302,8 @@ production: twilio_numbers: # Add JSON encoded array of phone numbers twilio_sid: # Twilio SID twilio_auth_token: # Twilio auth token + twilio_http_basic_auth_username: '' # Twilio console configure Request URL + twilio_http_basic_auth_password: '' # https://www.twilio.com/docs/usage/security twilio_messaging_service_sid: # Twilio CoPilot SID twilio_record_voice: 'false' twilio_verify_api_key: 'change-me' @@ -400,7 +400,9 @@ test: piv_cac_verify_token_secret: '3ac13bfa23e22adae321194c083e783faf89469f6f85dcc0802b27475c94b5c3891b5657bd87d0c1ad65de459166440512f2311018db90d57b15d8ab6660748f' piv_cac_verify_token_url: 'https://localhost:8443/' pkcs11_lib: '/opt/cloudhsm/lib/libcloudhsm_pkcs11.so' + programmable_sms_countries: 'US,CA,MX' proofer_mock_fallback: 'true' + programmable_sms_countries: 'US,CA' reauthn_window: '120' recaptcha_enabled_percent: '0' recaptcha_site_key: 'key1' @@ -425,6 +427,8 @@ test: twilio_numbers: '["9999999999","2222222222"]' twilio_sid: 'sid1' twilio_auth_token: 'token1' + twilio_http_basic_auth_username: '' # Twilio console configure Request URL + twilio_http_basic_auth_password: '' # https://www.twilio.com/docs/usage/security twilio_messaging_service_sid: '123abc' twilio_record_voice: 'true' twilio_verify_api_key: 'secret' diff --git a/config/environments/production.rb b/config/environments/production.rb index 7a630bd1d5e..7f9b2d053ac 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -5,9 +5,9 @@ config.cache_classes = true config.eager_load = true config.consider_all_requests_local = false - config.action_controller.asset_host = Figaro.env.domain_name config.action_controller.perform_caching = true - config.serve_static_files = ENV['RAILS_SERVE_STATIC_FILES'].present? + + config.action_controller.asset_host = Figaro.env.asset_host || Figaro.env.domain_name config.assets.js_compressor = :uglifier config.assets.compile = false config.assets.digest = true @@ -19,7 +19,7 @@ host: Figaro.env.domain_name, protocol: 'https', } - config.action_mailer.asset_host = Figaro.env.mailer_domain_name + config.action_mailer.asset_host = Figaro.env.asset_host || Figaro.env.mailer_domain_name config.action_mailer.raise_delivery_errors = true config.action_mailer.default_options = { from: Figaro.env.email_from } config.action_mailer.delivery_method = if Figaro.env.disable_email_sending == 'true' diff --git a/config/initializers/figaro.rb b/config/initializers/figaro.rb index ed1d5602f6d..fef45c0cbf7 100644 --- a/config/initializers/figaro.rb +++ b/config/initializers/figaro.rb @@ -30,6 +30,7 @@ 'password_max_attempts', 'password_pepper', 'password_strength_enabled', + 'programmable_sms_countries', 'queue_health_check_dead_interval_seconds', 'reauthn_window', 'recovery_code_length', diff --git a/config/initializers/new_relic_tracers.rb b/config/initializers/new_relic_tracers.rb index 7be3e33c66c..01fae6fa19b 100644 --- a/config/initializers/new_relic_tracers.rb +++ b/config/initializers/new_relic_tracers.rb @@ -66,7 +66,7 @@ add_method_tracer :encrypt, "Custom/#{name}/encrypt" end -TwilioService.class_eval do +TwilioService::Utils.class_eval do include ::NewRelic::Agent::MethodTracer add_method_tracer :place_call, "Custom/#{name}/place_call" add_method_tracer :send_sms, "Custom/#{name}/send_sms" diff --git a/config/initializers/secure_headers.rb b/config/initializers/secure_headers.rb index 76c04b56f57..f78e27b0ecd 100644 --- a/config/initializers/secure_headers.rb +++ b/config/initializers/secure_headers.rb @@ -18,8 +18,8 @@ '*.nr-data.net', '*.google-analytics.com', ], - font_src: ["'self'", 'data:'], - img_src: ["'self'", 'data:', 'login.gov'], + font_src: ["'self'", 'data:', Figaro.env.asset_host], + img_src: ["'self'", 'data:', 'login.gov', Figaro.env.asset_host], media_src: ["'self'"], object_src: ["'none'"], script_src: [ @@ -30,8 +30,9 @@ '*.google-analytics.com', 'www.google.com', 'www.gstatic.com', + Figaro.env.asset_host, ], - style_src: ["'self'"], + style_src: ["'self'", Figaro.env.asset_host], base_uri: ["'self'"], } diff --git a/config/locales/account_reset/en.yml b/config/locales/account_reset/en.yml index fe5a2b35f08..b165867510c 100644 --- a/config/locales/account_reset/en.yml +++ b/config/locales/account_reset/en.yml @@ -20,7 +20,7 @@ en: and you will need to restore each connection.

If you continue, you will first receive an email confirmation. As a security measure, you will receive another email with the link to continue deleting your account 24 hours - after the intial confirmation email arrives. + after the initial confirmation email arrives. are_you_sure: Are you sure you don't have access to any of your security methods OR your personal key? no_cancel: No, cancel diff --git a/config/locales/devise/en.yml b/config/locales/devise/en.yml index e9602f0374c..33f2bbc98c2 100644 --- a/config/locales/devise/en.yml +++ b/config/locales/devise/en.yml @@ -146,7 +146,7 @@ en: code below. please_try_again_html: Please try again in %{time_remaining}. account_reset: - successful_cancel: Thank you. The request to delete your login.gov account + successful_cancel: Thank you. Your request to delete your login.gov account has been cancelled. link: deleting your account text_html: If you can't use any of these security options above, you can reset @@ -178,23 +178,6 @@ en: piv_cac_info: Use your PIV/CAC card to secure your account. two_factor_choice_intro: login.gov makes sure you can access your account by adding a second layer of security. - two_factor_choice_cancel: "‹ Choose another option" - two_factor_login_choice: Secure your account - two_factor_login_choice_options: - voice: Phone call - voice_info: Get your security code via phone call. - sms: Text message / SMS - sms_info: Get your security code via text message / SMS. - auth_app: Authentication application - auth_app_info: Set up an authentication application to get your security code - without providing a phone number. - piv_cac: Government employees - piv_cac_info: Use your PIV/CAC card to secure your account. - personal_key: Personal Key - personal_key_info: Use the 12 character personal key you used at account creation. - two_factor_login_choice_intro: login.gov makes sure you can access your account - by adding a second layer of security. - two_factor_login_choice_cancel: "‹ Choose another option" two_factor_setup: Add a phone number user: new_otp_sent: We sent you a new one-time security code. diff --git a/config/locales/devise/es.yml b/config/locales/devise/es.yml index 1e273df55ee..242c09a1ac1 100644 --- a/config/locales/devise/es.yml +++ b/config/locales/devise/es.yml @@ -151,8 +151,8 @@ es: el código de seguridad a continuación. please_try_again_html: Inténtelo de nuevo en %{time_remaining}. account_reset: - successful_cancel: Gracias. La solicitud para eliminar su cuenta de login.gov - ha sido cancelado. + successful_cancel: Gracias. Su solicitud para eliminar su cuenta de login.gov + ha sido cancelada. link: eliminando su cuenta text_html: Si no puede usar ninguna de estas opciones de seguridad anteriores, puede restablecer tus preferencias por %{link}. @@ -184,24 +184,6 @@ es: piv_cac_info: Use su tarjeta PIV / CAC para asegurar su cuenta. two_factor_choice_intro: login.gov se asegura de que pueda acceder a su cuenta agregando una segunda capa de seguridad. - two_factor_choice_cancel: "‹ Elige otra opción" - two_factor_login_choice: Asegure su cuenta - two_factor_login_choice_options: - voice: Llamada telefónica - voice_info: Obtenga su código de seguridad a través de una llamada telefónica. - sms: Mensaje de texto / SMS - sms_info: Obtenga su código de seguridad a través de mensajes de texto / SMS. - auth_app: Aplicación de autenticación - auth_app_info: Configure una aplicación de autenticación para obtener su código - de seguridad sin proporcionar un número de teléfono. - piv_cac: Empleados del Gobierno - piv_cac_info: Use su tarjeta PIV / CAC para asegurar su cuenta. - personal_key: Clave personal - personal_key_info: Use la clave personal de 12 caracteres que usó en la creación - de la cuenta. - two_factor_login_choice_intro: login.gov se asegura de que pueda acceder a su - cuenta agregando una segunda capa de seguridad. - two_factor_login_choice_cancel: "‹ Elige otra opción" two_factor_setup: Añada un número de teléfono user: new_otp_sent: Le enviamos un nuevo código de sólo un uso diff --git a/config/locales/devise/fr.yml b/config/locales/devise/fr.yml index f9f764116c5..a9f2ea7d1f7 100644 --- a/config/locales/devise/fr.yml +++ b/config/locales/devise/fr.yml @@ -158,8 +158,8 @@ fr: le code de sécurité ci-dessous. please_try_again_html: Veuillez essayer de nouveau dans %{time_remaining}. account_reset: - successful_cancel: Je vous remercie. La demande de suppression de votre compte - login.gov a été annulé. + successful_cancel: Je vous remercie. Votre demande de suppression de votre + compte login.gov a été annulée. link: supprimer votre compte text_html: Si vous ne pouvez pas utiliser l'une de ces options de sécurité ci-dessus, vous pouvez réinitialiser vos préférences par %{link}. @@ -193,24 +193,6 @@ fr: piv_cac_info: Utilisez votre carte PIV / CAC pour sécuriser votre compte. two_factor_choice_intro: login.gov s'assure que vous pouvez accéder à votre compte en ajoutant une deuxième couche de sécurité. - two_factor_choice_cancel: "‹ Choisissez une autre option" - two_factor_login_choice: Sécurise ton compte - two_factor_login_choice_options: - voice: Appel téléphonique - voice_info: Obtenez votre code de sécurité par appel téléphonique. - sms: SMS - sms_info: Obtenez votre code de sécurité par SMS. - auth_app: Application d'authentification - auth_app_info: Configurez une application d'authentification pour obtenir - votre code de sécurité sans fournir de numéro de téléphone. - piv_cac: Employés du gouvernement - piv_cac_info: Utilisez votre carte PIV / CAC pour sécuriser votre compte. - personal_key: Clé personnelle - personal_key_info: Utilisez la clé personnelle de 12 caractères que vous avez - utilisée lors de la création du compte. - two_factor_login_choice_intro: login.gov s'assure que vous pouvez accéder à - votre compte en ajoutant une deuxième couche de sécurité. - two_factor_login_choice_cancel: "‹ Choisissez une autre option" two_factor_setup: Ajoutez un numéro de téléphone user: new_otp_sent: Nous vous avons envoyé un code de sécurité à utilisation unique. diff --git a/config/locales/errors/en.yml b/config/locales/errors/en.yml index d611f1aa46f..5dfe8ea8114 100644 --- a/config/locales/errors/en.yml +++ b/config/locales/errors/en.yml @@ -40,7 +40,10 @@ en: otp_incorrect: Incorrect code. Did you type it correctly? password_incorrect: Incorrect password personal_key_incorrect: Incorrect personal key + phone_unsupported: Sorry, we are unable to send SMS at this time. Please try + the phone call option below, or use your personal key. requires_phone: requires you to enter your phone number. + twilio_inbound_sms_invalid: The inbound Twilio SMS message failed validation. unauthorized_authn_context: Unauthorized authentication context unauthorized_nameid_format: Unauthorized nameID format unauthorized_service_provider: Unauthorized Service Provider diff --git a/config/locales/errors/es.yml b/config/locales/errors/es.yml index dc35a569c1b..3f5cd91d7ed 100644 --- a/config/locales/errors/es.yml +++ b/config/locales/errors/es.yml @@ -38,7 +38,10 @@ es: otp_incorrect: El código es incorrecto. ¿Lo escribió correctamente? password_incorrect: La contraseña es incorrecta personal_key_incorrect: La clave personal es incorrecta + phone_unsupported: Lo sentimos, no podemos enviar SMS en este momento. Pruebe + la opción de llamada telefónica a continuación o use su clave personal. requires_phone: requiere que ingrese su número de teléfono. + twilio_inbound_sms_invalid: El mensaje de Twilio SMS de entrada falló la validación. unauthorized_authn_context: Contexto de autenticación no autorizado unauthorized_nameid_format: NOT TRANSLATED YET unauthorized_service_provider: Proveedor de servicio no autorizado diff --git a/config/locales/errors/fr.yml b/config/locales/errors/fr.yml index edb0d0a551c..7b14eebc229 100644 --- a/config/locales/errors/fr.yml +++ b/config/locales/errors/fr.yml @@ -7,7 +7,7 @@ fr: invalid_totp: Code non valide. Veuillez essayer de nouveau. max_password_attempts_reached: Vous avez inscrit des mots de passe incorrects un trop grand nombre de fois. Vous pouvez réinitialiser votre mot de passe en - utilisant le lien « Vous avez oublié votre mot de passe? ». + utilisant le lien « Vous avez oublié votre mot de passe? ». messages: already_confirmed: a déjà été confirmé, veuillez essayer de vous connecter blank: Veuillez remplir ce champ. @@ -15,7 +15,7 @@ fr: confirmation_invalid_token: Lien de confirmation non valide. Le lien est expiré ou vous avez déjà confirmé votre compte. confirmation_period_expired: Lien de confirmation expiré. Vous pouvez cliquer - sur « Envoyer les instructions de confirmation de nouveau » pour en obtenir + sur « Envoyer les instructions de confirmation de nouveau » pour en obtenir un autre. expired: est expiré, veuillez en demander un nouveau format_mismatch: Veuillez vous assurer de respecter le format requis. @@ -33,13 +33,18 @@ fr: not_found: introuvable not_locked: n'a pas été verrouillé not_saved: - one: '1 erreur a interdit la sauvegarde de cette %{resource} :' - other: "%{count} des erreurs ont empêché la sauvegarde de cette %{resource} :" + one: '1 erreur a interdit la sauvegarde de cette %{resource} :' + other: "%{count} des erreurs ont empêché la sauvegarde de cette %{resource} + :" otp_failed: NOT TRANSLATED YET otp_incorrect: Code non valide. L'avez-vous inscrit correctement? password_incorrect: Mot de passe incorrect personal_key_incorrect: Clé personnelle incorrecte + phone_unsupported: Désolé, nous ne sommes pas en mesure d'envoyer des SMS pour + le moment. S'il vous plaît essayez l'option d'appel téléphonique ci-dessous, + ou utilisez votre clé personnelle. requires_phone: vous demande d'entrer votre numéro de téléphone. + twilio_inbound_sms_invalid: Le message SMS Twilio entrant a échoué à la validation. unauthorized_authn_context: Contexte d'authentification non autorisé unauthorized_nameid_format: NOT TRANSLATED YET unauthorized_service_provider: Fournisseur de service non autorisé diff --git a/config/locales/idv/en.yml b/config/locales/idv/en.yml index 95e23031361..52e4a432f3e 100644 --- a/config/locales/idv/en.yml +++ b/config/locales/idv/en.yml @@ -8,7 +8,7 @@ en: mail: resend: Send another letter send: Send a letter - send_confirmation_code: Send confirmation code + send_confirmation_code: Continue cancel: modal_header: Are you sure you want to cancel? warning_header: If you cancel now @@ -96,7 +96,7 @@ en: loading: Verifying your identity mail_sent: Your letter is on its way otp_delivery_method: - phone_number_html: We will send a code to %{phone} + phone_number_html: We'll send a code to %{phone} personal_key: This is your new personal key. Write it down and keep it in a safe place. You will need it if you ever lose your password. phone: @@ -177,7 +177,7 @@ en: resend: Want another letter? verify: Want a letter? no_id: We are unable to verify your identity without a state-issued ID - otp_delivery_method: Get a code by phone + otp_delivery_method: How would you like to receive a code? phone: Phone number of record review: Review and submit select_verification: Choose how to confirm your address diff --git a/config/locales/jobs/en.yml b/config/locales/jobs/en.yml index d7d47937775..e2b078abf16 100644 --- a/config/locales/jobs/en.yml +++ b/config/locales/jobs/en.yml @@ -4,6 +4,8 @@ en: sms_otp_sender_job: message: "%{code} is your %{app} one-time security code. This code will expire in %{expiration} minutes." + sms_account_reset_cancel_job: + message: Your request to delete your login.gov account has been cancelled. sms_account_reset_notifier_job: message: 'You''ve requested to delete your login.gov account. Your request will be processed in 24 hours. If you don’t want to delete your account, please diff --git a/config/locales/jobs/es.yml b/config/locales/jobs/es.yml index e0f5c767b71..6ead589ceff 100644 --- a/config/locales/jobs/es.yml +++ b/config/locales/jobs/es.yml @@ -4,6 +4,8 @@ es: sms_otp_sender_job: message: "%{code} es su %{app} código de seguridad de sólo un uso. Este código caducará en %{expiration} minutos." + sms_account_reset_cancel_job: + message: Su solicitud para eliminar su cuenta de login.gov ha sido cancelada. sms_account_reset_notifier_job: message: 'Has solicitado eliminar tu cuenta de login.gov. Su solicitud será ser procesado en 24 horas. Si no desea eliminar su cuenta, por favor cancelar: diff --git a/config/locales/jobs/fr.yml b/config/locales/jobs/fr.yml index 76c1a828e42..f4509827b37 100644 --- a/config/locales/jobs/fr.yml +++ b/config/locales/jobs/fr.yml @@ -4,6 +4,8 @@ fr: sms_otp_sender_job: message: "%{code} est votre %{app} code de sécurité à utilisation unique. Ce code expirera dans %{expiration} minutes." + sms_account_reset_cancel_job: + message: Votre demande de suppression de votre compte login.gov a été annulée. sms_account_reset_notifier_job: message: 'Vous avez demandé à supprimer votre compte login.gov. Votre demande sera être traité en 24 heures. Si vous ne souhaitez pas supprimer votre compte, diff --git a/config/locales/links/en.yml b/config/locales/links/en.yml index 2013caa010f..530e926d66b 100644 --- a/config/locales/links/en.yml +++ b/config/locales/links/en.yml @@ -17,7 +17,6 @@ en: passwords: forgot: Forgot your password? phone_confirmation: - auth_app_fallback_html: " or %{link}." fallback_to_sms_html: Send me a text message with the code instead fallback_to_voice_html: If you can't get text message right now, you can get a security code via %{link} @@ -29,10 +28,5 @@ en: sign_out: Sign out two_factor_authentication: app_option: Use an authentication application instead. - app: get a security code via authentication app - resend_code: - sms: Get another text message - voice: Get another phone call - sms: get a security code via text message - voice: get a security code via phone call + get_another_code: Get another code what_is_totp: What is an authentication app? diff --git a/config/locales/links/es.yml b/config/locales/links/es.yml index 85038930325..d73a441cba8 100644 --- a/config/locales/links/es.yml +++ b/config/locales/links/es.yml @@ -17,7 +17,6 @@ es: passwords: forgot: "¿Olvidó su contraseña?" phone_confirmation: - auth_app_fallback_html: o %{link}. fallback_to_sms_html: Envíeme un mensaje de texto con el código en su lugar fallback_to_voice_html: Si no puede recibir un mensaje de texto ahora mismo, puede obtener un código de seguridad a través de %{link} @@ -29,10 +28,5 @@ es: sign_out: Cerrar sesión two_factor_authentication: app_option: Use una aplicación de autenticación en su lugar. - app: Obtener un código de seguridad mediante la app de autenticación - resend_code: - sms: Obtener otro mensaje de texto - voice: Obtener otra llamada telefónica - sms: Obtener un código de seguridad a través de un mensaje de texto - voice: Obtener un código de seguridad a través de una llamada telefónica + get_another_code: Obtener otro código what_is_totp: "¿Qué es una app de autenticación?" diff --git a/config/locales/links/fr.yml b/config/locales/links/fr.yml index 7374d160fcf..2005e358dea 100644 --- a/config/locales/links/fr.yml +++ b/config/locales/links/fr.yml @@ -17,7 +17,6 @@ fr: passwords: forgot: Vous avez oublié votre mot de passe? phone_confirmation: - auth_app_fallback_html: " ou %{link}." fallback_to_sms_html: Envoyez-moi plutôt un SMS contenant le code fallback_to_voice_html: Si vous ne pouvez recevoir de message texte pour le moment, vous pouvez obtenir un code de sécurité par %{link} @@ -29,10 +28,5 @@ fr: sign_out: Déconnexion two_factor_authentication: app_option: Utilisez une application d'authentification à la place. - app: obtenir un code de sécurité par l'application d'authentification - resend_code: - sms: Recevoir un autre SMS - voice: Recevoir un autre appel téléphonique - sms: obtenir un code de sécurité par SMS - voice: obtenir un code par un appel téléphonique + get_another_code: Obtenir un autre code what_is_totp: Qu'est-ce qu'une application d'authentification? diff --git a/config/locales/sms/en.yml b/config/locales/sms/en.yml new file mode 100644 index 00000000000..8997dd2f8c1 --- /dev/null +++ b/config/locales/sms/en.yml @@ -0,0 +1,8 @@ +--- +en: + sms: + help: + message: 'login.gov: Get help at www.login.gov/help or email us at hello@login.gov.' + stop: + message: login.gov is not a subscription service. You will only receive security + codes when you sign in. Reply HELP for help or see www.login.gov/help. diff --git a/config/locales/sms/es.yml b/config/locales/sms/es.yml new file mode 100644 index 00000000000..911a91dcf10 --- /dev/null +++ b/config/locales/sms/es.yml @@ -0,0 +1,9 @@ +--- +es: + sms: + help: + message: 'login.gov: obtenga ayuda en www.login.gov/help o envíenos un correo + electrónico a hello@login.gov.' + stop: + message: login.gov no es un servicio de suscripción. Solo recibirá códigos de + seguridad cuando se registre. Responda HELP para obtener ayuda o visite www.login.gov/help. diff --git a/config/locales/sms/fr.yml b/config/locales/sms/fr.yml new file mode 100644 index 00000000000..7bf85d11c8f --- /dev/null +++ b/config/locales/sms/fr.yml @@ -0,0 +1,10 @@ +--- +fr: + sms: + help: + message: 'login.gov: Obtenez de l''aide sur www.login.gov/help ou envoyez-nous + un courriel à hello@login.gov.' + stop: + message: login.gov n'est pas un service d'abonnement. Vous ne recevrez des codes + de sécurité que lorsque vous vous connecterez. Aidez HELP pour obtenir de + l'aide ou consultez www.login.gov/help. diff --git a/config/locales/two_factor_authentication/en.yml b/config/locales/two_factor_authentication/en.yml new file mode 100644 index 00000000000..39db0d54380 --- /dev/null +++ b/config/locales/two_factor_authentication/en.yml @@ -0,0 +1,27 @@ +--- +en: + two_factor_authentication: + login_options: + auth_app: Authentication app + auth_app_info: Use your authentication application to get security code. + personal_key: Personal Key + personal_key_info: Use the 16 character personal key you received at account + creation. + piv_cac: Government employee ID + piv_cac_info: Use your PIV/CAC card instead of a security code. + sms: Text message + sms_info: Get security code via text message. + voice: Automated phone call + voice_info: Get security code via phone call (North America phone numbers only). + login_options_title: Select your security option + login_options_link_text: Choose another security option + login_intro: You set these up when you created your account + choose_another_option: "‹ Choose another option" + personal_key_fallback: + question: Don't have your personal key? + phone_fallback: + question: Don't have access to your phone right now? + piv_cac_fallback: + question: Don't have your piv/cac card available? + totp_fallback: + question: Don't have your authenticator app? diff --git a/config/locales/two_factor_authentication/es.yml b/config/locales/two_factor_authentication/es.yml new file mode 100644 index 00000000000..021b5307714 --- /dev/null +++ b/config/locales/two_factor_authentication/es.yml @@ -0,0 +1,29 @@ +--- +es: + two_factor_authentication: + login_options: + auth_app: Aplicación de autenticación + auth_app_info: Use su aplicación de autenticación para obtener el código de + seguridad. + personal_key: Clave personal + personal_key_info: Use la clave personal de 16 caracteres que usó en la creación + de la cuenta. + piv_cac: Empleados del Gobierno + piv_cac_info: Use su tarjeta PIV / CAC para asegurar su cuenta. + sms: Mensaje de texto / SMS + sms_info: Obtenga su código de seguridad a través de mensajes de texto / SMS. + voice: Llamada telefónica automatizada + voice_info: Obtenga su código de seguridad a través de una llamada telefónica. + (Solo números de teléfono de América del Norte). + login_options_title: Seleccione su opción de seguridad + login_options_link_text: Elige otra opción de seguridad + login_intro: Usted configuró esto cuando creó su cuenta + choose_another_option: "‹ Elige otra opción" + personal_key_fallback: + question: "¿No tiene su clave personal?" + phone_fallback: + question: "¿No tiene acceso a su teléfono ahora mismo?" + piv_cac_fallback: + question: "¿No tiene su tarjeta PIV/CAC disponible?" + totp_fallback: + question: "¿No tiene su aplicación de autenticación?" diff --git a/config/locales/two_factor_authentication/fr.yml b/config/locales/two_factor_authentication/fr.yml new file mode 100644 index 00000000000..43ac3727793 --- /dev/null +++ b/config/locales/two_factor_authentication/fr.yml @@ -0,0 +1,29 @@ +--- +fr: + two_factor_authentication: + login_options: + auth_app: Application d'authentification + auth_app_info: Utilisez votre application d'authentification pour obtenir votre + code de sécurité + personal_key: Clé personnelle + personal_key_info: Utilisez la clé personnelle de 16 caractères que vous avez + utilisée lors de la création du compte. + piv_cac: Employés du gouvernement + piv_cac_info: Utilisez votre carte PIV / CAC pour sécuriser votre compte. + sms: SMS + sms_info: Obtenez votre code de sécurité par SMS. + voice: Appel téléphonique + voice_info: Obtenez votre code de sécurité par appel téléphonique. (Seulement + les numéros de téléphone en Amerique du Nord) + login_options_title: Sélectionnez votre option de sécurité + login_options_link_text: Choisissez une autre option de sécurité + login_intro: Vous les avez configurés lorsque vous avez crée votre compte + choose_another_option: "‹ Choisissez une autre option" + personal_key_fallback: + question: Vous n'avez pas votre clé personnelle? + phone_fallback: + question: Vous n'avez pas accès à votre téléphone maintenant? + piv_cac_fallback: + question: Vous n'avez pas accès à votre carte PIV/CAC? + totp_fallback: + question: Vous n'avez pas votre application d'authentification? diff --git a/config/locales/user_mailer/en.yml b/config/locales/user_mailer/en.yml index 0ba57faca25..21e09a1bfdf 100644 --- a/config/locales/user_mailer/en.yml +++ b/config/locales/user_mailer/en.yml @@ -8,6 +8,11 @@ en: didn't request a password reset, you can ignore this message. link_text: Create your account subject: Your login.gov password reset request + account_reset_cancel: + intro: This email confirms you have cancelled the request to delete your login.gov + account. + subject: Delete your account + help: '' account_reset_request: help: '' intro: You’ve requested to delete your login.gov account.

Your diff --git a/config/locales/user_mailer/es.yml b/config/locales/user_mailer/es.yml index 0c4d9b723a7..a7349619979 100644 --- a/config/locales/user_mailer/es.yml +++ b/config/locales/user_mailer/es.yml @@ -5,6 +5,11 @@ es: intro: NOT TRANSLATED YET link_text: NOT TRANSLATED YET subject: NOT TRANSLATED YET + account_reset_cancel: + intro: Este correo electrónico confirma que ha cancelado la solicitud para eliminar + su cuenta de login.gov. + subject: Eliminar su cuenta + help: '' account_reset_request: help: '' intro: Has solicitado eliminar tu cuenta de login.gov.

Su la diff --git a/config/locales/user_mailer/fr.yml b/config/locales/user_mailer/fr.yml index 632f237141b..18556d9a1fd 100644 --- a/config/locales/user_mailer/fr.yml +++ b/config/locales/user_mailer/fr.yml @@ -5,6 +5,11 @@ fr: intro: NOT TRANSLATED YET link_text: NOT TRANSLATED YET subject: NOT TRANSLATED YET + account_reset_cancel: + intro: Cet e-mail confirme que vous avez annulé la demande de suppression de + votre compte login.gov. + subject: Supprimer votre compte + help: '' account_reset_request: help: '' intro: Vous avez demandé à supprimer votre compte login.gov.

Votre diff --git a/config/routes.rb b/config/routes.rb index 94d589e86c9..e48f79a500e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -26,6 +26,9 @@ match "/api/saml/auth#{suffix}" => 'saml_idp#auth', via: %i[get post] end + # Twilio Request URL for inbound SMS + post '/api/sms/receive' => 'sms#receive' + post '/api/service_provider' => 'service_provider#update' match '/api/voice/otp' => 'voice/otp#show', via: %i[get post], @@ -63,6 +66,9 @@ get '/account_reset/confirm_delete_account' => 'account_reset/confirm_delete_account#show' post '/api/account_reset/send_notifications' => 'account_reset/send_notifications#update' + get '/login/two_factor/options' => 'two_factor_authentication/options#index' + post '/login/two_factor/options' => 'two_factor_authentication/options#create' + get '/login/two_factor/authenticator' => 'two_factor_authentication/totp_verification#show' post '/login/two_factor/authenticator' => 'two_factor_authentication/totp_verification#create' get '/login/two_factor/personal_key' => 'two_factor_authentication/personal_key_verification#show' diff --git a/config/service_providers.yml b/config/service_providers.yml index d34bef38a94..815c235aea9 100644 --- a/config/service_providers.yml +++ b/config/service_providers.yml @@ -582,6 +582,7 @@ production: - 'https://portal.dot.gov/openid-connect/dot_login' - 'https://fmcsa.portal.dot.gov/openid-connect/dot_login' - 'https://portal.dot.gov/' + - 'https://score.fmcsa.dot.gov/' restrict_to_deploy_env: 'prod' # NGA GEOWorks Symphony @@ -717,3 +718,18 @@ production: redirect_uris: - 'gov.dhs.cbp.opa.mycbp://result' restrict_to_deploy_env: 'prod' + + # A DoD system that allows tsps to manage moves. + 'urn:gov:gsa:openidconnect.profiles:sp:sso:dod:tspmovemilprod': + agency_id: 8 + friendly_name: 'tsp.move.mil' + agency: 'DOD' + logo: 'move_mil.svg' + cert: 'move_mil_prod' + return_to_sp_url: 'https://tsp.move.mil' + redirect_uris: + - 'https://tsp.move.mil' + - 'https://tsp.move.mil/auth/login-gov/callback' + attribute_bundle: + - email + restrict_to_deploy_env: 'prod' diff --git a/db/migrate/20180709141748_drop_encrypted_password_column_from_user.rb b/db/migrate/20180709141748_drop_encrypted_password_column_from_user.rb new file mode 100644 index 00000000000..d6fce556d29 --- /dev/null +++ b/db/migrate/20180709141748_drop_encrypted_password_column_from_user.rb @@ -0,0 +1,15 @@ +class DropEncryptedPasswordColumnFromUser < ActiveRecord::Migration[5.1] + def up + safety_assured do + remove_column :users, :encrypted_password + remove_column :users, :password_salt + remove_column :users, :password_cost + end + end + + def down + add_column :users, :encrypted_password, :string, limit: 255, default: '' + add_column :users, :password_salt, :string + add_column :users, :password_cost, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index b5d46bfb506..8ab5f52df9a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20180620233914) do +ActiveRecord::Schema.define(version: 20180709141748) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -171,7 +171,6 @@ end create_table "users", force: :cascade do |t| - t.string "encrypted_password", limit: 255, default: "" t.string "reset_password_token", limit: 255 t.datetime "reset_password_sent_at" t.datetime "remember_created_at" @@ -201,11 +200,9 @@ t.datetime "idv_attempted_at" t.integer "idv_attempts", default: 0 t.string "recovery_code" - t.string "password_salt" t.string "encryption_key" t.string "unique_session_id" t.string "recovery_salt" - t.string "password_cost" t.string "recovery_cost" t.string "email_fingerprint", default: "", null: false t.text "encrypted_email", default: "", null: false diff --git a/lib/proofer_mocks/address_mock.rb b/lib/proofer_mocks/address_mock.rb index 2a4983c84c8..530ac83d129 100644 --- a/lib/proofer_mocks/address_mock.rb +++ b/lib/proofer_mocks/address_mock.rb @@ -1,5 +1,5 @@ class AddressMock < Proofer::Base - attributes :phone + required_attributes :phone stage :address diff --git a/lib/proofer_mocks/resolution_mock.rb b/lib/proofer_mocks/resolution_mock.rb index 5e256e149b1..4fc373e39f0 100644 --- a/lib/proofer_mocks/resolution_mock.rb +++ b/lib/proofer_mocks/resolution_mock.rb @@ -1,5 +1,5 @@ class ResolutionMock < Proofer::Base - attributes :first_name, :ssn, :zipcode + required_attributes :first_name, :ssn, :zipcode stage :resolution diff --git a/lib/proofer_mocks/state_id_mock.rb b/lib/proofer_mocks/state_id_mock.rb index 0ea04dc31a5..764de54ab75 100644 --- a/lib/proofer_mocks/state_id_mock.rb +++ b/lib/proofer_mocks/state_id_mock.rb @@ -7,7 +7,7 @@ class StateIdMock < Proofer::Base drivers_license drivers_permit state_id_card ].freeze - attributes :state_id_number, :state_id_type, :state_id_jurisdiction + required_attributes :state_id_number, :state_id_type, :state_id_jurisdiction stage :state_id diff --git a/lib/tasks/rotate.rake b/lib/tasks/rotate.rake index 6a599ef7523..ac6d9657b8c 100644 --- a/lib/tasks/rotate.rake +++ b/lib/tasks/rotate.rake @@ -11,9 +11,13 @@ namespace :rotate do User.find_in_batches.with_index do |users, _batch| User.transaction do users.each do |user| - rotator = KeyRotator::AttributeEncryption.new(user) - rotator.rotate - progress&.increment + begin + rotator = KeyRotator::AttributeEncryption.new(user) + rotator.rotate + progress&.increment + rescue StandardError => err # Don't use user.email in output... + Kernel.puts "Error with user id:#{user.id} #{err.message} #{err.backtrace}" + end end end end diff --git a/lib/user_flow_exporter.rb b/lib/user_flow_exporter.rb index 0bc8af45368..15f049c56f0 100644 --- a/lib/user_flow_exporter.rb +++ b/lib/user_flow_exporter.rb @@ -89,4 +89,4 @@ def self.massage_assets(dir) end end end -end \ No newline at end of file +end diff --git a/spec/controllers/account_reset/cancel_controller_spec.rb b/spec/controllers/account_reset/cancel_controller_spec.rb index 521243c1c37..2cc508be842 100644 --- a/spec/controllers/account_reset/cancel_controller_spec.rb +++ b/spec/controllers/account_reset/cancel_controller_spec.rb @@ -1,31 +1,66 @@ require 'rails_helper' describe AccountReset::CancelController do + let(:user) { create(:user, :signed_up, phone: '+1 (703) 555-0000') } + before do + TwilioService::Utils.telephony_service = FakeSms + end + describe '#cancel' do it 'logs a good token to the analytics' do - user = create(:user) AccountResetService.new(user).create_request stub_analytics expect(@analytics).to receive(:track_event). - with(Analytics::ACCOUNT_RESET, {event: :cancel, token_valid: true}) + with(Analytics::ACCOUNT_RESET, + event: :cancel, token_valid: true, user_id: user.uuid) - post :cancel, params: {token:AccountResetRequest.all[0].request_token} + post :cancel, params: { token: AccountResetRequest.all[0].request_token } end it 'logs a bad token to the analytics' do - stub_analytics expect(@analytics).to receive(:track_event). - with(Analytics::ACCOUNT_RESET, {event: :cancel, token_valid: false}) + with(Analytics::ACCOUNT_RESET, event: :cancel, token_valid: false) - post :cancel, params: {token:'FOO'} + post :cancel, params: { token: 'FOO' } end it 'redirects to the root' do - post :cancel expect(response).to redirect_to root_url end + + it 'sends an SMS if there is a phone' do + AccountResetService.new(user).create_request + allow(SmsAccountResetCancellationNotifierJob).to receive(:perform_now) + + post :cancel, params: { token: AccountResetRequest.all[0].request_token } + + expect(SmsAccountResetCancellationNotifierJob).to have_received(:perform_now).with( + phone: user.phone + ) + end + + it 'does not send an SMS if there is no phone' do + AccountResetService.new(user).create_request + allow(SmsAccountResetCancellationNotifierJob).to receive(:perform_now) + user.phone = nil + user.save! + + post :cancel, params: { token: AccountResetRequest.all[0].request_token } + + expect(SmsAccountResetCancellationNotifierJob).to_not have_received(:perform_now) + end + + it 'sends an email' do + AccountResetService.new(user).create_request + + @mailer = instance_double(ActionMailer::MessageDelivery, deliver_later: true) + allow(UserMailer).to receive(:account_reset_cancel).with(user.email). + and_return(@mailer) + + post :cancel, params: { token: AccountResetRequest.all[0].request_token } + end end end diff --git a/spec/controllers/account_reset/delete_account_controller_spec.rb b/spec/controllers/account_reset/delete_account_controller_spec.rb index e045fcccc91..c9c5030b982 100644 --- a/spec/controllers/account_reset/delete_account_controller_spec.rb +++ b/spec/controllers/account_reset/delete_account_controller_spec.rb @@ -10,7 +10,7 @@ session[:granted_token] = AccountResetRequest.all[0].granted_token stub_analytics expect(@analytics).to receive(:track_event). - with(Analytics::ACCOUNT_RESET, {event: :delete, token_valid: true}) + with(Analytics::ACCOUNT_RESET, event: :delete, token_valid: true, user_id: user.uuid) delete :delete end @@ -18,9 +18,9 @@ it 'logs a bad token to the analytics' do stub_analytics expect(@analytics).to receive(:track_event). - with(Analytics::ACCOUNT_RESET, {event: :delete, token_valid: false}) + with(Analytics::ACCOUNT_RESET, event: :delete, token_valid: false) - delete :delete, params: {token:'FOO'} + delete :delete, params: { token: 'FOO' } end it 'redirects to root if there is no token' do @@ -36,13 +36,13 @@ AccountResetService.new(user).create_request AccountResetService.new(user).grant_request - get :show, params: {token: AccountResetRequest.all[0].granted_token} + get :show, params: { token: AccountResetRequest.all[0].granted_token } expect(response).to redirect_to(account_reset_delete_account_url) end it 'redirects to root if the token is bad' do - get :show, params: {token: 'FOO'} + get :show, params: { token: 'FOO' } expect(response).to redirect_to(root_url) end diff --git a/spec/controllers/account_reset/report_fraud_controller_spec.rb b/spec/controllers/account_reset/report_fraud_controller_spec.rb index 8268c91bb3f..383378f1fae 100644 --- a/spec/controllers/account_reset/report_fraud_controller_spec.rb +++ b/spec/controllers/account_reset/report_fraud_controller_spec.rb @@ -8,22 +8,20 @@ stub_analytics expect(@analytics).to receive(:track_event). - with(Analytics::ACCOUNT_RESET, {event: :fraud, token_valid: true}) + with(Analytics::ACCOUNT_RESET, event: :fraud, token_valid: true) - post :update, params: {token:AccountResetRequest.all[0].request_token} + post :update, params: { token: AccountResetRequest.all[0].request_token } end it 'logs a bad token to the analytics' do - stub_analytics expect(@analytics).to receive(:track_event). - with(Analytics::ACCOUNT_RESET, {event: :fraud, token_valid: false}) + with(Analytics::ACCOUNT_RESET, event: :fraud, token_valid: false) - post :update, params: {token:'FOO'} + post :update, params: { token: 'FOO' } end it 'redirects to the root' do - post :update expect(response).to redirect_to root_url end diff --git a/spec/controllers/account_reset/request_controller_spec.rb b/spec/controllers/account_reset/request_controller_spec.rb index d002d1675ff..9fb3678f3a7 100644 --- a/spec/controllers/account_reset/request_controller_spec.rb +++ b/spec/controllers/account_reset/request_controller_spec.rb @@ -27,12 +27,12 @@ describe '#create' do it 'logs the request in the analytics' do - TwilioService.telephony_service = FakeSms + TwilioService::Utils.telephony_service = FakeSms sign_in_before_2fa stub_analytics expect(@analytics).to receive(:track_event). - with(Analytics::ACCOUNT_RESET, {event: :request}) + with(Analytics::ACCOUNT_RESET, event: :request) post :create end diff --git a/spec/controllers/idv/confirmations_controller_spec.rb b/spec/controllers/idv/confirmations_controller_spec.rb index dd77041d4f0..adb891e083c 100644 --- a/spec/controllers/idv/confirmations_controller_spec.rb +++ b/spec/controllers/idv/confirmations_controller_spec.rb @@ -1,5 +1,4 @@ require 'rails_helper' -require 'proofer/vendor/mock' describe Idv::ConfirmationsController do include SamlAuthHelper diff --git a/spec/controllers/idv/review_controller_spec.rb b/spec/controllers/idv/review_controller_spec.rb index 09393049665..f0a6f8c9e0b 100644 --- a/spec/controllers/idv/review_controller_spec.rb +++ b/spec/controllers/idv/review_controller_spec.rb @@ -1,7 +1,5 @@ require 'rails_helper' -require 'proofer/vendor/mock' - describe Idv::ReviewController do let(:user) do create( diff --git a/spec/controllers/idv/usps_controller_spec.rb b/spec/controllers/idv/usps_controller_spec.rb index e703016161d..0214d4be532 100644 --- a/spec/controllers/idv/usps_controller_spec.rb +++ b/spec/controllers/idv/usps_controller_spec.rb @@ -1,7 +1,5 @@ require 'rails_helper' -require 'proofer/vendor/mock' - describe Idv::UspsController do let(:user) { create(:user) } diff --git a/spec/controllers/two_factor_authentication/options_controller_spec.rb b/spec/controllers/two_factor_authentication/options_controller_spec.rb new file mode 100644 index 00000000000..a2ad7631828 --- /dev/null +++ b/spec/controllers/two_factor_authentication/options_controller_spec.rb @@ -0,0 +1,77 @@ +require 'rails_helper' + +describe TwoFactorAuthentication::OptionsController do + describe '#index' do + it 'renders the page' do + sign_in_before_2fa + + get :index + + expect(response).to render_template(:index) + end + + it 'logs an analytics event' do + sign_in_before_2fa + stub_analytics + + expect(@analytics).to receive(:track_event). + with(Analytics::MULTI_FACTOR_AUTH_OPTION_LIST_VISIT) + + get :index + end + end + + describe '#create' do + before { sign_in_before_2fa } + + it 'redirects to login_two_factor_url if user selects sms' do + post :create, params: { two_factor_options_form: { selection: 'sms' } } + + expect(response).to redirect_to otp_send_url( \ + otp_delivery_selection_form: { otp_delivery_preference: 'sms' } + ) + end + + it 'redirects to login_two_factor_url if user selects voice' do + post :create, params: { two_factor_options_form: { selection: 'voice' } } + + expect(response).to redirect_to otp_send_url( \ + otp_delivery_selection_form: { otp_delivery_preference: 'voice' } + ) + end + + it 'redirects to login_two_factor_piv_cac_url if user selects piv_cac' do + post :create, params: { two_factor_options_form: { selection: 'piv_cac' } } + + expect(response).to redirect_to login_two_factor_piv_cac_url + end + + it 'redirects to login_two_factor_authenticator_url if user selects auth_app' do + post :create, params: { two_factor_options_form: { selection: 'auth_app' } } + + expect(response).to redirect_to login_two_factor_authenticator_url + end + + it 'rerenders the page with errors on failure' do + post :create, params: { two_factor_options_form: { selection: 'foo' } } + + expect(response).to render_template(:index) + end + + it 'tracks analytics event' do + stub_sign_in_before_2fa + stub_analytics + + result = { + selection: 'sms', + success: true, + errors: {}, + } + + expect(@analytics).to receive(:track_event). + with(Analytics::MULTI_FACTOR_AUTH_OPTION_LIST, result) + + post :create, params: { two_factor_options_form: { selection: 'sms' } } + end + end +end diff --git a/spec/controllers/users/two_factor_authentication_controller_spec.rb b/spec/controllers/users/two_factor_authentication_controller_spec.rb index c87b128c9da..85ef7036ebc 100644 --- a/spec/controllers/users/two_factor_authentication_controller_spec.rb +++ b/spec/controllers/users/two_factor_authentication_controller_spec.rb @@ -332,11 +332,18 @@ def index } twilio_error = "[HTTP 400] : error message\n\n" + twilio_error_hash = { + error: twilio_error, + code: '', + context: 'confirmation', + country: 'US', + } + expect(@analytics).to receive(:track_event). with(Analytics::OTP_DELIVERY_SELECTION, analytics_hash) expect(@analytics).to receive(:track_event). - with(Analytics::TWILIO_PHONE_VALIDATION_FAILED, error: twilio_error, code: '') + with(Analytics::TWILIO_PHONE_VALIDATION_FAILED, twilio_error_hash) get :send_code, params: { otp_delivery_selection_form: { otp_delivery_preference: 'sms' } } end @@ -345,7 +352,11 @@ def index stub_analytics code = 60_033 error_message = 'error' - verify_error = PhoneVerification::VerifyError.new(code: code, message: error_message) + response = '{"error_code":"60004"}' + status = 400 + verify_error = PhoneVerification::VerifyError.new( + code: code, message: error_message, status: status, response: response + ) allow(SmsOtpSenderJob).to receive(:perform_now).and_raise(verify_error) analytics_hash = { @@ -357,12 +368,20 @@ def index country_code: '1', area_code: '202', } + twilio_error_hash = { + error: error_message, + code: code, + context: 'confirmation', + country: 'US', + status: status, + response: response, + } expect(@analytics).to receive(:track_event). with(Analytics::OTP_DELIVERY_SELECTION, analytics_hash) expect(@analytics).to receive(:track_event). - with(Analytics::TWILIO_PHONE_VALIDATION_FAILED, error: error_message, code: code) + with(Analytics::TWILIO_PHONE_VALIDATION_FAILED, twilio_error_hash) get :send_code, params: { otp_delivery_selection_form: { otp_delivery_preference: 'sms' } } end diff --git a/spec/controllers/usps_upload_controller_spec.rb b/spec/controllers/usps_upload_controller_spec.rb index 5a9d692af91..1fd295957e6 100644 --- a/spec/controllers/usps_upload_controller_spec.rb +++ b/spec/controllers/usps_upload_controller_spec.rb @@ -29,6 +29,8 @@ context 'on a federal workday' do it 'runs the uploader' do + expect(controller).to receive(:today).and_return(Date.new(2018, 7, 3)) + usps_uploader = instance_double(UspsUploader) expect(usps_uploader).to receive(:run) expect(UspsUploader).to receive(:new).and_return(usps_uploader) diff --git a/spec/features/flows/sp_authentication_flows_spec.rb b/spec/features/flows/sp_authentication_flows_spec.rb index 406395fcdae..2d5f9f66598 100644 --- a/spec/features/flows/sp_authentication_flows_spec.rb +++ b/spec/features/flows/sp_authentication_flows_spec.rb @@ -183,10 +183,6 @@ end end end - - # context 'when choosing to sign in' do - # TODO: duplicate scenarios from Create Account here - # end end context 'when LOA1' do diff --git a/spec/features/openid_connect/openid_connect_spec.rb b/spec/features/openid_connect/openid_connect_spec.rb index 498f6e57f2d..eeea3adcfa3 100644 --- a/spec/features/openid_connect/openid_connect_spec.rb +++ b/spec/features/openid_connect/openid_connect_spec.rb @@ -5,7 +5,9 @@ class MockSession; end shared_examples 'OpenID Connect' do |cloudhsm_enabled| include IdvHelper - before { enable_cloudhsm(cloudhsm_enabled) } + before do + enable_cloudhsm(cloudhsm_enabled) + end after(:all) do SamlIdp.configure { |config| SamlIdpEncryptionConfigurator.configure(config, false) } end diff --git a/spec/features/sign_in/two_factor_options_spec.rb b/spec/features/sign_in/two_factor_options_spec.rb new file mode 100644 index 00000000000..65e95df3ed1 --- /dev/null +++ b/spec/features/sign_in/two_factor_options_spec.rb @@ -0,0 +1,183 @@ +require 'rails_helper' + +describe '2FA options when signing in' do + context 'when the user only has SMS configured' do + it 'only displays SMS, Voice and Personal key' do + user = create(:user, :signed_up, otp_delivery_preference: 'sms') + sign_in_user(user) + + click_link t('two_factor_authentication.login_options_link_text') + + expect(page). + to have_content t('two_factor_authentication.login_options.sms') + expect(page). + to have_content t('two_factor_authentication.login_options.voice') + expect(page). + to have_content t('two_factor_authentication.login_options.personal_key') + expect(page). + to_not have_content t('two_factor_authentication.login_options.piv_cac') + expect(page). + to_not have_content t('two_factor_authentication.login_options.auth_app') + end + end + + context 'when the user only has Voice configured' do + it 'only displays SMS, Voice and Personal key' do + user = create(:user, :signed_up, otp_delivery_preference: 'voice') + sign_in_user(user) + + click_link t('two_factor_authentication.login_options_link_text') + + expect(page). + to have_content t('two_factor_authentication.login_options.sms') + expect(page). + to have_content t('two_factor_authentication.login_options.voice') + expect(page). + to have_content t('two_factor_authentication.login_options.personal_key') + expect(page). + to_not have_content t('two_factor_authentication.login_options.piv_cac') + expect(page). + to_not have_content t('two_factor_authentication.login_options.auth_app') + end + end + + context 'when the user only has SMS configured with a number that we cannot call' do + it 'only displays SMS and Personal key' do + user = create(:user, :signed_up, otp_delivery_preference: 'sms', phone: '+12423270143') + sign_in_user(user) + + click_link t('two_factor_authentication.login_options_link_text') + + expect(page). + to have_content t('two_factor_authentication.login_options.sms') + expect(page). + to_not have_content t('two_factor_authentication.login_options.voice') + expect(page). + to have_content t('two_factor_authentication.login_options.personal_key') + expect(page). + to_not have_content t('two_factor_authentication.login_options.piv_cac') + expect(page). + to_not have_content t('two_factor_authentication.login_options.auth_app') + end + end + + context 'when the user only has TOTP configured' do + it 'only displays TOTP and Personal key' do + user = create(:user, :with_authentication_app) + sign_in_user(user) + + click_link t('two_factor_authentication.login_options_link_text') + + expect(page). + to_not have_content t('two_factor_authentication.login_options.sms') + expect(page). + to_not have_content t('two_factor_authentication.login_options.voice') + expect(page). + to have_content t('two_factor_authentication.login_options.personal_key') + expect(page). + to have_content t('two_factor_authentication.login_options.auth_app') + expect(page). + to_not have_content t('two_factor_authentication.login_options.piv_cac') + end + end + + context 'when the user only has PIV/CAC configured' do + it 'only displays PIV/CAC and Personal key' do + user = create(:user, :with_piv_or_cac, :with_personal_key) + sign_in_user(user) + + click_link t('two_factor_authentication.login_options_link_text') + + expect(page). + to_not have_content t('two_factor_authentication.login_options.sms') + expect(page). + to_not have_content t('two_factor_authentication.login_options.voice') + expect(page). + to have_content t('two_factor_authentication.login_options.personal_key') + expect(page). + to_not have_content t('two_factor_authentication.login_options.auth_app') + expect(page). + to have_content t('two_factor_authentication.login_options.piv_cac') + end + end + + context 'when the user only has SMS and TOTP configured' do + it 'only displays SMS, Voice, TOTP and Personal key' do + user = create(:user, :signed_up, :with_authentication_app) + sign_in_user(user) + + click_link t('two_factor_authentication.login_options_link_text') + + expect(page). + to have_content t('two_factor_authentication.login_options.sms') + expect(page). + to have_content t('two_factor_authentication.login_options.voice') + expect(page). + to have_content t('two_factor_authentication.login_options.personal_key') + expect(page). + to have_content t('two_factor_authentication.login_options.auth_app') + expect(page). + to_not have_content t('two_factor_authentication.login_options.piv_cac') + end + end + + context 'when the user only has SMS and PIV/CAC configured' do + it 'only displays SMS, Voice, PIV/CAC and Personal key' do + user = create(:user, :signed_up, :with_piv_or_cac) + sign_in_user(user) + + click_link t('two_factor_authentication.login_options_link_text') + + expect(page). + to have_content t('two_factor_authentication.login_options.sms') + expect(page). + to have_content t('two_factor_authentication.login_options.voice') + expect(page). + to have_content t('two_factor_authentication.login_options.personal_key') + expect(page). + to_not have_content t('two_factor_authentication.login_options.auth_app') + expect(page). + to have_content t('two_factor_authentication.login_options.piv_cac') + end + end + + context 'when the user only has TOTP and PIV/CAC configured' do + it 'only displays PIV/CAC, TOTP, and Personal key' do + user = create(:user, :with_authentication_app, :with_piv_or_cac) + sign_in_user(user) + + click_link t('two_factor_authentication.login_options_link_text') + + expect(page). + to_not have_content t('two_factor_authentication.login_options.sms') + expect(page). + to_not have_content t('two_factor_authentication.login_options.voice') + expect(page). + to have_content t('two_factor_authentication.login_options.personal_key') + expect(page). + to have_content t('two_factor_authentication.login_options.auth_app') + expect(page). + to have_content t('two_factor_authentication.login_options.piv_cac') + end + end + + context 'when the user has SMS, TOTP and PIV/CAC configured' do + it 'only displays SMS, Voice, PIV/CAC, TOTP, and Personal key' do + user = create(:user, :signed_up, :with_authentication_app, :with_piv_or_cac) + sign_in_user(user) + + click_link t('two_factor_authentication.login_options_link_text') + + expect(page). + to have_content t('two_factor_authentication.login_options.sms') + expect(page). + to have_content t('two_factor_authentication.login_options.voice') + expect(page). + to have_content t('two_factor_authentication.login_options.personal_key') + expect(page). + to have_content t('two_factor_authentication.login_options.auth_app') + expect(page). + to have_content t('two_factor_authentication.login_options.piv_cac') + end + end +end diff --git a/spec/features/two_factor_authentication/change_factor_spec.rb b/spec/features/two_factor_authentication/change_factor_spec.rb index c4ca885c936..fd6c34d8114 100644 --- a/spec/features/two_factor_authentication/change_factor_spec.rb +++ b/spec/features/two_factor_authentication/change_factor_spec.rb @@ -109,7 +109,7 @@ Timecop.travel(Figaro.env.reauthn_window.to_i + 1) do visit manage_phone_path complete_2fa_confirmation_without_entering_otp - click_link t('links.two_factor_authentication.resend_code.sms') + click_link t('links.two_factor_authentication.get_another_code') expect(SmsOtpSenderJob).to have_received(:perform_later). with( @@ -175,6 +175,25 @@ end end + context 'with SMS and number that Verify does not think is valid' do + it 'rescues the VerifyError' do + allow(SmsOtpSenderJob).to receive(:perform_later) + PhoneVerification.adapter = FakeAdapter + allow(FakeAdapter).to receive(:post).and_return(FakeAdapter::ErrorResponse.new) + + user = create(:user, :signed_up, phone: '+17035551212') + visit new_user_session_path + sign_in_live_with_2fa(user) + visit manage_phone_path + select 'Morocco', from: 'user_phone_form_international_code' + fill_in 'user_phone_form_phone', with: '+212 661-289325' + click_button t('forms.buttons.submit.confirm_change') + + expect(current_path).to eq manage_phone_path + expect(page).to have_content t('errors.messages.invalid_phone_number') + end + end + def complete_2fa_confirmation complete_2fa_confirmation_without_entering_otp click_submit_default diff --git a/spec/features/two_factor_authentication/sign_in_spec.rb b/spec/features/two_factor_authentication/sign_in_spec.rb index 61cf0da6b8a..c48e27b06d6 100644 --- a/spec/features/two_factor_authentication/sign_in_spec.rb +++ b/spec/features/two_factor_authentication/sign_in_spec.rb @@ -69,7 +69,7 @@ location: 'Bahamas' ) - click_on t('devise.two_factor_authentication.two_factor_choice_cancel') + click_on t('two_factor_authentication.choose_another_option') expect(current_path).to eq two_factor_options_path end @@ -203,7 +203,7 @@ def submit_prefilled_otp_code sign_in_before_2fa(user) old_code = find('input[@name="code"]').value - click_link t('links.two_factor_authentication.resend_code.sms') + click_link t('links.two_factor_authentication.get_another_code') new_code = find('input[@name="code"]').value @@ -231,7 +231,7 @@ def submit_prefilled_otp_code allow(VoiceOtpSenderJob).to receive(:perform_later) - click_on t('links.two_factor_authentication.voice') + choose_another_security_option('voice') expect(VoiceOtpSenderJob).to have_received(:perform_later) end @@ -285,7 +285,7 @@ def submit_prefilled_otp_code sign_in_before_2fa(user) Figaro.env.otp_delivery_blocklist_maxretry.to_i.times do - click_link t('links.two_factor_authentication.resend_code.sms') + click_link t('links.two_factor_authentication.get_another_code') end expect(page).to have_content t('titles.account_locked') @@ -318,7 +318,7 @@ def submit_prefilled_otp_code sign_in_before_2fa(user) Figaro.env.otp_delivery_blocklist_maxretry.to_i.times do - click_link t('links.two_factor_authentication.resend_code.sms') + click_link t('links.two_factor_authentication.get_another_code') end expect(page).to have_content t('titles.account_locked') @@ -340,7 +340,7 @@ def submit_prefilled_otp_code sign_in_before_2fa(user) (max_attempts - 1).times do - click_link t('links.two_factor_authentication.resend_code.sms') + click_link t('links.two_factor_authentication.get_another_code') end click_submit_default @@ -357,7 +357,7 @@ def submit_prefilled_otp_code expect(rate_limited_phone.reload.otp_send_count).to eq 1 - click_link t('links.two_factor_authentication.resend_code.sms') + click_link t('links.two_factor_authentication.get_another_code') expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'sms') @@ -375,14 +375,14 @@ def submit_prefilled_otp_code max_attempts = Figaro.env.otp_delivery_blocklist_maxretry.to_i sign_in_before_2fa(first_user) - click_link t('links.two_factor_authentication.resend_code.sms') + click_link t('links.two_factor_authentication.get_another_code') expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'sms') visit destroy_user_session_url sign_in_before_2fa(second_user) - click_link t('links.two_factor_authentication.resend_code.sms') + click_link t('links.two_factor_authentication.get_another_code') phone_fingerprint = Pii::Fingerprinter.fingerprint(first_user.phone) rate_limited_phone = OtpRequestsTracker.find_by(phone_fingerprint: phone_fingerprint) @@ -415,7 +415,7 @@ def submit_prefilled_otp_code submit_2fa_setup_form_with_valid_phone max_attempts.times do - click_link t('links.two_factor_authentication.resend_code.sms') + click_link t('links.two_factor_authentication.get_another_code') end expect(page).to have_content t('titles.account_locked') @@ -479,15 +479,13 @@ def submit_prefilled_otp_code expect(current_path).to eq login_two_factor_piv_cac_path - expect(page).not_to have_link(t('links.two_factor_authentication.app')) - - click_link t('devise.two_factor_authentication.totp_fallback.sms_link_text') + choose_another_security_option('sms') expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'sms') visit login_two_factor_piv_cac_path - click_link t('devise.two_factor_authentication.totp_fallback.voice_link_text') + choose_another_security_option('voice') expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'voice') end @@ -498,7 +496,7 @@ def submit_prefilled_otp_code expect(current_path).to eq login_two_factor_piv_cac_path - click_link t('links.two_factor_authentication.app') + choose_another_security_option('auth_app') expect(current_path).to eq login_two_factor_authenticator_path end @@ -562,6 +560,45 @@ def submit_prefilled_otp_code expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'sms') end end + + context 'with SMS and international number that Verify does not think is valid' do + it 'rescues the VerifyError' do + allow(SmsOtpSenderJob).to receive(:perform_later) do |*args| + SmsOtpSenderJob.perform_now(*args) + end + PhoneVerification.adapter = FakeAdapter + allow(FakeAdapter).to receive(:post).and_return(FakeAdapter::ErrorResponse.new) + + user = create(:user, :signed_up, phone: '+212 661-289324') + sign_in_user(user) + + expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'sms') + expect(page). + to have_content t('errors.messages.phone_unsupported') + end + end + + context 'user with Voice preference sends SMS, causing a Twilio error' do + it 'does not change their OTP delivery preference' do + allow(Figaro.env).to receive(:programmable_sms_countries).and_return('CA') + allow(VoiceOtpSenderJob).to receive(:perform_later) + allow(SmsOtpSenderJob).to receive(:perform_later) do |*args| + SmsOtpSenderJob.perform_now(*args) + end + PhoneVerification.adapter = FakeAdapter + allow(FakeAdapter).to receive(:post).and_return(FakeAdapter::ErrorResponse.new) + + user = create(:user, :signed_up, phone: '+17035551212', otp_delivery_preference: 'voice') + sign_in_user(user) + + expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'voice') + + choose_another_security_option('sms') + + expect(page).to have_content t('errors.messages.invalid_phone_number') + expect(user.reload.otp_delivery_preference).to eq 'voice' + end + end end describe 'when the user is not piv/cac enabled' do @@ -578,13 +615,13 @@ def submit_prefilled_otp_code user = create(:user, :with_authentication_app, :with_phone) sign_in_before_2fa(user) - click_link t('devise.two_factor_authentication.totp_fallback.sms_link_text') + choose_another_security_option('sms') expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'sms') visit login_two_factor_authenticator_path - click_link t('devise.two_factor_authentication.totp_fallback.voice_link_text') + choose_another_security_option('voice') expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'voice') end @@ -621,7 +658,6 @@ def submit_prefilled_otp_code end end - # TODO: readd profile redirect, modal tests describe 'signing in when user does not already have personal key' do # For example, when migrating users from another DB it 'displays personal key and redirects to profile' do diff --git a/spec/features/two_factor_authentication/sign_in_via_personal_key_spec.rb b/spec/features/two_factor_authentication/sign_in_via_personal_key_spec.rb index b125e56343c..9d0d05793a0 100644 --- a/spec/features/two_factor_authentication/sign_in_via_personal_key_spec.rb +++ b/spec/features/two_factor_authentication/sign_in_via_personal_key_spec.rb @@ -7,7 +7,7 @@ personal_key = PersonalKeyGenerator.new(user).create - click_link t('devise.two_factor_authentication.personal_key_fallback.link') + choose_another_security_option('personal_key') enter_personal_key(personal_key: personal_key) @@ -26,7 +26,7 @@ personal_key = PersonalKeyGenerator.new(user).create wrong_personal_key = personal_key.split('-').reverse.join - click_link t('devise.two_factor_authentication.personal_key_fallback.link') + choose_another_security_option('personal_key') enter_personal_key(personal_key: wrong_personal_key) click_submit_default diff --git a/spec/features/users/password_recovery_via_recovery_code_spec.rb b/spec/features/users/password_recovery_via_recovery_code_spec.rb index 8e52bae7b68..410e5e4c954 100644 --- a/spec/features/users/password_recovery_via_recovery_code_spec.rb +++ b/spec/features/users/password_recovery_via_recovery_code_spec.rb @@ -58,7 +58,7 @@ trigger_reset_password_and_click_email_link(user.email) reset_password_and_sign_back_in(user, new_password) - click_link t('devise.two_factor_authentication.personal_key_fallback.link') + choose_another_security_option('personal_key') enter_personal_key(personal_key: personal_key) click_submit_default diff --git a/spec/features/users/sign_in_spec.rb b/spec/features/users/sign_in_spec.rb index f954c9ccfae..6826ff45371 100644 --- a/spec/features/users/sign_in_spec.rb +++ b/spec/features/users/sign_in_spec.rb @@ -412,7 +412,7 @@ user = create(:user, :signed_up) old_personal_key = PersonalKeyGenerator.new(user).create signin(user.email, user.password) - click_link t('devise.two_factor_authentication.personal_key_fallback.link') + choose_another_security_option('personal_key') enter_personal_key(personal_key: old_personal_key) click_submit_default visit account_path diff --git a/spec/features/visitors/phone_confirmation_spec.rb b/spec/features/visitors/phone_confirmation_spec.rb index a4b6fea9395..a717ea92c0c 100644 --- a/spec/features/visitors/phone_confirmation_spec.rb +++ b/spec/features/visitors/phone_confirmation_spec.rb @@ -30,7 +30,7 @@ end it 'allows user to resend confirmation code' do - click_link t('links.two_factor_authentication.resend_code.sms') + click_link t('links.two_factor_authentication.get_another_code') expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'sms') end @@ -48,11 +48,9 @@ end it 'informs the user that the OTP code is sent to the phone' do - expect(page).to have_content( - t('instructions.mfa.sms.confirm_code_html', - number: '+1 (703) 555-5555', - resend_code_link: t('links.two_factor_authentication.resend_code.sms')) - ) + expect(page).to have_content(t('instructions.mfa.sms.number_message', + number: '+1 703-555-5555', + expiration: Figaro.env.otp_valid_for)) end end @@ -67,11 +65,9 @@ it 'pretends the phone is valid and prompts to confirm the number' do expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'sms') - expect(page).to have_content( - t('instructions.mfa.sms.confirm_code_html', - number: @existing_user.phone, - resend_code_link: t('links.two_factor_authentication.resend_code.sms')) - ) + expect(page).to have_content(t('instructions.mfa.sms.number_message', + number: '+1 202-555-1212', + expiration: Figaro.env.otp_valid_for)) end it 'does not confirm the new number with an invalid code' do diff --git a/spec/forms/otp_delivery_selection_form_spec.rb b/spec/forms/otp_delivery_selection_form_spec.rb index d281a5dcda7..d1a50d84ae0 100644 --- a/spec/forms/otp_delivery_selection_form_spec.rb +++ b/spec/forms/otp_delivery_selection_form_spec.rb @@ -66,56 +66,38 @@ end end - context 'with authentication context' do - context 'when otp_delivery_preference is the same as the user otp_delivery_preference' do - it 'does not update the user' do - user = build_stubbed(:user, otp_delivery_preference: 'sms') - form = OtpDeliverySelectionForm.new(user, phone_to_deliver_to, 'authentication') - - expect(UpdateUser).to_not receive(:new) - - form.submit(otp_delivery_preference: 'sms') - end - end - - context 'when otp_delivery_preference is different from the user otp_delivery_preference' do - it 'updates the user' do - user = build_stubbed(:user, otp_delivery_preference: 'voice') - form = OtpDeliverySelectionForm.new(user, phone_to_deliver_to, 'authentication') - attributes = { otp_delivery_preference: 'sms' } + context 'with voice preference and unsupported phone' do + it 'changes the otp_delivery_preference to sms' do + user = build_stubbed(:user, otp_delivery_preference: 'voice') + form = OtpDeliverySelectionForm.new( + user, + '+12423270143', + 'authentication' + ) + attributes = { otp_delivery_preference: 'sms' } - updated_user = instance_double(UpdateUser) - allow(UpdateUser).to receive(:new). - with(user: user, attributes: attributes).and_return(updated_user) + updated_user = instance_double(UpdateUser) + allow(UpdateUser).to receive(:new). + with(user: user, attributes: attributes).and_return(updated_user) - expect(updated_user).to receive(:call) + expect(updated_user).to receive(:call) - form.submit(otp_delivery_preference: 'sms') - end + form.submit(otp_delivery_preference: 'voice') end end - context 'with idv context' do - context 'when otp_delivery_preference is the same as the user otp_delivery_preference' do - it 'does not update the user' do - user = build_stubbed(:user, otp_delivery_preference: 'sms') - form = OtpDeliverySelectionForm.new(user, phone_to_deliver_to, 'idv') - - expect(UpdateUser).to_not receive(:new) - - form.submit(otp_delivery_preference: 'sms') - end - end - - context 'when otp_delivery_preference is different from the user otp_delivery_preference' do - it 'does not update the user' do - user = build_stubbed(:user, otp_delivery_preference: 'voice') - form = OtpDeliverySelectionForm.new(user, phone_to_deliver_to, 'idv') + context 'with voice preference and supported phone' do + it 'does not change the otp_delivery_preference to sms' do + user = build_stubbed(:user, otp_delivery_preference: 'voice') + form = OtpDeliverySelectionForm.new( + user, + '+17035551212', + 'authentication' + ) - expect(UpdateUser).to_not receive(:new) + expect(UpdateUser).to_not receive(:new) - form.submit(otp_delivery_preference: 'sms') - end + form.submit(otp_delivery_preference: 'voice') end end end diff --git a/spec/forms/sms_form_spec.rb b/spec/forms/sms_form_spec.rb new file mode 100644 index 00000000000..ae0a1e9d0df --- /dev/null +++ b/spec/forms/sms_form_spec.rb @@ -0,0 +1,46 @@ +require 'rails_helper' + +describe SmsForm do + describe '#submit' do + let(:url) { 'https://example.com' } + let(:signature) { 'signature' } + + before do + # Only testing our validation here; service spec tests Twilio msg failing + allow_any_instance_of(Twilio::Security::RequestValidator).to( + receive(:validate).and_return(true) + ) + end + + context 'when the form is valid' do + it 'returns FormResponse with success: true' do + good_params = { Body: 'STOP', FromCountry: 'US', MessageSid: '1' } + message = TwilioService::Sms::Request.new(url, good_params, signature) + form = SmsForm.new(message) + + result = instance_double(FormResponse) + extra = { message_sid: '1', from_country: 'US' } + + expect(FormResponse).to receive(:new). + with(success: true, errors: {}, extra: extra).and_return(result) + expect(form.submit).to eq result + end + end + + context 'when the form is invalid' do + it 'returns FormResponse with success: false' do + bad_params = { Body: 'SPORK', FromCountry: 'US', MessageSid: '1' } + message = TwilioService::Sms::Request.new(url, bad_params, signature) + form = SmsForm.new(message) + errors = { base: [t('errors.messages.twilio_inbound_sms_invalid')] } + + result = instance_double(FormResponse) + extra = { message_sid: '1', from_country: 'US' } + + expect(FormResponse).to receive(:new). + with(success: false, errors: errors, extra: extra).and_return(result) + expect(form.submit).to eq result + end + end + end +end diff --git a/spec/forms/two_factor_login_options_form_spec.rb b/spec/forms/two_factor_login_options_form_spec.rb new file mode 100644 index 00000000000..2ae1f264cc6 --- /dev/null +++ b/spec/forms/two_factor_login_options_form_spec.rb @@ -0,0 +1,43 @@ +require 'rails_helper' + +describe TwoFactorLoginOptionsForm do + subject do + TwoFactorLoginOptionsForm.new( + build_stubbed(:user) + ) + end + + describe '#submit' do + context 'when the form is valid' do + it 'returns true for success?' do + extra = { + selection: 'sms', + } + + result = instance_double(FormResponse) + + expect(FormResponse).to receive(:new). + with(success: true, errors: {}, extra: extra).and_return(result) + expect(subject.submit(selection: 'sms')).to eq result + end + end + + context 'when the form is invalid' do + it 'returns false for success? and includes errors' do + errors = { + selection: ['is not included in the list'], + } + + extra = { + selection: 'foo', + } + + result = instance_double(FormResponse) + + expect(FormResponse).to receive(:new). + with(success: false, errors: errors, extra: extra).and_return(result) + expect(subject.submit(selection: 'foo')).to eq result + end + end + end +end diff --git a/spec/jobs/sms_account_reset_cancellation_notifier_job_spec.rb b/spec/jobs/sms_account_reset_cancellation_notifier_job_spec.rb new file mode 100644 index 00000000000..2960013b35d --- /dev/null +++ b/spec/jobs/sms_account_reset_cancellation_notifier_job_spec.rb @@ -0,0 +1,38 @@ +require 'rails_helper' + +describe SmsAccountResetCancellationNotifierJob do + include Features::ActiveJobHelper + + describe '.perform' do + before do + reset_job_queues + TwilioService::Utils.telephony_service = FakeSms + FakeSms.messages = [] + end + + subject(:perform) do + SmsAccountResetCancellationNotifierJob.perform_now( + phone: '+1 (888) 555-5555' + ) + end + + it 'sends a message to the mobile number', twilio: true do + allow(Figaro.env).to receive(:twilio_messaging_service_sid).and_return('fake_sid') + + TwilioService::Utils.telephony_service = FakeSms + + perform + + messages = FakeSms.messages + + expect(messages.size).to eq(1) + + msg = messages.first + + expect(msg.messaging_service_sid).to eq('fake_sid') + expect(msg.to).to eq('+1 (888) 555-5555') + expect(msg.body). + to eq(I18n.t('jobs.sms_account_reset_cancel_job.message', app: APP_NAME)) + end + end +end diff --git a/spec/jobs/sms_account_reset_notifier_job_spec.rb b/spec/jobs/sms_account_reset_notifier_job_spec.rb index 1c220653cd5..d80cdfe9ee6 100644 --- a/spec/jobs/sms_account_reset_notifier_job_spec.rb +++ b/spec/jobs/sms_account_reset_notifier_job_spec.rb @@ -7,7 +7,7 @@ describe '.perform' do before do reset_job_queues - TwilioService.telephony_service = FakeSms + TwilioService::Utils.telephony_service = FakeSms FakeSms.messages = [] end @@ -21,7 +21,7 @@ it 'sends a message containing the cancel link to the mobile number', twilio: true do allow(Figaro.env).to receive(:twilio_messaging_service_sid).and_return('fake_sid') - TwilioService.telephony_service = FakeSms + TwilioService::Utils.telephony_service = FakeSms perform @@ -33,8 +33,10 @@ expect(msg.messaging_service_sid).to eq('fake_sid') expect(msg.to).to eq('+1 (888) 555-5555') - expect(msg.body).to eq(I18n.t('jobs.sms_account_reset_notifier_job.message', app: APP_NAME, - cancel_link: account_reset_cancel_url(token: 'UUID1'))) + cancel_link = account_reset_cancel_url(token: 'UUID1') + expect(msg.body). + to eq(I18n.t('jobs.sms_account_reset_notifier_job.message', app: APP_NAME, + cancel_link: cancel_link)) end end -end \ No newline at end of file +end diff --git a/spec/jobs/sms_otp_sender_job_spec.rb b/spec/jobs/sms_otp_sender_job_spec.rb index 288fa3bd67a..0ae550cc039 100644 --- a/spec/jobs/sms_otp_sender_job_spec.rb +++ b/spec/jobs/sms_otp_sender_job_spec.rb @@ -6,7 +6,7 @@ describe '.perform' do before do reset_job_queues - TwilioService.telephony_service = FakeSms + TwilioService::Utils.telephony_service = FakeSms FakeSms.messages = [] end @@ -23,7 +23,7 @@ it 'sends a message containing the OTP code to the mobile number', twilio: true do allow(Figaro.env).to receive(:twilio_messaging_service_sid).and_return('fake_sid') - TwilioService.telephony_service = FakeSms + TwilioService::Utils.telephony_service = FakeSms perform @@ -44,7 +44,7 @@ allow(I18n).to receive(:locale).and_return(:en).at_least(:once) allow(Devise).to receive(:direct_otp_valid_for).and_return(4.minutes) - TwilioService.telephony_service = FakeSms + TwilioService::Utils.telephony_service = FakeSms perform @@ -83,7 +83,7 @@ end end - context 'when the parsed country of the phone number is not US' do + context 'when the phone number country is not in the programmable_sms_countries list' do it 'sends the SMS via PhoneVerification class' do PhoneVerification.adapter = FakeAdapter phone = '+1 787-327-0143' @@ -104,5 +104,30 @@ ) end end + + context 'when the phone number country is in the programmable_sms_countries list' do + it 'sends the SMS via TwilioService' do + allow(Figaro.env).to receive(:programmable_sms_countries).and_return('US,CA,FR') + phone = '+33 661 32 70 14' + service = instance_double(TwilioService::Utils) + code = '123456' + + expect(TwilioService::Utils).to receive(:new).and_return(service) + expect(service).to receive(:send_sms).with( + to: phone, + body: I18n.t( + 'jobs.sms_otp_sender_job.message', + code: code, app: APP_NAME, expiration: Devise.direct_otp_valid_for.to_i / 60 + ) + ) + + SmsOtpSenderJob.perform_now( + code: code, + phone: phone, + otp_created_at: otp_created_at, + locale: 'fr' + ) + end + end end end diff --git a/spec/jobs/voice_otp_sender_job_spec.rb b/spec/jobs/voice_otp_sender_job_spec.rb index ef2275176aa..490accc8d26 100644 --- a/spec/jobs/voice_otp_sender_job_spec.rb +++ b/spec/jobs/voice_otp_sender_job_spec.rb @@ -5,7 +5,7 @@ describe '.perform' do before do - TwilioService.telephony_service = FakeVoiceCall + TwilioService::Utils.telephony_service = FakeVoiceCall FakeVoiceCall.calls = [] end diff --git a/spec/lib/tasks/rotate_rake_spec.rb b/spec/lib/tasks/rotate_rake_spec.rb index ae7fb955892..829902cc56f 100644 --- a/spec/lib/tasks/rotate_rake_spec.rb +++ b/spec/lib/tasks/rotate_rake_spec.rb @@ -2,14 +2,18 @@ require 'rake' describe 'rotate' do + let(:user) { create(:user, phone: '703-555-5555') } + before do + Rake.application.rake_require('lib/tasks/rotate', [Rails.root.to_s]) + Rake::Task.define_task(:environment) + ENV['PROGRESS'] = 'no' + end + after do + ENV['PROGRESS'] = 'yes' + end + describe 'attribute_encryption_key' do it 'runs successfully' do - prev_progress = ENV['PROGRESS'] - ENV['PROGRESS'] = 'no' - Rake.application.rake_require('lib/tasks/rotate', [Rails.root.to_s]) - Rake::Task.define_task(:environment) - - user = create(:user, phone: '703-555-5555') old_email = user.email old_phone = user.phone old_encrypted_email = user.encrypted_email @@ -17,14 +21,29 @@ rotate_attribute_encryption_key - Rake::Task['rotate:attribute_encryption_key'].invoke + Rake::Task['rotate:attribute_encryption_key'].execute user.reload expect(user.phone).to eq old_phone expect(user.email).to eq old_email expect(user.encrypted_email).to_not eq old_encrypted_email expect(user.encrypted_phone).to_not eq old_encrypted_phone - ENV['PROGRESS'] = prev_progress + end + + it 'does not raise an exception when encrypting/decrypting a user' do + allow_any_instance_of(User).to receive(:email).and_raise(StandardError) + + expect do + Rake::Task['rotate:attribute_encryption_key'].execute + end.to_not raise_error + end + + it 'outputs diagnostic information on users that throw exceptions ' do + allow_any_instance_of(User).to receive(:email).and_raise(StandardError) + + expect do + Rake::Task['rotate:attribute_encryption_key'].execute + end.to output(/Error with user id:#{user.id}/).to_stdout end end end diff --git a/spec/lib/worker_health_checker_spec.rb b/spec/lib/worker_health_checker_spec.rb index 3c67542ffba..8ed30ec5884 100644 --- a/spec/lib/worker_health_checker_spec.rb +++ b/spec/lib/worker_health_checker_spec.rb @@ -12,7 +12,6 @@ end def create_sidekiq_queues(*queues) - # TODO: find an API to use rather than manually mess with redis? Sidekiq.redis do |redis| queues.each do |queue| redis.sadd('queues', queue) diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb index 13e6c1ac0c2..beb7db5bab6 100644 --- a/spec/mailers/user_mailer_spec.rb +++ b/spec/mailers/user_mailer_spec.rb @@ -145,9 +145,12 @@ def expect_email_body_to_have_help_and_contact_links end it 'renders the body' do - expect(mail.html_part.body).to have_content(strip_tags( \ - t('user_mailer.account_reset_request.intro', \ - cancel_account_reset: t('user_mailer.account_reset_granted.cancel_link_text')))) + reset_text = t('user_mailer.account_reset_granted.cancel_link_text') + expect(mail.html_part.body).to have_content( + strip_tags( + t('user_mailer.account_reset_request.intro', cancel_account_reset: reset_text) + ) + ) end end @@ -184,7 +187,8 @@ def expect_email_body_to_have_help_and_contact_links end it 'renders the body' do - expect(mail.html_part.body).to have_content(strip_tags(t('user_mailer.account_reset_complete.intro'))) + expect(mail.html_part.body). + to have_content(strip_tags(t('user_mailer.account_reset_complete.intro'))) end end diff --git a/spec/models/account_reset_request_spec.rb b/spec/models/account_reset_request_spec.rb index 41ede864ac7..fdc19d3fd93 100644 --- a/spec/models/account_reset_request_spec.rb +++ b/spec/models/account_reset_request_spec.rb @@ -41,7 +41,9 @@ end it 'returns the record if the token is valid' do - arr = AccountResetRequest.create(id: 1, user_id: 2, granted_token: '123', granted_at: Time.zone.now) + arr = AccountResetRequest.create( + id: 1, user_id: 2, granted_token: '123', granted_at: Time.zone.now + ) expect(AccountResetRequest.from_valid_granted_token('123')).to eq(arr) end diff --git a/spec/presenters/two_factor_auth_code/authenticator_delivery_presenter_spec.rb b/spec/presenters/two_factor_auth_code/authenticator_delivery_presenter_spec.rb index abd1d8621c3..7f4d9f4e860 100644 --- a/spec/presenters/two_factor_auth_code/authenticator_delivery_presenter_spec.rb +++ b/spec/presenters/two_factor_auth_code/authenticator_delivery_presenter_spec.rb @@ -1,16 +1,28 @@ require 'rails_helper' describe TwoFactorAuthCode::AuthenticatorDeliveryPresenter do + let(:view) { ActionController::Base.new.view_context } + let(:presenter) do + TwoFactorAuthCode::AuthenticatorDeliveryPresenter. + new(data: {}, view: view) + end + + describe '#header' do + it 'supplies a header' do + expect(presenter.header).to eq(t('devise.two_factor_authentication.totp_header_text')) + end + end + + describe '#fallback_question' do + it 'supplies a fallback_question' do + expect(presenter.fallback_question).to \ + eq(t('two_factor_authentication.totp_fallback.question')) + end + end + it 'handles multiple locales' do I18n.available_locales.each do |locale| I18n.locale = locale - presenter.fallback_links.each do |html| - if locale == :en - expect(html).not_to match(%r{href="/en/}) - else - expect(html).to match(%r{href="/#{locale}/}) - end - end if locale == :en expect(presenter.cancel_link).not_to match(%r{/en/}) else @@ -18,9 +30,4 @@ end end end - - def presenter - TwoFactorAuthCode::AuthenticatorDeliveryPresenter. - new(data: {}, view: ActionController::Base.new.view_context) - end end diff --git a/spec/presenters/two_factor_auth_code/personal_key_presenter_spec.rb b/spec/presenters/two_factor_auth_code/personal_key_presenter_spec.rb new file mode 100644 index 00000000000..8e67e556238 --- /dev/null +++ b/spec/presenters/two_factor_auth_code/personal_key_presenter_spec.rb @@ -0,0 +1,22 @@ +require 'rails_helper' + +describe TwoFactorAuthCode::PersonalKeyPresenter do + include Rails.application.routes.url_helpers + + let(:presenter) do + TwoFactorAuthCode::PersonalKeyPresenter.new + end + + describe '#fallback_question' do + it 'returns the fallback question' do + expect(presenter.fallback_question).to eq \ + t('two_factor_authentication.personal_key_fallback.question') + end + end + + describe '#help_text' do + it 'returns blank' do + expect(presenter.help_text).to eq '' + end + end +end diff --git a/spec/presenters/two_factor_auth_code/phone_delivery_presenter_spec.rb b/spec/presenters/two_factor_auth_code/phone_delivery_presenter_spec.rb index 3fdb8a3c622..878b0759c4b 100644 --- a/spec/presenters/two_factor_auth_code/phone_delivery_presenter_spec.rb +++ b/spec/presenters/two_factor_auth_code/phone_delivery_presenter_spec.rb @@ -52,127 +52,6 @@ end end - describe '#fallback_links' do - it 'does not list an account reset link when the phone is unconfirmed' do - allow(presenter).to receive(:unconfirmed_phone).and_return(true) - - expect(presenter.fallback_links.join(' ')).to_not include(account_reset_delete_account_link) - end - - it 'does not list an account reset link when the feature is disabled' do - allow(presenter).to receive(:unconfirmed_phone).and_return(false) - allow(Figaro.env).to receive(:account_reset_enabled).and_return('false') - - expect(presenter.fallback_links.join(' ')).to_not include(account_reset_delete_account_link) - end - - it 'shows an account reset link when the user has not requested a delete' do - allow(presenter).to receive(:unconfirmed_phone).and_return(false) - allow(Figaro.env).to receive(:account_reset_enabled).and_return('enabled') - - expect(presenter.fallback_links.join(' ')).to include(account_reset_delete_account_link) - end - - it 'shows a cancel link when the user has requested a delete' do - allow(presenter).to receive(:unconfirmed_phone).and_return(false) - allow(presenter).to receive(:account_reset_token).and_return('foo') - allow(Figaro.env).to receive(:account_reset_enabled).and_return('enabled') - - expect(presenter.fallback_links.join(' ')).to include(account_reset_cancel_link('foo')) - end - - it 'handles multiple locales' do - I18n.available_locales.each do |locale| - presenter_for_locale = presenter_with_locale(locale) - I18n.locale = locale - presenter_for_locale.fallback_links.each do |html| - if locale == :en - expect(html).not_to match(%r{href="/en/}) - else - expect(html).to match(%r{href="/#{locale}/}) - end - end - if locale == :en - expect(presenter_for_locale.cancel_link).not_to match(%r{/en/}) - else - expect(presenter_for_locale.cancel_link).to match(%r{/#{locale}/}) - end - end - end - - context 'with totp enabled' do - before do - data[:totp_enabled] = true - end - - context 'voice otp delivery supported' do - it 'renders an auth app fallback link' do - expect(presenter.fallback_links.join(' ')).to include( - I18n.t('links.two_factor_authentication.app') - ) - end - - it 'renders a voice otp link' do - expect(presenter.fallback_links.join(' ')).to include( - I18n.t('links.two_factor_authentication.voice') - ) - end - end - - context 'voice otp deliver unsupported' do - before do - data[:voice_otp_delivery_unsupported] = true - end - - it 'renders an auth app fallback link' do - expect(presenter.fallback_links.join(' ')).to include( - I18n.t('links.two_factor_authentication.app') - ) - end - - it 'does not render a voice otp link' do - expect(presenter.fallback_links.join(' ')).to_not include( - I18n.t('links.two_factor_authentication.voice') - ) - end - end - end - - context 'without totp enabled' do - context 'voice otp delivery supported' do - it 'does not render an auth app fallback link' do - expect(presenter.fallback_links.join(' ')).to_not include( - I18n.t('links.two_factor_authentication.app') - ) - end - - it 'renders a voice otp link' do - expect(presenter.fallback_links.join(' ')).to include( - I18n.t('links.two_factor_authentication.voice') - ) - end - end - - context 'voice otp deliver unsupported' do - before do - data[:voice_otp_delivery_unsupported] = true - end - - it 'does not render an auth app fallback link' do - expect(presenter.fallback_links.join(' ')).to_not include( - I18n.t('links.two_factor_authentication.app') - ) - end - - it 'does not render a voice otp link' do - expect(presenter.fallback_links.join(' ')).to_not include( - I18n.t('links.two_factor_authentication.voice') - ) - end - end - end - end - describe '#phone_number_message' do it 'specifies when the code will expire' do text = t( diff --git a/spec/presenters/two_factor_auth_code/piv_cac_authentication_presenter_spec.rb b/spec/presenters/two_factor_auth_code/piv_cac_authentication_presenter_spec.rb index 615ab590732..9449d4137e0 100644 --- a/spec/presenters/two_factor_auth_code/piv_cac_authentication_presenter_spec.rb +++ b/spec/presenters/two_factor_auth_code/piv_cac_authentication_presenter_spec.rb @@ -34,28 +34,6 @@ def presenter_with(arguments = {}, view = ActionController::Base.new.view_contex it { expect(presenter.piv_cac_capture_text).to eq expected_text } end - describe '#fallback_links' do - context 'with phone enabled' do - let(:presenter) do - presenter_with(reauthn: reauthn, user_email: user_email, phone_enabled: true) - end - - it 'has two options' do - expect(presenter.fallback_links.count).to eq 2 - end - end - - context 'with phone disabled' do - let(:presenter) do - presenter_with(reauthn: reauthn, user_email: user_email, phone_enabled: false) - end - - it 'has one option' do - expect(presenter.fallback_links.count).to eq 1 - end - end - end - describe '#cancel_link' do let(:locale) { LinkLocaleResolver.locale } diff --git a/spec/presenters/two_factor_login_options_presenter_spec.rb b/spec/presenters/two_factor_login_options_presenter_spec.rb new file mode 100644 index 00000000000..7220d2926ba --- /dev/null +++ b/spec/presenters/two_factor_login_options_presenter_spec.rb @@ -0,0 +1,43 @@ +require 'rails_helper' + +describe TwoFactorLoginOptionsPresenter do + include Rails.application.routes.url_helpers + + let(:user) { User.new } + let(:view) { ActionController::Base.new.view_context } + let(:presenter) do + TwoFactorLoginOptionsPresenter.new(user, view, nil) + end + + it 'supplies a title' do + expect(presenter.title).to eq \ + t('two_factor_authentication.login_options_title') + end + + it 'supplies a heading' do + expect(presenter.heading).to eq \ + t('two_factor_authentication.login_options_title') + end + + it 'supplies a cancel link when there is an account reset token' do + allow_any_instance_of(TwoFactorLoginOptionsPresenter).to \ + receive(:account_reset_token).and_return('foo') + expect(presenter.account_reset_or_cancel_link).to eq \ + t('devise.two_factor_authentication.account_reset.pending_html', + cancel_link: view.link_to( + t('devise.two_factor_authentication.account_reset.cancel_link'), + account_reset_cancel_url(token: 'foo') + )) + end + + it 'supplies a reset link when there is no account reset token' do + allow_any_instance_of(TwoFactorLoginOptionsPresenter).to \ + receive(:account_reset_token).and_return(nil) + expect(presenter.account_reset_or_cancel_link).to eq \ + t('devise.two_factor_authentication.account_reset.text_html', + link: view.link_to( + t('devise.two_factor_authentication.account_reset.link'), + account_reset_request_path(locale: LinkLocaleResolver.locale) + )) + end +end diff --git a/spec/requests/invalid_sign_in_params_spec.rb b/spec/requests/invalid_sign_in_params_spec.rb new file mode 100644 index 00000000000..c0dd3dc1b33 --- /dev/null +++ b/spec/requests/invalid_sign_in_params_spec.rb @@ -0,0 +1,7 @@ +require 'rails_helper' + +describe 'visiting sign in page with invalid user params' do + it 'does not raise an exception' do + get new_user_session_path, params: { user: 'test@test.com' } + end +end diff --git a/spec/requests/sms_spec.rb b/spec/requests/sms_spec.rb new file mode 100644 index 00000000000..06cd7f1a063 --- /dev/null +++ b/spec/requests/sms_spec.rb @@ -0,0 +1,88 @@ +require 'rails_helper' + +describe 'SMS receiving' do + include Features::LocalizationHelper + + let(:username) { 'auth_username' } + let(:password) { 'auth_password' } + let(:access_denied) { 'HTTP Basic: Access denied' } + let(:credentials) { "Basic #{Base64.encode64("#{username}:#{password}")}" } + + describe 'HTTP Basic Authentication' do + context 'without required credentials' do + it 'returns unauthorized status' do + post api_sms_receive_path + + expect(response).to have_http_status(:unauthorized) + expect(response.body).to include(access_denied) + end + end + + context 'with required credentials' do + it 'returns authorized status' do + allow(Figaro.env).to( + receive(:twilio_http_basic_auth_username).and_return(username) + ) + allow(Figaro.env).to( + receive(:twilio_http_basic_auth_password).and_return(password) + ) + allow_any_instance_of(Twilio::Security::RequestValidator).to( + receive(:validate).and_return(true) + ) + + post_message + + expect(response).to have_http_status(:accepted) + end + end + end + + describe 'receiving messages' do + before do + allow(Figaro.env).to( + receive(:twilio_http_basic_auth_username).and_return(username) + ) + allow(Figaro.env).to( + receive(:twilio_http_basic_auth_password).and_return(password) + ) + end + + context 'when failing' do + it 'does not send a reply' do + allow_any_instance_of(Twilio::Security::RequestValidator).to( + receive(:validate).and_return(false) + ) + + expect(SmsReplySenderJob).to_not receive(:perform_later) + + post_message + + expect(response).to have_http_status(:forbidden) + end + end + + context 'when successful' do + it 'sends a reply' do + allow_any_instance_of(Twilio::Security::RequestValidator).to( + receive(:validate).and_return(true) + ) + + expect(SmsReplySenderJob).to receive(:perform_later) + + post_message + + expect(response).to have_http_status(:accepted) + end + end + end + + private + + def post_message + post( + api_sms_receive_path, + params: { Body: 'help' }, + headers: { 'HTTP_AUTHORIZATION': credentials } + ) + end +end diff --git a/spec/services/account_reset_service_spec.rb b/spec/services/account_reset_service_spec.rb index a9c3edca9d8..6ad96b17490 100644 --- a/spec/services/account_reset_service_spec.rb +++ b/spec/services/account_reset_service_spec.rb @@ -8,9 +8,9 @@ let(:user2) { create(:user) } let(:subject2) { AccountResetService.new(user2) } - before { + before do allow(Figaro.env).to receive(:account_reset_wait_period_days).and_return('1') - } + end describe '#create_request' do it 'creates a new account reset request on the user' do @@ -37,12 +37,13 @@ describe '#cancel_request' do it 'removes tokens from a account reset request' do subject.create_request - AccountResetService.cancel_request(user.account_reset_request.request_token) + cancel = AccountResetService.cancel_request(user.account_reset_request.request_token) arr = AccountResetRequest.find_by(user_id: user.id) expect(arr.request_token).to_not be_present expect(arr.granted_token).to_not be_present expect(arr.requested_at).to be_present expect(arr.cancelled_at).to be_present + expect(arr).to eq(cancel) end it 'does not raise an error for a cancel request with a blank token' do @@ -103,14 +104,14 @@ subject.create_request after_waiting_the_full_wait_period do - notifications_sent = AccountResetService.grant_tokens_and_send_notifications + AccountResetService.grant_tokens_and_send_notifications notifications_sent = AccountResetService.grant_tokens_and_send_notifications expect(notifications_sent).to eq(0) end end it 'does not send notifications when the request was cancelled' do - arr = subject.create_request + subject.create_request AccountResetService.cancel_request(AccountResetRequest.all[0].request_token) after_waiting_the_full_wait_period do @@ -150,7 +151,7 @@ end it 'does not send notifications when the request was cancelled' do - arr = subject.create_request + subject.create_request AccountResetService.cancel_request(AccountResetRequest.all[0].request_token) notifications_sent = AccountResetService.grant_tokens_and_send_notifications @@ -160,10 +161,10 @@ end def after_waiting_the_full_wait_period - TwilioService.telephony_service = FakeSms + TwilioService::Utils.telephony_service = FakeSms days = Figaro.env.account_reset_wait_period_days.to_i.days Timecop.travel(Time.zone.now + days) do yield end end -end \ No newline at end of file +end diff --git a/spec/services/idv/agent_spec.rb b/spec/services/idv/agent_spec.rb index 6a5ced0f146..601caeeccc2 100644 --- a/spec/services/idv/agent_spec.rb +++ b/spec/services/idv/agent_spec.rb @@ -72,7 +72,7 @@ proc { |_, r| r.add_message('bah humbug').add_error(:bad, 'stuff') } end Class.new(Proofer::Base) do - attributes(:foo) + required_attributes(:foo) proof(&logic) end end diff --git a/spec/services/otp_delivery_preference_updater_spec.rb b/spec/services/otp_delivery_preference_updater_spec.rb new file mode 100644 index 00000000000..d8b28dc88b4 --- /dev/null +++ b/spec/services/otp_delivery_preference_updater_spec.rb @@ -0,0 +1,89 @@ +require 'rails_helper' + +describe OtpDeliveryPreferenceUpdater do + subject do + OtpDeliveryPreferenceUpdater.new( + user: build_stubbed(:user, otp_delivery_preference: 'sms'), + preference: 'sms', + context: 'authentication' + ) + end + + describe '#call' do + context 'with authentication context' do + context 'when otp_delivery_preference is the same as the user otp_delivery_preference' do + it 'does not update the user' do + expect(UpdateUser).to_not receive(:new) + + subject.call + end + end + + context 'when otp_delivery_preference is different from the user otp_delivery_preference' do + it 'updates the user' do + user = build_stubbed(:user, otp_delivery_preference: 'voice') + updater = OtpDeliveryPreferenceUpdater.new( + user: user, + preference: 'sms', + context: 'authentication' + ) + attributes = { otp_delivery_preference: 'sms' } + + updated_user = instance_double(UpdateUser) + allow(UpdateUser).to receive(:new). + with(user: user, attributes: attributes).and_return(updated_user) + + expect(updated_user).to receive(:call) + + updater.call + end + end + end + + context 'with idv context' do + context 'when otp_delivery_preference is the same as the user otp_delivery_preference' do + it 'does not update the user' do + user = build_stubbed(:user, otp_delivery_preference: 'sms') + updater = OtpDeliveryPreferenceUpdater.new( + user: user, + preference: 'sms', + context: 'idv' + ) + + expect(UpdateUser).to_not receive(:new) + + updater.call + end + end + + context 'when otp_delivery_preference is different from the user otp_delivery_preference' do + it 'does not update the user' do + user = build_stubbed(:user, otp_delivery_preference: 'voice') + updater = OtpDeliveryPreferenceUpdater.new( + user: user, + preference: 'sms', + context: 'idv' + ) + + expect(UpdateUser).to_not receive(:new) + + updater.call + end + end + end + + context 'when user is nil' do + it 'does not update the user' do + updater = OtpDeliveryPreferenceUpdater.new( + user: nil, + preference: 'sms', + context: 'idv' + ) + + expect(UpdateUser).to_not receive(:new) + + updater.call + end + end + end +end diff --git a/spec/services/phone_verification_spec.rb b/spec/services/phone_verification_spec.rb index dbbc692fd1a..86db9f3fe67 100644 --- a/spec/services/phone_verification_spec.rb +++ b/spec/services/phone_verification_spec.rb @@ -45,5 +45,21 @@ expect(error).to be_a(PhoneVerification::VerifyError) end end + + it 'raises VerifyError when response body is not valid JSON' do + PhoneVerification.adapter = FakeAdapter + phone = '17035551212' + code = '123456' + + allow(FakeAdapter).to receive(:post).and_return(FakeAdapter::EmptyResponse.new) + + expect { PhoneVerification.new(phone: phone, code: code).send_sms }.to raise_error do |error| + expect(error.code).to eq 0 + expect(error.message).to eq '' + expect(error.status).to eq 400 + expect(error.response).to eq '' + expect(error).to be_a(PhoneVerification::VerifyError) + end + end end end diff --git a/spec/services/twilio_service/sms/request_spec.rb b/spec/services/twilio_service/sms/request_spec.rb new file mode 100644 index 00000000000..ee630086033 --- /dev/null +++ b/spec/services/twilio_service/sms/request_spec.rb @@ -0,0 +1,59 @@ +require 'rails_helper' + +describe TwilioService::Sms::Request do + let(:url) { 'https://example.com' } + let(:params) { { Body: 'stop' } } + let(:signature) { 'signature' } + + describe '#valid?' do + context 'when message signature is invalid' do + it 'returns false' do + allow_any_instance_of(Twilio::Security::RequestValidator).to( + receive(:validate).and_return(false) + ) + + message = described_class.new(url, params, signature) + + expect(message.valid?).to be false + end + end + + context 'when message signature is valid' do + before do + allow_any_instance_of(Twilio::Security::RequestValidator).to( + receive(:validate).and_return(true) + ) + end + + it 'returns false when message is empty' do + empty_params = {} + + message = described_class.new(url, empty_params, signature) + + expect(message.valid?).to be false + end + + it 'returns false when message is not a valid message type' do + invalid_params = { Body: 'SPORK' } + + message = described_class.new(url, invalid_params, signature) + + expect(message.valid?).to be false + end + + it 'returns false when message includes valid message type in sentence' do + invalid_params = { Body: 'stop it' } + + message = described_class.new(url, invalid_params, signature) + + expect(message.valid?).to be false + end + + it 'returns true when message is valid' do + message = described_class.new(url, params, signature) + + expect(message.valid?).to be true + end + end + end +end diff --git a/spec/services/twilio_service/sms/response_spec.rb b/spec/services/twilio_service/sms/response_spec.rb new file mode 100644 index 00000000000..999ac6d8161 --- /dev/null +++ b/spec/services/twilio_service/sms/response_spec.rb @@ -0,0 +1,63 @@ +require 'rails_helper' + +describe TwilioService::Sms::Response do + let(:url) { 'https://example.com' } + let(:params) { { Body: 'stop' } } + let(:signature) { 'signature' } + let(:request) { TwilioService::Sms::Request } + + before do + allow_any_instance_of(Twilio::Security::RequestValidator).to( + receive(:validate).and_return(true) + ) + end + + describe '#reply' do + let(:number) { '+1 202-555-1212' } + + it 'dispatches a message based on its type' do + message = request.new(url, params, signature) + response = described_class.new(message) + + expect(response).to receive(:stop) + + response.reply + end + + it 'is case-insensitive' do + message = request.new(url, { Body: 'STOP' }, signature) + response = described_class.new(message) + + expect(response).to receive(:stop) + + response.reply + end + + it 'calls stop with alternative words' do + %w[cancel end quit unsubscribe].each do |stop_word| + message = request.new(url, { Body: stop_word }, signature) + response = described_class.new(message) + + expect(response).to receive(:stop) + + response.reply + end + end + + it 'calls stop and returns the appropriate message' do + message = request.new(url, { Body: 'stop', From: number }, signature) + response = described_class.new(message) + expected = { to: number, body: t('sms.stop.message') } + + expect(response.reply).to eq(expected) + end + + it 'calls help and returns the appropriate message' do + message = request.new(url, { Body: 'help', From: number }, signature) + response = described_class.new(message) + expected = { to: number, body: t('sms.help.message') } + + expect(response.reply).to eq(expected) + end + end +end diff --git a/spec/services/twilio_service_spec.rb b/spec/services/twilio_service_spec.rb index 6246839f845..01ea8a591f7 100644 --- a/spec/services/twilio_service_spec.rb +++ b/spec/services/twilio_service_spec.rb @@ -1,13 +1,13 @@ require 'rails_helper' -describe TwilioService do +describe TwilioService::Utils do context 'when telephony is disabled' do before do expect(FeatureManagement).to receive(:telephony_disabled?).at_least(:once).and_return(true) end it 'uses NullTwilioClient' do - TwilioService.telephony_service = Twilio::REST::Client + TwilioService::Utils.telephony_service = Twilio::REST::Client client = instance_double(NullTwilioClient) expect(NullTwilioClient).to receive(:new).and_return(client) @@ -16,11 +16,11 @@ expect(http_client).to receive(:adapter=).with(:typhoeus) expect(Twilio::REST::Client).to_not receive(:new) - TwilioService.new + TwilioService::Utils.new end it 'does not send OTP messages', twilio: true do - TwilioService.telephony_service = FakeSms + TwilioService::Utils.telephony_service = FakeSms SmsOtpSenderJob.perform_now( code: '1234', @@ -36,7 +36,7 @@ before do expect(FeatureManagement).to receive(:telephony_disabled?). at_least(:once).and_return(false) - TwilioService.telephony_service = Twilio::REST::Client + TwilioService::Utils.telephony_service = Twilio::REST::Client end it 'uses a real Twilio client' do @@ -46,20 +46,20 @@ http_client = Struct.new(:adapter) expect(client).to receive(:http_client).and_return(http_client) expect(http_client).to receive(:adapter=).with(:typhoeus) - TwilioService.new + TwilioService::Utils.new end end describe '#phone_number' do it 'randomly samples one of the numbers' do - expect(TWILIO_NUMBERS).to include(TwilioService.new.phone_number) + expect(TWILIO_NUMBERS).to include(TwilioService::Utils.new.phone_number) end end describe '#place_call' do it 'initiates a phone call with options', twilio: true do - TwilioService.telephony_service = FakeVoiceCall - service = TwilioService.new + TwilioService::Utils.telephony_service = FakeVoiceCall + service = TwilioService::Utils.new service.place_call( to: '5555555555', @@ -74,7 +74,7 @@ end it 'partially redacts phone numbers embedded in error messages from Twilio' do - TwilioService.telephony_service = FakeVoiceCall + TwilioService::Utils.telephony_service = FakeVoiceCall raw_message = 'Unable to create record: Account not authorized to call +123456789012.' error_code = '21215' status_code = 400 @@ -82,7 +82,7 @@ "Unable to create record: Account " \ "not authorized to call +12345#######.\n\n" - service = TwilioService.new + service = TwilioService::Utils.new raw_error = Twilio::REST::RestError.new( raw_message, FakeTwilioErrorResponse.new(error_code) @@ -100,8 +100,8 @@ it 'sends an SMS with valid attributes', twilio: true do allow(Figaro.env).to receive(:twilio_messaging_service_sid).and_return('fake_sid') - TwilioService.telephony_service = FakeSms - service = TwilioService.new + TwilioService::Utils.telephony_service = FakeSms + service = TwilioService::Utils.new service.send_sms( to: '5555555555', @@ -118,14 +118,14 @@ end it 'partially redacts phone numbers embedded in error messages from Twilio' do - TwilioService.telephony_service = FakeSms + TwilioService::Utils.telephony_service = FakeSms raw_message = "The 'To' number +1 (888) 555-5555 is not a valid phone number" error_code = '21211' status_code = 400 sanitized_message = "[HTTP #{status_code}] #{error_code} : The 'To' " \ "number +1 (888) 5##-#### is not a valid phone number\n\n" - service = TwilioService.new + service = TwilioService::Utils.new raw_error = Twilio::REST::RestError.new( raw_message, FakeTwilioErrorResponse.new(error_code) ) diff --git a/spec/support/fake_adapter.rb b/spec/support/fake_adapter.rb index f721126e560..628551296c9 100644 --- a/spec/support/fake_adapter.rb +++ b/spec/support/fake_adapter.rb @@ -20,5 +20,23 @@ def response_body message: 'Invalid number', }.to_json end + + def response_code + 400 + end + end + + class EmptyResponse + def success? + false + end + + def response_body + '' + end + + def response_code + 400 + end end end diff --git a/spec/support/features/session_helper.rb b/spec/support/features/session_helper.rb index 94e7c921ebf..a7482c619dc 100644 --- a/spec/support/features/session_helper.rb +++ b/spec/support/features/session_helper.rb @@ -10,6 +10,14 @@ def sign_up_with(email) click_button t('forms.buttons.submit.default') end + def choose_another_security_option(option) + click_link t('two_factor_authentication.login_options_link_text') + + expect(current_path).to eq login_two_factor_options_path + + select_2fa_option(option) + end + def select_2fa_option(option) find("label[for='two_factor_options_form_selection_#{option}']").click click_on t('forms.buttons.continue') @@ -441,11 +449,11 @@ def sign_in_via_branded_page(user) end def stub_twilio_service - twilio_service = instance_double(TwilioService) + twilio_service = instance_double(TwilioService::Utils) allow(twilio_service).to receive(:send_sms) allow(twilio_service).to receive(:place_call) - allow(TwilioService).to receive(:new).and_return(twilio_service) + allow(TwilioService::Utils).to receive(:new).and_return(twilio_service) end def stub_piv_cac_service diff --git a/spec/support/shared_examples/account_creation.rb b/spec/support/shared_examples/account_creation.rb index afe342b0246..141d16f4772 100644 --- a/spec/support/shared_examples/account_creation.rb +++ b/spec/support/shared_examples/account_creation.rb @@ -79,7 +79,7 @@ expect(page).to have_content t('instructions.account_recovery_setup.piv_cac_next_step') select_2fa_option('sms') - click_link t('devise.two_factor_authentication.two_factor_choice_cancel') + click_link t('two_factor_authentication.choose_another_option') expect(page).to have_current_path account_recovery_setup_path diff --git a/spec/support/shared_examples/sign_in.rb b/spec/support/shared_examples/sign_in.rb index 6a89df88c95..0dd1cacce63 100644 --- a/spec/support/shared_examples/sign_in.rb +++ b/spec/support/shared_examples/sign_in.rb @@ -35,7 +35,7 @@ visit_idp_from_sp_with_loa1(sp) click_link t('links.sign_in') fill_in_credentials_and_submit(user.email, 'Val!d Pass w0rd') - click_link t('devise.two_factor_authentication.personal_key_fallback.link') + choose_another_security_option('personal_key') enter_personal_key(personal_key: old_personal_key) click_submit_default @@ -65,7 +65,7 @@ visit_idp_from_sp_with_loa3(sp) click_link t('links.sign_in') fill_in_credentials_and_submit(user.email, user.password) - click_link t('devise.two_factor_authentication.personal_key_fallback.link') + choose_another_security_option('personal_key') enter_personal_key(personal_key: personal_key_for_loa3_user(user, pii)) click_submit_default @@ -90,7 +90,7 @@ visit_idp_from_sp_with_loa1(sp) trigger_reset_password_and_click_email_link(user.email) reset_password_and_sign_back_in(user, new_password) - click_link t('devise.two_factor_authentication.personal_key_fallback.link') + choose_another_security_option('personal_key') enter_personal_key(personal_key: old_personal_key) click_submit_default @@ -117,7 +117,7 @@ visit_idp_from_sp_with_loa3(sp) trigger_reset_password_and_click_email_link(user.email) reset_password_and_sign_back_in(user, new_password) - click_link t('devise.two_factor_authentication.personal_key_fallback.link') + choose_another_security_option('personal_key') enter_personal_key(personal_key: personal_key_for_loa3_user(user, pii)) click_submit_default diff --git a/spec/views/account_reset/confirm_delete_account/show.html.slim_spec.rb b/spec/views/account_reset/confirm_delete_account/show.html.slim_spec.rb index f7f48780e14..795f8092ccd 100644 --- a/spec/views/account_reset/confirm_delete_account/show.html.slim_spec.rb +++ b/spec/views/account_reset/confirm_delete_account/show.html.slim_spec.rb @@ -23,10 +23,9 @@ it 'contains link to create a new account' do render - puts rendered.inspect expect(rendered).to have_link( - t('account_reset.confirm_delete_account.link_text', app: APP_NAME), - href: root_path - ) + t('account_reset.confirm_delete_account.link_text', app: APP_NAME), + href: root_path + ) end end diff --git a/spec/views/account_reset/delete_account/show.html.slim_spec.rb b/spec/views/account_reset/delete_account/show.html.slim_spec.rb index 6fe0957933e..bd5573e0aba 100644 --- a/spec/views/account_reset/delete_account/show.html.slim_spec.rb +++ b/spec/views/account_reset/delete_account/show.html.slim_spec.rb @@ -12,7 +12,6 @@ end it 'has button to delete' do - render expect(rendered).to have_button t('account_reset.delete_account.delete_button') end diff --git a/spec/views/account_reset/request/show.html.slim_spec.rb b/spec/views/account_reset/request/show.html.slim_spec.rb index d68284b8311..b0fc0d303e8 100644 --- a/spec/views/account_reset/request/show.html.slim_spec.rb +++ b/spec/views/account_reset/request/show.html.slim_spec.rb @@ -8,7 +8,6 @@ end it 'has button to delete' do - render expect(rendered).to have_button t('account_reset.request.yes_continue') end diff --git a/spec/views/phone_setup/index.html.slim_spec.rb b/spec/views/phone_setup/index.html.slim_spec.rb index 94c1c5ebd31..895995f36ec 100644 --- a/spec/views/phone_setup/index.html.slim_spec.rb +++ b/spec/views/phone_setup/index.html.slim_spec.rb @@ -17,7 +17,7 @@ it 'renders a link to choose a different option' do expect(rendered).to have_link( - t('devise.two_factor_authentication.two_factor_choice_cancel'), + t('two_factor_authentication.choose_another_option'), href: two_factor_options_path ) end diff --git a/spec/views/two_factor_authentication/options/index.html.slim_spec.rb b/spec/views/two_factor_authentication/options/index.html.slim_spec.rb new file mode 100644 index 00000000000..d0aac074d23 --- /dev/null +++ b/spec/views/two_factor_authentication/options/index.html.slim_spec.rb @@ -0,0 +1,26 @@ +require 'rails_helper' + +describe 'two_factor_authentication/options/index.html.slim' do + let(:user) { User.new } + before do + allow(view).to receive(:user_session).and_return({}) + allow(view).to receive(:current_user).and_return(User.new) + @presenter = TwoFactorLoginOptionsPresenter.new(user, view, nil) + @two_factor_options_form = TwoFactorLoginOptionsForm.new(user) + end + + it 'has a localized title' do + expect(view).to receive(:title).with( \ + t('two_factor_authentication.login_options_title') + ) + + render + end + + it 'has a localized heading' do + render + + expect(rendered).to have_content \ + t('two_factor_authentication.login_options_title') + end +end diff --git a/spec/views/two_factor_authentication/otp_verification/show.html.slim_spec.rb b/spec/views/two_factor_authentication/otp_verification/show.html.slim_spec.rb index d09f422d7d7..ce1b436350c 100644 --- a/spec/views/two_factor_authentication/otp_verification/show.html.slim_spec.rb +++ b/spec/views/two_factor_authentication/otp_verification/show.html.slim_spec.rb @@ -46,22 +46,9 @@ context 'OTP copy' do let(:help_text) do - code_link = link_to( - t('links.two_factor_authentication.resend_code.sms'), - otp_send_path( - locale: LinkLocaleResolver.locale, - otp_delivery_selection_form: { - otp_delivery_preference: 'sms', - resend: true, - } - ) - ) - - t( - "instructions.mfa.#{presenter_data[:otp_delivery_preference]}.confirm_code_html", - number: "#{presenter_data[:phone_number]}", - resend_code_link: code_link - ) + t('instructions.mfa.sms.number_message', + number: content_tag(:strong, presenter_data[:phone_number]), + expiration: Figaro.env.otp_valid_for) end it 'informs the user that an OTP has been sent to their number via #help_text' do @@ -92,8 +79,8 @@ it 'provides an option to use a personal key' do expect(rendered).to have_link( - t('devise.two_factor_authentication.personal_key_fallback.link'), - href: login_two_factor_personal_key_path + t('two_factor_authentication.login_options_link_text'), + href: login_two_factor_options_path ) end end @@ -178,7 +165,8 @@ render expect(rendered).to have_link( - t('links.two_factor_authentication.app'), href: login_two_factor_authenticator_path + t('two_factor_authentication.login_options_link_text'), + href: login_two_factor_options_path ) end end @@ -203,23 +191,17 @@ }) expect(rendered).to have_link( - t("links.two_factor_authentication.resend_code.#{otp_delivery_preference}"), + t('links.two_factor_authentication.get_another_code'), href: resend_path ) end it 'has a fallback link to send confirmation with voice' do - expected_fallback_path = otp_send_path( - otp_delivery_selection_form: { otp_delivery_preference: 'voice' } - ) - expected_link = link_to( - t('links.two_factor_authentication.voice'), expected_fallback_path - ) - render - expect(rendered).to include( - t("instructions.mfa.#{otp_delivery_preference}.fallback_html", link: expected_link) + expect(rendered).to have_link( + t('two_factor_authentication.login_options_link_text'), + href: login_two_factor_options_path ) end @@ -265,25 +247,17 @@ ) expect(rendered).to have_link( - t("links.two_factor_authentication.resend_code.#{otp_delivery_preference}"), + t('links.two_factor_authentication.get_another_code'), href: resend_path ) end it 'has a fallback link to send confirmation as SMS' do - expected_fallback_path = otp_send_path( - otp_delivery_selection_form: { - otp_delivery_preference: 'sms', - } - ) - expected_link = link_to( - t('links.two_factor_authentication.sms'), expected_fallback_path - ) - render - expect(rendered).to include( - t("instructions.mfa.#{otp_delivery_preference}.fallback_html", link: expected_link) + expect(rendered).to have_link( + t('two_factor_authentication.login_options_link_text'), + href: login_two_factor_options_path ) end diff --git a/spec/views/two_factor_authentication/personal_key_verification/show.html.slim_spec.rb b/spec/views/two_factor_authentication/personal_key_verification/show.html.slim_spec.rb index 35d81a637d0..c616b68c0a8 100644 --- a/spec/views/two_factor_authentication/personal_key_verification/show.html.slim_spec.rb +++ b/spec/views/two_factor_authentication/personal_key_verification/show.html.slim_spec.rb @@ -4,6 +4,7 @@ let(:user) { build_stubbed(:user, :signed_up) } before do + @presenter = TwoFactorAuthCode::PersonalKeyPresenter.new @personal_key_form = PersonalKeyForm.new(user) allow(view).to receive(:current_user).and_return(user) end diff --git a/spec/views/two_factor_authentication/totp_verification/show.html.slim_spec.rb b/spec/views/two_factor_authentication/totp_verification/show.html.slim_spec.rb index adece4b916a..ec5e6509535 100644 --- a/spec/views/two_factor_authentication/totp_verification/show.html.slim_spec.rb +++ b/spec/views/two_factor_authentication/totp_verification/show.html.slim_spec.rb @@ -33,19 +33,15 @@ it 'allows the user to fallback to SMS and voice' do expect(rendered).to have_link( - t('devise.two_factor_authentication.totp_fallback.sms_link_text'), - href: otp_send_path(otp_delivery_selection_form: { otp_delivery_preference: 'sms' }) - ) - expect(rendered).to have_link( - t('devise.two_factor_authentication.totp_fallback.voice_link_text'), - href: otp_send_path(otp_delivery_selection_form: { otp_delivery_preference: 'voice' }) + t('two_factor_authentication.login_options_link_text'), + href: login_two_factor_options_path ) end it 'provides an option to use a personal key' do expect(rendered).to have_link( - t('devise.two_factor_authentication.personal_key_fallback.link'), - href: login_two_factor_personal_key_path + t('two_factor_authentication.login_options_link_text'), + href: login_two_factor_options_path ) end diff --git a/spec/views/users/piv_cac_authentication_setup/new.html.slim_spec.rb b/spec/views/users/piv_cac_authentication_setup/new.html.slim_spec.rb index 0077477a2d3..11eb4f9f99a 100644 --- a/spec/views/users/piv_cac_authentication_setup/new.html.slim_spec.rb +++ b/spec/views/users/piv_cac_authentication_setup/new.html.slim_spec.rb @@ -24,7 +24,7 @@ render expect(rendered).to have_link( - t('devise.two_factor_authentication.two_factor_choice_cancel'), + t('two_factor_authentication.choose_another_option'), href: two_factor_options_path ) end diff --git a/spec/views/users/totp_setup/new.html.slim_spec.rb b/spec/views/users/totp_setup/new.html.slim_spec.rb index 84fe4756f0e..db224cb7e5d 100644 --- a/spec/views/users/totp_setup/new.html.slim_spec.rb +++ b/spec/views/users/totp_setup/new.html.slim_spec.rb @@ -41,7 +41,7 @@ render expect(rendered).to have_link( - t('devise.two_factor_authentication.two_factor_choice_cancel'), + t('two_factor_authentication.choose_another_option'), href: two_factor_options_path ) end