diff --git a/.reek b/.reek index f13c3d4d284..d83f1dc79b1 100644 --- a/.reek +++ b/.reek @@ -5,6 +5,7 @@ ControlParameter: - CustomDeviseFailureApp#i18n_message - OpenidConnectRedirector#initialize - NoRetryJobs#call + - PhoneFormatter#self.format DuplicateMethodCall: exclude: - ApplicationController#disable_caching @@ -13,7 +14,6 @@ DuplicateMethodCall: - MfaConfirmationController#handle_invalid_password - needs_to_confirm_email_change? - WorkerHealthChecker#status - - FileEncryptor#encrypt - UserFlowExporter#self.massage_assets - BasicAuthUrl#build - fallback_to_english @@ -45,6 +45,7 @@ FeatureEnvy: - Utf8Sanitizer#event_attributes - Utf8Sanitizer#remote_ip - Idv::Proofer#validate_vendors + - PersonalKeyGenerator#create_legacy_recovery_code InstanceVariableAssumption: exclude: - User @@ -57,13 +58,13 @@ ManualDispatch: - CloudhsmKeyGenerator#initialize_settings NestedIterators: exclude: - - FileEncryptor#encrypt - UserFlowExporter#self.massage_html - TwilioService#sanitize_phone_number - ServiceProviderSeeder#run NilCheck: enabled: false LongParameterList: + max_params: 4 exclude: - IdentityLinker#optional_attributes - Idv::ProoferJob#perform @@ -92,7 +93,6 @@ TooManyStatements: - OpenidConnect::AuthorizationController#store_request - SamlIdpAuthConcern#store_saml_request - Users::PhoneConfirmationController - - FileEncryptor#encrypt - UserFlowExporter#self.massage_assets - UserFlowExporter#self.massage_html - UserFlowExporter#self.run @@ -115,6 +115,8 @@ TooManyMethods: - Idv::SessionsController - ServiceProviderSessionDecorator - SessionDecorator + - HolidayService + - PhoneDeliveryPresenter UncommunicativeMethodName: exclude: - PhoneConfirmationFlow @@ -127,6 +129,10 @@ UncommunicativeModuleName: - X509::Attribute - X509::Attributes - X509::SessionStore +UnusedParameters: + exclude: + - SmsOtpSenderJob#perform + - VoiceOtpSenderJob#perform UnusedPrivateMethod: exclude: - ApplicationController diff --git a/.rubocop.yml b/.rubocop.yml index da4f8abaa80..9cb21bca7e5 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -67,6 +67,7 @@ Metrics/ClassLength: - app/decorators/user_decorator.rb - app/services/analytics.rb - app/services/idv/session.rb + - app/presenters/two_factor_auth_code/phone_delivery_presenter.rb Metrics/LineLength: Description: Limit lines to 100 characters. diff --git a/Gemfile b/Gemfile index ba40671bc09..f342eca5aca 100644 --- a/Gemfile +++ b/Gemfile @@ -30,7 +30,6 @@ gem 'net-sftp' gem 'newrelic_rpm' gem 'pg' gem 'phonelib' -gem 'phony_rails' gem 'pkcs11' gem 'premailer-rails' gem 'proofer', github: '18F/identity-proofer-gem', tag: 'v2.5.0' @@ -62,7 +61,6 @@ gem 'typhoeus' gem 'uglifier', '~> 3.2' gem 'valid_email' gem 'webpacker', '~> 3.4' -gem 'whenever', require: false gem 'xml-simple' gem 'xmlenc', '~> 0.6' gem 'zxcvbn-js' diff --git a/Gemfile.lock b/Gemfile.lock index 7bf3ea4dc70..2aca7ec3c71 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -195,7 +195,6 @@ GEM chromedriver-helper (1.2.0) archive-zip (~> 0.10) nokogiri (~> 1.8) - chronic (0.10.2) chunky_png (1.3.8) codeclimate-engine-rb (0.4.1) virtus (~> 1.0) @@ -388,10 +387,6 @@ GEM ast (~> 2.4.0) pg (1.0.0) phonelib (0.6.21) - phony (2.15.44) - phony_rails (0.14.6) - activesupport (>= 3.0) - phony (> 2.15) pkcs11 (0.2.7) powerpack (0.1.1) premailer (1.11.1) @@ -582,7 +577,7 @@ GEM slim (~> 3.0) sysexits (~> 1.1) socksify (1.7.1) - sprockets (3.7.1) + sprockets (3.7.2) concurrent-ruby (~> 1.0) rack (> 1, < 3) sprockets-rails (3.2.1) @@ -651,8 +646,6 @@ GEM websocket-driver (0.6.5) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.3) - whenever (0.10.0) - chronic (>= 0.6.3) xml-simple (1.1.5) xmldsig (0.6.6) nokogiri (>= 1.6.8, < 2.0.0) @@ -723,7 +716,6 @@ DEPENDENCIES overcommit pg phonelib - phony_rails pkcs11 premailer-rails proofer! @@ -770,7 +762,6 @@ DEPENDENCIES valid_email webmock webpacker (~> 3.4) - whenever xml-simple xmlenc (~> 0.6) zonebie @@ -780,4 +771,4 @@ RUBY VERSION ruby 2.5.1p57 BUNDLED WITH - 1.16.1 + 1.16.2 diff --git a/app/assets/images/sp-logos/mycbp.png b/app/assets/images/sp-logos/mycbp.png new file mode 100644 index 00000000000..fcb8aac4b63 Binary files /dev/null and b/app/assets/images/sp-logos/mycbp.png differ diff --git a/app/assets/images/sp-logos/usaid.png b/app/assets/images/sp-logos/usaid.png index be27e210b75..2ee4257e019 100644 Binary files a/app/assets/images/sp-logos/usaid.png and b/app/assets/images/sp-logos/usaid.png differ diff --git a/app/assets/stylesheets/components/_background.scss b/app/assets/stylesheets/components/_background.scss index 9c26230e10c..1895ecfd6ef 100644 --- a/app/assets/stylesheets/components/_background.scss +++ b/app/assets/stylesheets/components/_background.scss @@ -1,6 +1,7 @@ .bg-gray-lighter { background-color: $gray-lighter; } .bg-light-blue { background-color: $blue-light; } .bg-lightest-blue { background-color: $blue-lightest; } +.bg-lightest-red { background-color: $red-lightest; } @media #{$breakpoint-sm} { .sm-bg-light-blue { background-color: $blue-light; } diff --git a/app/assets/stylesheets/email.css.scss b/app/assets/stylesheets/email.css.scss index a6f37cb8389..d69531c8002 100644 --- a/app/assets/stylesheets/email.css.scss +++ b/app/assets/stylesheets/email.css.scss @@ -38,6 +38,28 @@ h4 { .button.large.expanded table a { padding: 20px 0; } +.button.expanded.large .btn-warn-bkg { + background-color: $white; + border: 0; + + &:hover { + background-color: $white; + } +} + +.btn-warn-bkg .btn-warn { + background-color: $red-lightest; + border: 2px solid $red; + border-radius: 8px; + color: $gray; + padding: 10px; + width: 50%; +} + +.half { + width: 50%; +} + .footer { background: $secondary-color; diff --git a/app/assets/stylesheets/variables/_colors.scss b/app/assets/stylesheets/variables/_colors.scss index f32744a6e76..6fb6022d46e 100644 --- a/app/assets/stylesheets/variables/_colors.scss +++ b/app/assets/stylesheets/variables/_colors.scss @@ -22,3 +22,5 @@ $gray-light: #ddd !default; $gray-lighter: #fafafa !default; $black: #111 !default; $pink: #eb4d67 !default; +$red: #f00 !default; +$red-lightest: #fff7f8 !default; diff --git a/app/controllers/account_reset/cancel_controller.rb b/app/controllers/account_reset/cancel_controller.rb new file mode 100644 index 00000000000..7c12994e22e --- /dev/null +++ b/app/controllers/account_reset/cancel_controller.rb @@ -0,0 +1,25 @@ +module AccountReset + class CancelController < ApplicationController + def cancel + if AccountResetService.cancel_request(params[:token]) + handle_success + else + handle_failure + end + redirect_to root_url + end + + private + + def handle_success + analytics.track_event(Analytics::ACCOUNT_RESET, event: :cancel, token_valid: true) + sign_out if current_user + flash[:success] = t('devise.two_factor_authentication.account_reset.successful_cancel') + end + + def handle_failure + return if params[:token].blank? + analytics.track_event(Analytics::ACCOUNT_RESET, event: :cancel, token_valid: false) + end + end +end diff --git a/app/controllers/account_reset/confirm_delete_account_controller.rb b/app/controllers/account_reset/confirm_delete_account_controller.rb new file mode 100644 index 00000000000..73f313257f5 --- /dev/null +++ b/app/controllers/account_reset/confirm_delete_account_controller.rb @@ -0,0 +1,12 @@ +module AccountReset + class ConfirmDeleteAccountController < ApplicationController + def show + email = flash[:email] + if email.blank? + redirect_to root_url + else + render :show, locals: { email: email } + end + end + end +end diff --git a/app/controllers/account_reset/confirm_request_controller.rb b/app/controllers/account_reset/confirm_request_controller.rb new file mode 100644 index 00000000000..d6172b22c0b --- /dev/null +++ b/app/controllers/account_reset/confirm_request_controller.rb @@ -0,0 +1,12 @@ +module AccountReset + class ConfirmRequestController < ApplicationController + def show + email = flash[:email] + if email.blank? + redirect_to root_url + else + render :show, locals: { email: email } + end + end + end +end diff --git a/app/controllers/account_reset/delete_account_controller.rb b/app/controllers/account_reset/delete_account_controller.rb new file mode 100644 index 00000000000..b97d9c5a2b3 --- /dev/null +++ b/app/controllers/account_reset/delete_account_controller.rb @@ -0,0 +1,48 @@ +module AccountReset + class DeleteAccountController < ApplicationController + before_action :check_feature_enabled + before_action :prevent_parameter_leak, only: :show + before_action :check_granted_token + + def show; end + + def delete + analytics.track_event(Analytics::ACCOUNT_RESET, event: :delete, token_valid: true) + email = reset_session_and_set_email + UserMailer.account_reset_complete(email).deliver_later + redirect_to account_reset_confirm_delete_account_url + end + + private + + 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 + email = user.email + user.destroy! + sign_out + flash[:email] = email + end + + def check_granted_token + @account_reset_request = AccountResetRequest.from_valid_granted_token(session[:granted_token]) + return if @account_reset_request + analytics.track_event(Analytics::ACCOUNT_RESET, event: :delete, token_valid: false) + redirect_to root_url + end + + def prevent_parameter_leak + token = params[:token] + return if token.blank? + if AccountResetRequest.find_by(granted_token: token)&.granted_token_valid? + session[:granted_token] = token + redirect_to url_for + else + redirect_to root_url + end + end + end +end diff --git a/app/controllers/account_reset/report_fraud_controller.rb b/app/controllers/account_reset/report_fraud_controller.rb new file mode 100644 index 00000000000..f3763b29d18 --- /dev/null +++ b/app/controllers/account_reset/report_fraud_controller.rb @@ -0,0 +1,25 @@ +module AccountReset + class ReportFraudController < ApplicationController + def update + if AccountResetService.report_fraud(params[:token]) + handle_success + else + handle_failure + end + redirect_to root_url + end + + private + + def handle_success + analytics.track_event(Analytics::ACCOUNT_RESET, event: :fraud, token_valid: true) + sign_out if current_user + flash[:success] = t('devise.two_factor_authentication.account_reset.successful_cancel') + end + + def handle_failure + return if params[:token].blank? + analytics.track_event(Analytics::ACCOUNT_RESET, event: :fraud, token_valid: false) + end + end +end diff --git a/app/controllers/account_reset/request_controller.rb b/app/controllers/account_reset/request_controller.rb new file mode 100644 index 00000000000..0b99bc2ace5 --- /dev/null +++ b/app/controllers/account_reset/request_controller.rb @@ -0,0 +1,48 @@ +module AccountReset + class RequestController < ApplicationController + include TwoFactorAuthenticatable + + before_action :check_account_reset_enabled + before_action :confirm_two_factor_enabled + + def show; end + + def create + analytics.track_event(Analytics::ACCOUNT_RESET, event: :request) + create_request + send_notifications + reset_session_with_email + redirect_to account_reset_confirm_request_url + end + + private + + def check_account_reset_enabled + redirect_to root_url unless FeatureManagement.account_reset_enabled? + end + + def reset_session_with_email + email = current_user.email + sign_out + flash[:email] = email + end + + def send_notifications + SmsAccountResetNotifierJob.perform_now( + phone: current_user.phone, + cancel_token: current_user.account_reset_request.request_token + ) + UserMailer.account_reset_request(current_user).deliver_later + end + + def create_request + AccountResetService.new(current_user).create_request + end + + def confirm_two_factor_enabled + return if current_user.two_factor_enabled? + + redirect_to phone_setup_url + end + end +end diff --git a/app/controllers/account_reset/send_notifications_controller.rb b/app/controllers/account_reset/send_notifications_controller.rb new file mode 100644 index 00000000000..5626674c33e --- /dev/null +++ b/app/controllers/account_reset/send_notifications_controller.rb @@ -0,0 +1,23 @@ +module AccountReset + class SendNotificationsController < ApplicationController + skip_before_action :verify_authenticity_token + before_action :authorize + + def update + count = AccountResetService.grant_tokens_and_send_notifications + analytics.track_event(Analytics::ACCOUNT_RESET, event: :notifications, count: count) + render plain: 'ok' + end + + private + + def authorize + return if auth_token == Figaro.env.account_reset_auth_token + head :unauthorized + end + + def auth_token + request.headers['X-API-AUTH-TOKEN'] + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index ab0d3cc5335..d852a58b905 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -96,7 +96,11 @@ def redirect_on_timeout unless current_user flash[:notice] = t('notices.session_cleared', minutes: Figaro.env.session_timeout_in_minutes) end - redirect_to url_for(permitted_timeout_params) + begin + redirect_to url_for(permitted_timeout_params) + rescue ActionController::UrlGenerationError # binary data in params cause redirect to throw this + head :bad_request + end end def permitted_timeout_params diff --git a/app/controllers/concerns/two_factor_authenticatable.rb b/app/controllers/concerns/two_factor_authenticatable.rb index a1b64bce45b..e8b7b909dfe 100644 --- a/app/controllers/concerns/two_factor_authenticatable.rb +++ b/app/controllers/concerns/two_factor_authenticatable.rb @@ -244,10 +244,15 @@ def phone_view_data unconfirmed_phone: unconfirmed_phone?, totp_enabled: current_user.totp_enabled?, remember_device_available: !idv_context?, + account_reset_token: account_reset_token, }.merge(generic_data) end # rubocop:enable MethodLength + def account_reset_token + current_user&.account_reset_request&.request_token + end + def authenticator_view_data { two_factor_authentication_method: two_factor_authentication_method, diff --git a/app/controllers/idv/otp_delivery_method_controller.rb b/app/controllers/idv/otp_delivery_method_controller.rb index edcc275fec4..8978669f80c 100644 --- a/app/controllers/idv/otp_delivery_method_controller.rb +++ b/app/controllers/idv/otp_delivery_method_controller.rb @@ -14,7 +14,7 @@ def create result = @otp_delivery_selection_form.submit(otp_delivery_selection_params) if result.success? prompt_to_confirm_phone( - phone: idv_session.params[:phone], + phone: @otp_delivery_selection_form.phone, context: 'idv', selected_delivery_method: @otp_delivery_selection_form.otp_delivery_preference ) diff --git a/app/controllers/sign_up/passwords_controller.rb b/app/controllers/sign_up/passwords_controller.rb index 74e1554f16c..1e3eef67306 100644 --- a/app/controllers/sign_up/passwords_controller.rb +++ b/app/controllers/sign_up/passwords_controller.rb @@ -46,10 +46,12 @@ def permitted_params def process_successful_password_creation @user.confirm + password = permitted_params[:password] UpdateUser.new( user: @user, - attributes: { reset_requested_at: nil, password: permitted_params[:password] } + attributes: { reset_requested_at: nil, password: password } ).call + PasswordMetricsIncrementer.new(password).increment_password_metrics sign_in_and_redirect_user end diff --git a/app/controllers/users/reset_passwords_controller.rb b/app/controllers/users/reset_passwords_controller.rb index 77777f833b1..a73f775d188 100644 --- a/app/controllers/users/reset_passwords_controller.rb +++ b/app/controllers/users/reset_passwords_controller.rb @@ -137,10 +137,15 @@ def update_user attributes = { password: user_params[:password] } attributes[:confirmed_at] = Time.zone.now unless resource.confirmed? UpdateUser.new(user: resource, attributes: attributes).call + increment_password_metrics mark_profile_inactive end + def increment_password_metrics + PasswordMetricsIncrementer.new(user_params[:password]).increment_password_metrics + end + def mark_profile_inactive resource.active_profile&.deactivate(:password_reset) end diff --git a/app/controllers/users/two_factor_authentication_controller.rb b/app/controllers/users/two_factor_authentication_controller.rb index db1a17a40c7..2c93a0fd861 100644 --- a/app/controllers/users/two_factor_authentication_controller.rb +++ b/app/controllers/users/two_factor_authentication_controller.rb @@ -5,12 +5,12 @@ class TwoFactorAuthenticationController < ApplicationController before_action :check_remember_device_preference def show - if current_user.totp_enabled? + if current_user.piv_cac_enabled? + redirect_to login_two_factor_piv_cac_url + elsif current_user.totp_enabled? redirect_to login_two_factor_authenticator_url elsif current_user.phone_enabled? validate_otp_delivery_preference_and_send_code - elsif current_user.piv_cac_enabled? - redirect_to login_two_factor_piv_cac_url else redirect_to two_factor_options_url end @@ -25,7 +25,7 @@ def send_code else handle_invalid_otp_delivery_preference(result) end - rescue Twilio::REST::RestError => exception + rescue Twilio::REST::RestError, PhoneVerification::VerifyError => exception invalid_phone_number(exception) end @@ -51,29 +51,21 @@ def handle_invalid_otp_delivery_preference(result) end def invalid_phone_number(exception) + code = exception.code analytics.track_event( - Analytics::TWILIO_PHONE_VALIDATION_FAILED, error: exception.message, code: exception.code + Analytics::TWILIO_PHONE_VALIDATION_FAILED, error: exception.message, code: code ) - flash_error_for_exception(exception) + flash[:error] = error_message(code) redirect_back(fallback_location: account_url) end - # rubocop:disable Metrics/MethodLength - def flash_error_for_exception(exception) - flash[:error] = case exception.code - when TwilioService::SMS_ERROR_CODE - t('errors.messages.invalid_sms_number') - when TwilioService::INVALID_ERROR_CODE - t('errors.messages.invalid_phone_number') - when TwilioService::INVALID_CALLING_AREA_ERROR_CODE - t('errors.messages.invalid_calling_area') - when TwilioService::INVALID_VOICE_NUMBER_ERROR_CODE - t('errors.messages.invalid_voice_number') - else - t('errors.messages.otp_failed') - end + def error_message(code) + twilio_errors.fetch(code, t('errors.messages.otp_failed')) + end + + def twilio_errors + TwilioErrors::REST_ERRORS.merge(TwilioErrors::VERIFY_ERRORS) end - # rubocop:enable Metrics/MethodLength def otp_delivery_selection_form OtpDeliverySelectionForm.new(current_user, phone_to_deliver_to, context) @@ -105,7 +97,8 @@ def send_user_otp(method) job = "#{method.capitalize}OtpSenderJob".constantize job_priority = confirmation_context? ? :perform_now : :perform_later job.send(job_priority, code: current_user.direct_otp, phone: phone_to_deliver_to, - otp_created_at: current_user.direct_otp_sent_at.to_s) + otp_created_at: current_user.direct_otp_sent_at.to_s, + locale: user_locale) end def user_selected_otp_delivery_preference @@ -122,6 +115,11 @@ def phone_to_deliver_to user_session[:unconfirmed_phone] end + def user_locale + available_locales = PhoneVerification::AVAILABLE_LOCALES + http_accept_language.language_region_compatible_from(available_locales) + end + def otp_rate_limiter @_otp_rate_limited ||= OtpRateLimiter.new(phone: phone_to_deliver_to, user: current_user) end diff --git a/app/controllers/usps_upload_controller.rb b/app/controllers/usps_upload_controller.rb new file mode 100644 index 00000000000..09cd3e1404f --- /dev/null +++ b/app/controllers/usps_upload_controller.rb @@ -0,0 +1,27 @@ +class UspsUploadController < ApplicationController + def create + authorize do + UspsUploader.new.run unless HolidayService.observed_holiday?(today) + render plain: 'ok' + end + end + + private + + def authorize + # Check for empty to make sure that the token is configured + if authorization_token && authorization_token == Figaro.env.usps_upload_token + yield + else + head :unauthorized + end + end + + def authorization_token + request.headers['X-USPS-UPLOAD-TOKEN'] + end + + def today + Time.zone.today + end +end diff --git a/app/errors/twilio_errors.rb b/app/errors/twilio_errors.rb new file mode 100644 index 00000000000..e2f3d37f763 --- /dev/null +++ b/app/errors/twilio_errors.rb @@ -0,0 +1,18 @@ +module TwilioErrors + REST_ERRORS = { + 13_224 => I18n.t('errors.messages.invalid_voice_number'), + 21_211 => I18n.t('errors.messages.invalid_phone_number'), + 21_215 => I18n.t('errors.messages.invalid_calling_area'), + 21_614 => I18n.t('errors.messages.invalid_sms_number'), + }.freeze + + VERIFY_ERRORS = { + 60_033 => I18n.t('errors.messages.invalid_phone_number'), + # invalid country code + 60_078 => I18n.t('errors.messages.invalid_phone_number'), + # cannot send sms to landline + 60_082 => I18n.t('errors.messages.invalid_sms_number'), + # phone number not provisioned with any carrier + 60_083 => I18n.t('errors.messages.invalid_phone_number'), + }.freeze +end diff --git a/app/forms/idv/phone_form.rb b/app/forms/idv/phone_form.rb index 8d22d368c69..67678c29f25 100644 --- a/app/forms/idv/phone_form.rb +++ b/app/forms/idv/phone_form.rb @@ -1,12 +1,11 @@ module Idv class PhoneForm include ActiveModel::Model - include FormPhoneValidator attr_reader :idv_params, :user, :phone attr_accessor :international_code - validate :phone_has_us_country_code + validate :phone_is_a_valid_us_number def initialize(idv_params, user) @idv_params = idv_params @@ -16,9 +15,8 @@ def initialize(idv_params, user) end def submit(params) - formatted_phone = PhoneFormatter.new.format(params[:phone]) + formatted_phone = PhoneFormatter.format(params[:phone]) self.phone = formatted_phone - success = valid? update_idv_params(formatted_phone) if success @@ -30,16 +28,14 @@ def submit(params) attr_writer :phone def initial_phone_value(phone) - formatted_phone = PhoneFormatter.new.format( - phone, country_code: PhoneFormatter::DEFAULT_COUNTRY - ) - return unless Phony.plausible? formatted_phone + formatted_phone = PhoneFormatter.format(phone) + return unless Phonelib.valid_for_country?(formatted_phone, 'US') + self.phone = formatted_phone end - def phone_has_us_country_code - country_code = Phonelib.parse(phone).country_code || '1' - return if country_code == '1' + def phone_is_a_valid_us_number + return if Phonelib.valid_for_country?(phone, 'US') errors.add(:phone, :must_have_us_country_code) end @@ -48,8 +44,12 @@ def update_idv_params(phone) normalized_phone = phone.gsub(/\D/, '')[1..-1] idv_params[:phone] = normalized_phone - return idv_params[:phone_confirmed_at] = nil unless phone == user.phone + return idv_params[:phone_confirmed_at] = nil unless phone == formatted_user_phone idv_params[:phone_confirmed_at] = user.phone_confirmed_at end + + def formatted_user_phone + Phonelib.parse(user.phone).international + end end end diff --git a/app/forms/otp_delivery_selection_form.rb b/app/forms/otp_delivery_selection_form.rb index 40cb805ac65..4971439a99f 100644 --- a/app/forms/otp_delivery_selection_form.rb +++ b/app/forms/otp_delivery_selection_form.rb @@ -2,14 +2,14 @@ class OtpDeliverySelectionForm include ActiveModel::Model include OtpDeliveryPreferenceValidator - attr_reader :otp_delivery_preference + attr_reader :otp_delivery_preference, :phone validates :otp_delivery_preference, inclusion: { in: %w[sms voice] } validates :phone, presence: true def initialize(user, phone_to_deliver_to, context) @user = user - @phone = phone_to_deliver_to + @phone = PhoneFormatter.format(phone_to_deliver_to) @context = context end @@ -29,7 +29,7 @@ def submit(params) attr_writer :otp_delivery_preference attr_accessor :resend - attr_reader :success, :user, :phone, :context + attr_reader :success, :user, :context def change_otp_delivery_preference_to_sms user_attributes = { otp_delivery_preference: 'sms' } diff --git a/app/forms/update_user_password_form.rb b/app/forms/update_user_password_form.rb index 5d648ff4440..416b7663c72 100644 --- a/app/forms/update_user_password_form.rb +++ b/app/forms/update_user_password_form.rb @@ -24,6 +24,7 @@ def process_valid_submission update_user_password email_user_about_password_change encrypt_user_profile_if_active + increment_password_metrics end def update_user_password @@ -42,6 +43,10 @@ def encrypt_user_profile_if_active encryptor.call end + def increment_password_metrics + PasswordMetricsIncrementer.new(password).increment_password_metrics + end + def encryptor @_encryptor ||= ActiveProfileEncryptor.new(user, user_session, password) end diff --git a/app/forms/user_phone_form.rb b/app/forms/user_phone_form.rb index fca174eebc3..2d8c18f36e2 100644 --- a/app/forms/user_phone_form.rb +++ b/app/forms/user_phone_form.rb @@ -27,7 +27,7 @@ def submit(params) end def phone_changed? - user.phone != phone + formatted_user_phone != phone end private @@ -43,7 +43,7 @@ def extra_analytics_attributes def ingest_submitted_params(params) self.international_code = params[:international_code] self.submitted_phone = params[:phone] - self.phone = PhoneFormatter.new.format( + self.phone = PhoneFormatter.format( submitted_phone, country_code: international_code ) @@ -61,4 +61,8 @@ def update_otp_delivery_preference_for_user user_attributes = { otp_delivery_preference: otp_delivery_preference } UpdateUser.new(user: user, attributes: user_attributes).call end + + def formatted_user_phone + Phonelib.parse(user.phone).international + end end diff --git a/app/forms/user_piv_cac_setup_form.rb b/app/forms/user_piv_cac_setup_form.rb index f482b4613de..cad669b7713 100644 --- a/app/forms/user_piv_cac_setup_form.rb +++ b/app/forms/user_piv_cac_setup_form.rb @@ -59,7 +59,7 @@ def token_has_correct_nonce def piv_cac_not_already_associated self.x509_dn_uuid = @data['uuid'] - self.x509_dn = @data['dn'] + self.x509_dn = @data['subject'] if User.find_by(x509_dn_uuid: x509_dn_uuid) self.error_type = 'piv_cac.already_associated' false diff --git a/app/forms/user_piv_cac_verification_form.rb b/app/forms/user_piv_cac_verification_form.rb index afa1ac20fca..bba81df8131 100644 --- a/app/forms/user_piv_cac_verification_form.rb +++ b/app/forms/user_piv_cac_verification_form.rb @@ -44,7 +44,7 @@ def not_error_token false else self.x509_dn_uuid = @data['uuid'] - self.x509_dn = @data['dn'] + self.x509_dn = @data['subject'] true end end diff --git a/app/jobs/sms_account_reset_notifier_job.rb b/app/jobs/sms_account_reset_notifier_job.rb new file mode 100644 index 00000000000..f4584572f27 --- /dev/null +++ b/app/jobs/sms_account_reset_notifier_job.rb @@ -0,0 +1,15 @@ +class SmsAccountResetNotifierJob < ApplicationJob + queue_as :sms + include Rails.application.routes.url_helpers + + def perform(phone:, cancel_token:) + TwilioService.new.send_sms( + to: phone, + body: I18n.t( + 'jobs.sms_account_reset_notifier_job.message', + app: APP_NAME, + cancel_link: account_reset_cancel_url(token: cancel_token) + ) + ) + end +end diff --git a/app/jobs/sms_otp_sender_job.rb b/app/jobs/sms_otp_sender_job.rb index b7a959e7f7b..073363f2529 100644 --- a/app/jobs/sms_otp_sender_job.rb +++ b/app/jobs/sms_otp_sender_job.rb @@ -1,19 +1,30 @@ class SmsOtpSenderJob < ApplicationJob queue_as :sms - def perform(code:, phone:, otp_created_at:) - send_otp(TwilioService.new, code, phone) if otp_valid?(otp_created_at) + # rubocop:disable Lint/UnusedMethodArgument + def perform(code:, phone:, otp_created_at:, locale: nil) + return unless otp_valid?(otp_created_at) + + if us_number? + send_sms_via_twilio_rest_api + else + send_sms_via_twilio_verify_api(locale) + end end + # rubocop:enable Lint/UnusedMethodArgument private - def otp_valid?(otp_created_at) - time_zone = Time.zone - time_zone.now < time_zone.parse(otp_created_at) + Devise.direct_otp_valid_for + def code + arguments[0][:code] end - def send_otp(twilio_service, code, phone) - twilio_service.send_sms( + def phone + arguments[0][:phone] + end + + def send_sms_via_twilio_rest_api + TwilioService.new.send_sms( to: phone, body: I18n.t( 'jobs.sms_otp_sender_job.message', @@ -22,6 +33,19 @@ def send_otp(twilio_service, code, phone) ) end + 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' + end + + def otp_valid?(otp_created_at) + time_zone = Time.zone + time_zone.now < time_zone.parse(otp_created_at) + Devise.direct_otp_valid_for + end + def otp_valid_for_minutes Devise.direct_otp_valid_for.to_i / 60 end diff --git a/app/jobs/voice_otp_sender_job.rb b/app/jobs/voice_otp_sender_job.rb index eb451232802..b9d17f696cb 100644 --- a/app/jobs/voice_otp_sender_job.rb +++ b/app/jobs/voice_otp_sender_job.rb @@ -4,9 +4,15 @@ class VoiceOtpSenderJob < ApplicationJob queue_as :voice - def perform(code:, phone:, otp_created_at:) + # rubocop:disable Lint/UnusedMethodArgument + # locale is an argument used for the Twilio/Authy Verify service, which uses + # a localized message for delivering OTPs via SMS and Voice. As of this + # 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) end + # rubocop:enable Lint/UnusedMethodArgument private diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 330388e7f38..72ff09e8954 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -26,4 +26,20 @@ def account_does_not_exist(email, request_id) @sign_up_email_url = sign_up_email_url(request_id: request_id, locale: locale_url_param) mail(to: email, subject: t('user_mailer.account_does_not_exist.subject')) end + + def account_reset_request(user) + account_reset = user.account_reset_request + @token = account_reset&.request_token + mail(to: user.email, subject: t('user_mailer.account_reset_request.subject')) + end + + def account_reset_granted(user, account_reset) + @token = account_reset&.request_token + @granted_token = account_reset&.granted_token + mail(to: user.email, subject: t('user_mailer.account_reset_granted.subject')) + end + + def account_reset_complete(email) + mail(to: email, subject: t('user_mailer.account_reset_complete.subject')) + end end diff --git a/app/models/account_reset_request.rb b/app/models/account_reset_request.rb new file mode 100644 index 00000000000..8d1452f4ff9 --- /dev/null +++ b/app/models/account_reset_request.rb @@ -0,0 +1,13 @@ +class AccountResetRequest < ApplicationRecord + belongs_to :user + + def self.from_valid_granted_token(granted_token) + account_reset = AccountResetRequest.find_by(granted_token: granted_token) + account_reset&.granted_token_valid? ? account_reset : nil + end + + def granted_token_valid? + !granted_token.nil? && + ((Time.zone.now - granted_at) <= Figaro.env.account_reset_token_valid_for_days.to_i.days) + end +end diff --git a/app/models/concerns/user_access_key_overrides.rb b/app/models/concerns/user_access_key_overrides.rb index 8b05383ef4b..a9ec587f14b 100644 --- a/app/models/concerns/user_access_key_overrides.rb +++ b/app/models/concerns/user_access_key_overrides.rb @@ -17,10 +17,7 @@ def valid_password?(password) def password=(new_password) @password = new_password return if @password.blank? - digest = Encryption::PasswordVerifier.digest(@password) - self.encrypted_password_digest = digest.to_s - # Until we drop the old columns, still write to them so that we can rollback - write_legacy_password_attributes(digest) + self.encrypted_password_digest = Encryption::PasswordVerifier.digest(@password).to_s end # This is a devise method, which we are overriding. This should not be removed @@ -35,13 +32,6 @@ def authenticatable_salt private - def write_legacy_password_attributes(digest) - self.encrypted_password = digest.encrypted_password - self.encryption_key = digest.encryption_key - self.password_salt = digest.password_salt - self.password_cost = digest.password_cost - end - def log_password_verification_failure metadata = { event: 'Failure to validate password', diff --git a/app/models/password_metric.rb b/app/models/password_metric.rb new file mode 100644 index 00000000000..2b2b496b3b7 --- /dev/null +++ b/app/models/password_metric.rb @@ -0,0 +1,28 @@ +class PasswordMetric < ApplicationRecord + enum metric: %i[length guesses_log10] + + def self.increment(metric, value) + create_row_for_metric_category(metric, value) + query = <<-SQL + UPDATE password_metrics + SET count = count + 1 + WHERE metric = ? AND value = ? + SQL + sanitized_query = sanitize_sql_array([query, metrics[metric.to_s], value]) + connection.execute(sanitized_query) + end + + private_class_method def self.create_row_for_metric_category(metric, value) + metric_key = metrics[metric.to_s] + # Insert a row with the count equal to 0 if a row does not already exist + query = <<-SQL + INSERT INTO password_metrics (metric, value, count) + SELECT ?, ?, 0 + WHERE NOT EXISTS ( + SELECT id FROM password_metrics WHERE metric = ? AND value = ? + ) + SQL + sanitized_query = sanitize_sql_array([query, metric_key, value, metric_key, value]) + connection.execute(sanitized_query) + end +end diff --git a/app/models/service_provider_request.rb b/app/models/service_provider_request.rb index 8b5e32f218f..e5c1ec60e36 100644 --- a/app/models/service_provider_request.rb +++ b/app/models/service_provider_request.rb @@ -1,5 +1,7 @@ class ServiceProviderRequest < ApplicationRecord def self.from_uuid(uuid) find_by(uuid: uuid) || NullServiceProviderRequest.new + rescue ArgumentError # a null byte in the uuid will raise this + NullServiceProviderRequest.new end end diff --git a/app/models/user.rb b/app/models/user.rb index 5fec213a7a7..3208419e1d5 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -35,6 +35,7 @@ class User < ApplicationRecord has_many :agency_identities, dependent: :destroy has_many :profiles, dependent: :destroy has_many :events, dependent: :destroy + has_one :account_reset_request, dependent: :destroy validates :x509_dn_uuid, uniqueness: true, allow_nil: true diff --git a/app/models/usps_confirmation.rb b/app/models/usps_confirmation.rb index c232c3cc32d..34f1b5914b8 100644 --- a/app/models/usps_confirmation.rb +++ b/app/models/usps_confirmation.rb @@ -1,5 +1,20 @@ class UspsConfirmation < ApplicationRecord - def decrypted_entry - UspsConfirmationEntry.new_from_encrypted(entry) + # Store the pii as encrypted json + def entry=(entry_hash) + self[:entry] = encryptor.encrypt(entry_hash.to_json) + end + + # Read the pii as a decrypted hash + def entry + JSON.parse(encryptor.decrypt(self[:entry]), symbolize_names: true) + end + + private + + def encryptor + # This currently uses the SessionEncryptor, which is meant to be used to + # encrypt the session. When this code is changed to integrate a new mail + # vendor we should create a purpose built encryptor for that vendor + Encryption::Encryptors::SessionEncryptor.new end end diff --git a/app/presenters/idv/otp_delivery_method_presenter.rb b/app/presenters/idv/otp_delivery_method_presenter.rb index 6d864fa48da..7cdf0f8802a 100644 --- a/app/presenters/idv/otp_delivery_method_presenter.rb +++ b/app/presenters/idv/otp_delivery_method_presenter.rb @@ -5,7 +5,7 @@ class OtpDeliveryMethodPresenter delegate :sms_only?, to: :phone_number_capabilites def initialize(phone) - @phone = PhoneFormatter.new.format(phone) + @phone = PhoneFormatter.format(phone) end def phone_unsupported_message 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 6117d9a4c45..47c4708c4a1 100644 --- a/app/presenters/two_factor_auth_code/phone_delivery_presenter.rb +++ b/app/presenters/two_factor_auth_code/phone_delivery_presenter.rb @@ -21,6 +21,7 @@ def fallback_links update_phone_link, piv_cac_option, personal_key_link, + account_reset_link, ].compact end @@ -43,6 +44,7 @@ def cancel_link :phone_number, :unconfirmed_phone, :otp_delivery_preference, + :account_reset_token, :confirmation_for_phone_change, :voice_otp_delivery_unsupported, :confirmation_for_idv @@ -71,6 +73,23 @@ def update_phone_link 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 diff --git a/app/services/account_reset_service.rb b/app/services/account_reset_service.rb new file mode 100644 index 00000000000..5bb429cb16d --- /dev/null +++ b/app/services/account_reset_service.rb @@ -0,0 +1,83 @@ +class AccountResetService + def initialize(user) + @user_id = user.id + end + + def create_request + account_reset = account_reset_request + account_reset.update(request_token: SecureRandom.uuid, + requested_at: Time.zone.now, + cancelled_at: nil, + granted_at: nil, + granted_token: nil) + end + + def self.cancel_request(token) + account_reset = token.blank? ? nil : AccountResetRequest.find_by(request_token: token) + return false unless account_reset + account_reset.update(cancelled_at: Time.zone.now, + request_token: nil, + granted_token: nil) + end + + def self.report_fraud(token) + account_reset = token.blank? ? nil : AccountResetRequest.find_by(request_token: token) + return false unless account_reset + now = Time.zone.now + account_reset.update(cancelled_at: now, + reported_fraud_at: now, + request_token: nil, + granted_token: nil) + end + + def grant_request + token = SecureRandom.uuid + arr = AccountResetRequest.find_by(user_id: @user_id) + arr.with_lock do + return false if arr.granted_token_valid? + account_reset_request.update(granted_at: Time.zone.now, + granted_token: token) + end + true + end + + def self.grant_tokens_and_send_notifications + users_sql = <<~SQL + cancelled_at IS NULL AND + granted_at IS NULL AND + requested_at < :tvalue AND + request_token IS NOT NULL AND + granted_token IS NULL + SQL + send_notifications_with_sql(users_sql) + end + + def self.send_notifications_with_sql(users_sql) + notifications_sent = 0 + AccountResetRequest.where( + users_sql, tvalue: Time.zone.now - Figaro.env.account_reset_wait_period_days.to_i.days + ).order('requested_at ASC').each do |arr| + notifications_sent += 1 if reset_and_notify(arr) + end + notifications_sent + end + private_class_method :send_notifications_with_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 + private_class_method :reset_and_notify + + private + + def account_reset_request + AccountResetRequest.find_or_create_by(user_id: @user_id) + end +end diff --git a/app/services/analytics.rb b/app/services/analytics.rb index 4bbd34ba5fe..afca14abc63 100644 --- a/app/services/analytics.rb +++ b/app/services/analytics.rb @@ -54,6 +54,7 @@ def browser end # rubocop:disable Metrics/LineLength + ACCOUNT_RESET = 'Account Reset'.freeze ACCOUNT_DELETION = 'Account Deletion Requested'.freeze ACCOUNT_VISIT = 'Account Page Visited'.freeze EMAIL_AND_PASSWORD_AUTH = 'Email and Password Authentication'.freeze diff --git a/app/services/encryption/encryptors/attribute_encryptor.rb b/app/services/encryption/encryptors/attribute_encryptor.rb index dd32f618288..6b5915ecd6a 100644 --- a/app/services/encryption/encryptors/attribute_encryptor.rb +++ b/app/services/encryption/encryptors/attribute_encryptor.rb @@ -1,65 +1,71 @@ module Encryption module Encryptors class AttributeEncryptor - def encrypt(plaintext) - user_access_key = self.class.load_or_init_user_access_key( - key: current_key, cost: current_cost - ) - UserAccessKeyEncryptor.new(user_access_key).encrypt(plaintext) + include Encodable + + def initialize + @aes_cipher = AesCipher.new + @stale = false end - def decrypt(ciphertext) - encryption_keys_with_cost.each do |key_with_cost| - key = key_with_cost.fetch(:key) - cost = key_with_cost.fetch(:cost) - result = try_decrypt(ciphertext, key: key, cost: cost) - return result unless result.nil? + def encrypt(plaintext) + unless Figaro.env.attribute_encryption_without_kms == 'true' + return deprecated_encryptor.encrypt(plaintext) end - raise EncryptionError, 'unable to decrypt attribute with any key' + + aes_encrypted_ciphertext = aes_cipher.encrypt(plaintext, current_key) + encode(aes_encrypted_ciphertext) end - def stale? - stale + def decrypt(ciphertext) + return deprecated_encryptor.decrypt(ciphertext) if legacy?(ciphertext) + raise EncryptionError, 'ciphertext invalid' unless valid_base64_encoding?(ciphertext) + decoded_ciphertext = decode(ciphertext) + try_decrypt(decoded_ciphertext) end - def self.load_or_init_user_access_key(key:, cost:) - @_scypt_hashes_by_key ||= {} - scrypt_hash = @_scypt_hashes_by_key["#{key}:#{cost}"] - return UserAccessKey.new(scrypt_hash: scrypt_hash) if scrypt_hash.present? - uak = UserAccessKey.new(password: key, salt: key, cost: cost) - @_scypt_hashes_by_key["#{key}:#{cost}"] = uak.as_scrypt_hash - uak + def stale? + deprecated_encryptor&.stale? || stale end private + attr_reader :aes_cipher attr_accessor :stale - def try_decrypt(ciphertext, key:, cost:) - user_access_key = self.class.load_or_init_user_access_key(key: key, cost: cost) - begin - result = UserAccessKeyEncryptor.new(user_access_key).decrypt(ciphertext) - self.stale = key != current_key - result - rescue EncryptionError - nil + def try_decrypt(decoded_ciphertext) + all_keys.each do |key| + result = try_decrypt_with_key(decoded_ciphertext, key) + return result unless result.nil? end + raise EncryptionError, 'unable to decrypt attribute with any key' end - def encryption_keys_with_cost - @encryption_keys_with_cost ||= [{ key: current_key, cost: current_cost }] + old_keys + def try_decrypt_with_key(decoded_ciphertext, key) + self.stale = key != current_key + aes_cipher.decrypt(decoded_ciphertext, key) + rescue EncryptionError + nil + end + + def legacy?(ciphertext) + Encryption::KmsClient.looks_like_kms?(ciphertext) || ciphertext.index('.') end def current_key Figaro.env.attribute_encryption_key end - def current_cost - Figaro.env.attribute_cost + def all_keys + [current_key].concat(old_keys.collect { |hash| hash['key'] }) end def old_keys - JSON.parse(Figaro.env.attribute_encryption_key_queue, symbolize_names: true) + JSON.parse(Figaro.env.attribute_encryption_key_queue) + end + + def deprecated_encryptor + @_deprecated_encryptor ||= DeprecatedAttributeEncryptor.new end end end diff --git a/app/services/encryption/encryptors/deprecated_attribute_encryptor.rb b/app/services/encryption/encryptors/deprecated_attribute_encryptor.rb new file mode 100644 index 00000000000..9dda4049f97 --- /dev/null +++ b/app/services/encryption/encryptors/deprecated_attribute_encryptor.rb @@ -0,0 +1,66 @@ +module Encryption + module Encryptors + class DeprecatedAttributeEncryptor + def encrypt(plaintext) + user_access_key = self.class.load_or_init_user_access_key( + key: current_key, cost: current_cost + ) + UserAccessKeyEncryptor.new(user_access_key).encrypt(plaintext) + end + + def decrypt(ciphertext) + encryption_keys_with_cost.each do |key_with_cost| + key = key_with_cost.fetch(:key) + cost = key_with_cost.fetch(:cost) + result = try_decrypt(ciphertext, key: key, cost: cost) + return result unless result.nil? + end + raise Encryption::EncryptionError, 'unable to decrypt attribute with any key' + end + + def stale? + stale + end + + def self.load_or_init_user_access_key(key:, cost:) + @_scypt_hashes_by_key ||= {} + scrypt_hash = @_scypt_hashes_by_key["#{key}:#{cost}"] + return UserAccessKey.new(scrypt_hash: scrypt_hash) if scrypt_hash.present? + uak = UserAccessKey.new(password: key, salt: key, cost: cost) + @_scypt_hashes_by_key["#{key}:#{cost}"] = uak.as_scrypt_hash + uak + end + + private + + attr_accessor :stale + + def try_decrypt(ciphertext, key:, cost:) + user_access_key = self.class.load_or_init_user_access_key(key: key, cost: cost) + begin + result = UserAccessKeyEncryptor.new(user_access_key).decrypt(ciphertext) + self.stale = key != current_key + result + rescue Encryption::EncryptionError + nil + end + end + + def encryption_keys_with_cost + @encryption_keys_with_cost ||= [{ key: current_key, cost: current_cost }] + old_keys + end + + def current_key + Figaro.env.attribute_encryption_key + end + + def current_cost + Figaro.env.attribute_cost + end + + def old_keys + JSON.parse(Figaro.env.attribute_encryption_key_queue, symbolize_names: true) + end + end + end +end diff --git a/app/services/encryption/kms_client.rb b/app/services/encryption/kms_client.rb index 56c0c77f8d2..abd27bc6ebb 100644 --- a/app/services/encryption/kms_client.rb +++ b/app/services/encryption/kms_client.rb @@ -12,12 +12,20 @@ def encrypt(plaintext) end def decrypt(ciphertext) - return decrypt_kms(ciphertext) if FeatureManagement.use_kms? && looks_like_kms?(ciphertext) + return decrypt_kms(ciphertext) if use_kms?(ciphertext) decrypt_local(ciphertext) end + def self.looks_like_kms?(ciphertext) + ciphertext.start_with?(KEY_TYPE[:KMS]) + end + private + def use_kms?(ciphertext) + FeatureManagement.use_kms? && self.class.looks_like_kms?(ciphertext) + end + def encrypt_kms(plaintext) ciphertext_blob = aws_client.encrypt( key_id: Figaro.env.aws_kms_key_id, @@ -41,10 +49,6 @@ def decrypt_local(ciphertext) encryptor.decrypt(ciphertext, Figaro.env.password_pepper) end - def looks_like_kms?(ciphertext) - ciphertext.start_with?(KEY_TYPE[:KMS]) - end - def aws_client @aws_client ||= Aws::KMS::Client.new( instance_profile_credentials_timeout: 1, # defaults to 1 second diff --git a/app/services/encryption/password_verifier.rb b/app/services/encryption/password_verifier.rb index 6e5196bf638..cb8e5974605 100644 --- a/app/services/encryption/password_verifier.rb +++ b/app/services/encryption/password_verifier.rb @@ -14,7 +14,7 @@ def self.parse_from_string(digest_string) data[:password_salt], data[:password_cost] ) - rescue JSON::ParserError + rescue JSON::ParserError, TypeError raise EncryptionError, 'digest contains invalid json' end @@ -41,12 +41,11 @@ def self.digest(password) end def self.verify(password:, digest:) + return false if password.blank? parsed_digest = PasswordDigest.parse_from_string(digest) - uak = UserAccessKey.new( - password: password, - salt: parsed_digest.password_salt, - cost: parsed_digest.password_cost - ) + uak = UserAccessKey.new(password: password, + salt: parsed_digest.password_salt, + cost: parsed_digest.password_cost) uak.unlock(parsed_digest.encryption_key) Devise.secure_compare(uak.encrypted_password, parsed_digest.encrypted_password) rescue EncryptionError diff --git a/app/services/file_encryptor.rb b/app/services/file_encryptor.rb deleted file mode 100644 index 8544e982287..00000000000 --- a/app/services/file_encryptor.rb +++ /dev/null @@ -1,63 +0,0 @@ -class FileEncryptor - class EncryptionError < StandardError; end - - def initialize(path_to_public_gpg_key, recipient_email) - @gpg_key_path = path_to_public_gpg_key - @recipient_email = recipient_email - end - - def encrypt(text, output_file) - IO.pipe do |stdin_read_io, stdin_write_io| - IO.pipe do |stderr_read_io, stderr_write_io| - stdin_write_io.syswrite(text) - stdin_write_io.close - - success = system(gpg_encrypt_command(output_file), in: stdin_read_io, err: stderr_write_io) - stderr_write_io.close - raise EncryptionError, "gpg error: #{stderr_read_io.read}" unless success - end - end - end - - def decrypt(passphrase, input_file) - IO.pipe do |stdout_read_io, stdout_write_io| - system( - gpg_decrypt_command(passphrase, input_file), - out: stdout_write_io, - err: '/dev/null' - ) - stdout_write_io.close - stdout_read_io.read - end - end - - private - - attr_reader :gpg_key_path, :recipient_email - - # rubocop:disable MethodLength - def gpg_encrypt_command(outfile) - "gpg --no-default-keyring \ - --keyring #{Shellwords.shellescape(gpg_key_path)} \ - --trust-model always \ - --cipher-algo aes256 \ - --digest-algo sha256 \ - --batch \ - --yes \ - --pinentry-mode loopback \ - --status-fd \ - --with-colons \ - --no-tty \ - -e \ - -r #{Shellwords.shellescape(recipient_email)} \ - --output #{Shellwords.shellescape(outfile)} - " - end - # rubocop:enable MethodLength - - def gpg_decrypt_command(passphrase, infile) - password = Shellwords.shellescape(passphrase) - "echo #{password} | PASSPHRASE=#{password} gpg --batch \ - --pinentry-mode loopback --command-fd 0 -d #{Shellwords.shellescape(infile)}" - end -end diff --git a/app/services/holiday_service.rb b/app/services/holiday_service.rb new file mode 100644 index 00000000000..6b2b11e940f --- /dev/null +++ b/app/services/holiday_service.rb @@ -0,0 +1,127 @@ +class HolidayService + # https://www.opm.gov/policy-data-oversight/snow-dismissal-procedures/federal-holidays + + class << self + def holiday?(date) + new(date.year).holiday?(date) + end + + def observed_holiday?(date) + new(date.year).observed_holiday?(date) + end + end + + attr_reader :year + + def initialize(year) + @year = year + end + + def holiday?(date) + holidays.any? { |holiday| holiday == date } + end + + def observed_holiday?(date) + observed_holidays.any? { |oh| oh == date } + end + + # rubocop:disable Metrics/MethodLength + def holidays + [ + new_years, + mlk, + washington, + memorial, + independence, + labor, + columbus, + veterans, + thanksgiving, + christmas, + ] + end + # rubocop:enable Metrics/MethodLength + + def observed_holidays + holidays. + concat([next_new_years]). + map(&method(:observed)). + select { |oh| oh.year == year } + end + + # January 1st + def new_years + Date.new(year, 1, 1) + end + + # 3rd Monday of January + def mlk + Date.new(year, 1, 1). + step(Date.new(year, 2, 1)). + select(&:monday?)[2] + end + + # 3rd Monday of February + def washington + Date.new(year, 2, 1). + step(Date.new(year, 3, 1)). + select(&:monday?)[2] + end + + # Last Monday of May + def memorial + Date.new(year, 6, 1). + step(Date.new(year, 5, 1), -1). + find(&:monday?) + end + + # July 4th + def independence + Date.new(year, 7, 4) + end + + # First Monday of September + def labor + Date.new(year, 9, 1). + step(Date.new(year, 10, 1)). + find(&:monday?) + end + + # Second Monday of October + def columbus + Date.new(year, 10, 1). + step(Date.new(year, 11, 1)). + select(&:monday?).second + end + + # November 11th + def veterans + Date.new(year, 11, 11) + end + + # 4th Thursday of November + def thanksgiving + Date.new(year, 11, 1). + step(Date.new(year, 12, 1)). + select(&:thursday?)[3] + end + + # December 25th + def christmas + Date.new(year, 12, 25) + end + + # If NY is on a Saturday, the observed holiday would be in the prior year, + # make sure to include it in observed holidays when necessary + def next_new_years + Date.new(year + 1, 1, 1) + end + + private + + def observed(date) + return date - 1 if date.saturday? + return date + 1 if date.sunday? + date + end +end diff --git a/app/services/password_metrics_incrementer.rb b/app/services/password_metrics_incrementer.rb new file mode 100644 index 00000000000..e95fdfcfac4 --- /dev/null +++ b/app/services/password_metrics_incrementer.rb @@ -0,0 +1,19 @@ +class PasswordMetricsIncrementer + def initialize(password) + @password = password + end + + def increment_password_metrics + PasswordMetric.increment(:length, password.length) + PasswordMetric.increment(:guesses_log10, guesses_log10) + end + + private + + attr_reader :password + + # Disable :reek:UncommunicativeMethodName b/c this method name ends with a number + def guesses_log10 + Zxcvbn::Tester.new.test(password).guesses_log10.round(1) + end +end diff --git a/app/services/personal_key_generator.rb b/app/services/personal_key_generator.rb index 646a79f2433..1c613fc23ab 100644 --- a/app/services/personal_key_generator.rb +++ b/app/services/personal_key_generator.rb @@ -9,23 +9,18 @@ def initialize(user, length: 4) end def create - create_recovery_code - create_encrypted_recovery_code_digest + digest = create_encrypted_recovery_code_digest + # Until we drop the old columns, still write to them so that we can rollback + create_legacy_recovery_code(digest) user.save! raw_personal_key.tr(' ', '-') end def verify(plaintext_code) - @user_access_key = make_user_access_key(normalize(plaintext_code)) - encryption_key, encrypted_code = user.personal_key.split( - Encryption::Encryptors::AesEncryptor::DELIMITER + Encryption::PasswordVerifier.verify( + password: normalize(plaintext_code), + digest: user.encrypted_recovery_code_digest ) - begin - user_access_key.unlock(encryption_key) - rescue Encryption::EncryptionError => _err - return false - end - Devise.secure_compare(encrypted_code, user_access_key.encrypted_password) end def normalize(plaintext_code) @@ -42,20 +37,19 @@ def normalize(plaintext_code) attr_reader :user - def create_recovery_code - user.recovery_salt = Devise.friendly_token[0, 20] - user.recovery_cost = Figaro.env.scrypt_cost - @user_access_key = make_user_access_key(raw_personal_key) - user.personal_key = hashed_code + def create_legacy_recovery_code(digest) + user.personal_key = [ + digest.encryption_key, + digest.encrypted_password, + ].join(Encryption::Encryptors::AesEncryptor::DELIMITER) + user.recovery_salt = digest.password_salt + user.recovery_cost = digest.password_cost end def create_encrypted_recovery_code_digest - user.encrypted_recovery_code_digest = { - encryption_key: user_access_key.encryption_key, - encrypted_password: user_access_key.encrypted_password, - password_cost: user.recovery_cost, - password_salt: user.recovery_salt, - }.to_json + digest = Encryption::PasswordVerifier.digest(raw_personal_key) + user.encrypted_recovery_code_digest = digest.to_s + digest end def encode_code(code:, length:, split:) @@ -63,22 +57,6 @@ def encode_code(code:, length:, split:) Base32::Crockford.encode(decoded, length: length, split: split).tr('-', ' ') end - def make_user_access_key(code) - Encryption::UserAccessKey.new( - password: code, - salt: user.recovery_salt, - cost: user.recovery_cost - ) - end - - def hashed_code - user_access_key.build - [ - user_access_key.encryption_key, - user_access_key.encrypted_password, - ].join(Encryption::Encryptors::AesEncryptor::DELIMITER) - end - def raw_personal_key @raw_personal_key ||= RandomPhrase.new(num_words: personal_key_length).to_s end diff --git a/app/services/phone_formatter.rb b/app/services/phone_formatter.rb index 50f9a376116..f999a0eaa99 100644 --- a/app/services/phone_formatter.rb +++ b/app/services/phone_formatter.rb @@ -1,12 +1,7 @@ -class PhoneFormatter +module PhoneFormatter DEFAULT_COUNTRY = 'US'.freeze - def format(phone, country_code: nil) - normalized_phone = if country_code - phone&.phony_normalized(country_code: country_code) - else - phone&.phony_normalized(default_country_code: DEFAULT_COUNTRY) - end - normalized_phone&.phony_formatted(format: :international, spaces: ' ') + def self.format(phone, country_code: nil) + Phonelib.parse(phone, country_code || DEFAULT_COUNTRY)&.international end end diff --git a/app/services/phone_number_capabilities.rb b/app/services/phone_number_capabilities.rb index b19c5f6a651..09fc756b66b 100644 --- a/app/services/phone_number_capabilities.rb +++ b/app/services/phone_number_capabilities.rb @@ -50,7 +50,7 @@ def unsupported_location private def area_code - @area_code ||= phone_number_components.second + @area_code ||= parsed_phone.area_code end def country_code_data @@ -60,14 +60,10 @@ def country_code_data end def international_code - @international_code ||= phone_number_components.first + @international_code ||= parsed_phone.country_code end - def phone_number_components - return [] if phone.blank? - - @phone_number_components ||= Phony.split( - PhonyRails.normalize_number(phone.to_s, default_country_code: :us).slice(1..-1) - ) + def parsed_phone + @parsed_phone ||= Phonelib.parse(phone) end end diff --git a/app/services/phone_verification.rb b/app/services/phone_verification.rb new file mode 100644 index 00000000000..94abe87be27 --- /dev/null +++ b/app/services/phone_verification.rb @@ -0,0 +1,83 @@ +class PhoneVerification + AUTHY_START_ENDPOINT = 'https://api.authy.com/protected/json/phones/verification/start'.freeze + + HEADERS = { 'X-Authy-API-Key' => Figaro.env.twilio_verify_api_key }.freeze + OPEN_TIMEOUT = 5 + READ_TIMEOUT = 5 + + AVAILABLE_LOCALES = %w[af ar ca zh zh-CN zh-HK hr cs da nl en fi fr de el he hi hu id it ja ko ms + nb pl pt-BR pt ro ru es sv tl th tr vi].freeze + + cattr_accessor :adapter do + Typhoeus + end + + def initialize(phone:, code:, locale: nil) + @phone = phone + @code = code + @locale = locale + end + + def send_sms + raise VerifyError.new(code: error_code, message: error_message) unless start_request.success? + end + + private + + attr_reader :phone, :code, :locale + + def error_code + response_body.fetch('error_code', nil).to_i + end + + def error_message + response_body.fetch('message', '') + end + + def response_body + @response_body ||= JSON.parse(start_request.response_body) + end + + def start_request + @start_request ||= adapter.post(AUTHY_START_ENDPOINT, start_params) + end + + # rubocop:disable Metrics/MethodLength + def start_params + { + headers: HEADERS, + body: { + code_length: 6, + country_code: country_code, + custom_code: code, + locale: locale, + phone_number: number_without_country_code, + via: 'sms', + }, + connecttimeout: OPEN_TIMEOUT, + timeout: READ_TIMEOUT, + } + end + # rubocop:enable Metrics/MethodLength + + def number_without_country_code + parsed_phone.raw_national + end + + def parsed_phone + @parsed_phone ||= Phonelib.parse(phone) + end + + def country_code + parsed_phone.country_code + end + + class VerifyError < StandardError + attr_reader :code, :message + + def initialize(code:, message:) + @code = code + @message = message + end + end +end diff --git a/app/services/twilio_service.rb b/app/services/twilio_service.rb index 8f84084ea17..53e4e52667c 100644 --- a/app/services/twilio_service.rb +++ b/app/services/twilio_service.rb @@ -1,11 +1,6 @@ require 'typhoeus/adapters/faraday' class TwilioService - INVALID_VOICE_NUMBER_ERROR_CODE = 13_224 - SMS_ERROR_CODE = 21_614 - INVALID_ERROR_CODE = 21_211 - INVALID_CALLING_AREA_ERROR_CODE = 21_215 - cattr_accessor :telephony_service do Twilio::REST::Client end diff --git a/app/services/usps_confirmation_entry.rb b/app/services/usps_confirmation_entry.rb deleted file mode 100644 index 6ebbbb215b3..00000000000 --- a/app/services/usps_confirmation_entry.rb +++ /dev/null @@ -1,40 +0,0 @@ -UspsConfirmationEntry = Struct.new( - :address1, - :address2, - :city, - :first_name, - :last_name, - :otp, - :state, - :zipcode, - :issuer -) do - def self.encryptor - # This currently uses the SessionEncryptor, which is meant to be used to - # encrypt the session. When this code is changed to integrate a new mail - # vendor we should create a purpose built encryptor for that vendor - Encryption::Encryptors::SessionEncryptor.new - end - - def self.new_from_hash(hash) - attrs = new - hash.each { |key, val| attrs[key] = val } - attrs - end - - def self.new_from_encrypted(encrypted) - decrypted = encryptor.decrypt(encrypted) - new_from_json(decrypted) - end - - def self.new_from_json(pii_json) - return new if pii_json.blank? - pii = JSON.parse(pii_json, symbolize_names: true) - new_from_hash(pii) - end - - def encrypted - klass = self.class - klass.encryptor.encrypt(to_json) - end -end diff --git a/app/services/usps_confirmation_maker.rb b/app/services/usps_confirmation_maker.rb index f8b91cbc1d4..94a8af86722 100644 --- a/app/services/usps_confirmation_maker.rb +++ b/app/services/usps_confirmation_maker.rb @@ -10,8 +10,7 @@ def otp end def perform - entry = UspsConfirmationEntry.new_from_hash(attributes) - UspsConfirmation.create!(entry: entry.encrypted) + UspsConfirmation.create!(entry: attributes) UspsConfirmationCode.create!( profile: profile, otp_fingerprint: Pii::Fingerprinter.fingerprint(otp) diff --git a/app/services/usps_exporter.rb b/app/services/usps_exporter.rb index 81cd43c2fb3..8ac46be0c3b 100644 --- a/app/services/usps_exporter.rb +++ b/app/services/usps_exporter.rb @@ -8,11 +8,10 @@ def initialize(psv_file_path) end def run - psv_buffer = CSV.generate(col_sep: '|', row_sep: "\r\n") do |csv| + CSV.open(psv_file_path, 'a', col_sep: '|', row_sep: "\r\n") do |csv| make_psv(csv) end - file_encryptor.encrypt(psv_buffer, psv_file_path) - clear_entries + clear_confirmations end private @@ -20,18 +19,18 @@ def run attr_reader :psv_file_path def make_psv(csv) - csv << make_header_row(entries.size) - entries.map(&:decrypted_entry).each do |entry| - csv << make_entry_row(entry) + csv << make_header_row(confirmations.size) + confirmations.each do |confirmation| + csv << make_entry_row(confirmation.entry) end end - def entries - @entries ||= UspsConfirmation.all + def confirmations + @confirmations ||= UspsConfirmation.all end - def clear_entries - UspsConfirmation.where(id: entries.map(&:id)).destroy_all + def clear_confirmations + UspsConfirmation.where(id: confirmations.map(&:id)).destroy_all end def make_header_row(num_entries) @@ -42,17 +41,17 @@ def make_header_row(num_entries) def make_entry_row(entry) now = Time.zone.now due = now + OTP_MAX_VALID_DAYS.days - service_provider = ServiceProvider.from_issuer(entry.issuer) + service_provider = ServiceProvider.from_issuer(entry[:issuer]) [ CONTENT_ROW_ID, - "#{entry.first_name} #{entry.last_name}", - entry.address1, - entry.address2, - entry.city, - entry.state, - entry.zipcode, - entry.otp, + "#{entry[:first_name]} #{entry[:last_name]}", + entry[:address1], + entry[:address2], + entry[:city], + entry[:state], + entry[:zipcode], + entry[:otp], "#{now.strftime('%-B %-e')}, #{now.year}", "#{due.strftime('%-B %-e')}, #{due.year}", service_provider.friendly_name, @@ -60,11 +59,4 @@ def make_entry_row(entry) ] end # rubocop:enable MethodLength, AbcSize - - def file_encryptor - @_file_encryptor ||= FileEncryptor.new( - Rails.root.join('keys', 'equifax_gpg.pub.bin'), - Figaro.env.equifax_gpg_email - ) - end end diff --git a/app/services/usps_uploader.rb b/app/services/usps_uploader.rb index 8b5f394fc9f..70e040f0260 100644 --- a/app/services/usps_uploader.rb +++ b/app/services/usps_uploader.rb @@ -11,7 +11,7 @@ def run def local_path @_local_path ||= begin timestamp = Time.zone.now.strftime('%Y%m%d%H%M%S') - Rails.root.join('tmp', "batch-#{timestamp}.pgp") + Rails.root.join('tmp', "batch-#{timestamp}.psv") end end @@ -22,12 +22,10 @@ def build_file end def upload_file - env = Figaro.env - Net::SFTP.start( - env.equifax_sftp_host, - env.equifax_sftp_username, - key_data: [RequestKeyManager.equifax_ssh_key.to_pem] + env.usps_upload_sftp_host, + env.usps_upload_sftp_username, + password: env.usps_upload_sftp_password ) do |sftp| sftp.upload!(local_path.to_s, remote_path) end @@ -38,6 +36,10 @@ def clear_file end def remote_path - File.join(Figaro.env.equifax_sftp_directory, 'batch.pgp') + File.join(Figaro.env.usps_upload_sftp_directory, 'batch.psv') + end + + def env + Figaro.env end end diff --git a/app/validators/form_phone_validator.rb b/app/validators/form_phone_validator.rb index 164b12bfc57..d19b7c13d0f 100644 --- a/app/validators/form_phone_validator.rb +++ b/app/validators/form_phone_validator.rb @@ -2,10 +2,12 @@ module FormPhoneValidator extend ActiveSupport::Concern included do - validates_plausible_phone :phone, - presence: true, - message: :improbable_phone, - international_code: ->(form) { form.international_code } + validates :phone, + presence: true, + phone: { + message: :improbable_phone, + country_specifier: ->(form) { form.international_code }, + } validates :international_code, inclusion: { in: PhoneNumberCapabilities::INTERNATIONAL_CODES.keys, } diff --git a/app/view_models/sign_up_completions_show.rb b/app/view_models/sign_up_completions_show.rb index 48f7989a4fc..1c3ded72ee5 100644 --- a/app/view_models/sign_up_completions_show.rb +++ b/app/view_models/sign_up_completions_show.rb @@ -17,6 +17,7 @@ def initialize(loa3_requested:, decorated_session:, current_user:, handoff:) [[:email], :email], [[:birthdate], :birthdate], [[:social_security_number], :social_security_number], + [[:x509_subject], :x509_subject], ].freeze MAX_RECENT_IDENTITIES = 5 diff --git a/app/views/account_reset/confirm_delete_account/show.html.slim b/app/views/account_reset/confirm_delete_account/show.html.slim new file mode 100644 index 00000000000..5a72212e4bb --- /dev/null +++ b/app/views/account_reset/confirm_delete_account/show.html.slim @@ -0,0 +1,8 @@ +- title t('account_reset.confirm_delete_account.title') + +.my2.p3.sm-px4.border.border-red.rounded.rounded-xl.relative + = image_tag(asset_url('alert/fail-x.svg'), size: '48x48', alt: '',\ + class: 'absolute top-n24 left-0 right-0 mx-auto') + h3 = t('account_reset.confirm_delete_account.title') + p == t('account_reset.confirm_delete_account.info', \ + email: email, link: link_to(t('account_reset.confirm_delete_account.link_text'), root_path)) diff --git a/app/views/account_reset/confirm_request/show.html.slim b/app/views/account_reset/confirm_request/show.html.slim new file mode 100644 index 00000000000..7ba5b9833b4 --- /dev/null +++ b/app/views/account_reset/confirm_request/show.html.slim @@ -0,0 +1,8 @@ +- title t('account_reset.confirm_request.check_your_email') + +.my2.p3.sm-px4.border.border-teal.rounded.rounded-xl.relative + = image_tag(asset_url('check-email.svg'), size: '48x48', alt: '',\ + class: 'absolute top-n24 left-0 right-0 mx-auto') + h1.mt1.mb-12p.h3 = t('headings.verify_email') + p + == t('account_reset.confirm_request.instructions', email: email) diff --git a/app/views/account_reset/delete_account/show.html.slim b/app/views/account_reset/delete_account/show.html.slim new file mode 100644 index 00000000000..592457d5d52 --- /dev/null +++ b/app/views/account_reset/delete_account/show.html.slim @@ -0,0 +1,15 @@ +- title t('account_reset.delete_account.title') + +h1.h3.my0 = t('account_reset.delete_account.title') +p.mt-tiny.mb0 + = t('account_reset.delete_account.info') +br +h4.my2 = t('account_reset.delete_account.are_you_sure') + += button_to t('account_reset.delete_account.delete_button'), \ + account_reset_delete_account_path(token: session[:granted_token]), method: :delete, \ + class: 'btn btn-red col-6 p2 rounded-lg border bw2 bg-lightest-red border-red border-box' +br +br +hr += link_to t('account_reset.delete_account.cancel'), root_url diff --git a/app/views/account_reset/request/show.html.slim b/app/views/account_reset/request/show.html.slim new file mode 100644 index 00000000000..eb567a0c363 --- /dev/null +++ b/app/views/account_reset/request/show.html.slim @@ -0,0 +1,37 @@ +- title t('account_reset.request.title') + +h3.my0 = t('account_reset.request.title') +p.mt-tiny.mb0 == t('account_reset.request.info') +br +h4.my0 = t('account_reset.request.personal_key') + +p.mt-tiny.mb0 + = t('account_reset.request.personal_key_info') + +hr += render 'partials/personal_key/key', code: 'XXXX-XXXX-XXXX-XXXX' + +.mb3.right-align + + = link_to t('users.personal_key.print'), '#', + data: { print: true }, + class: 'ml2 btn-border ico ico-print text-decoration-none' + +hr +p.mt-tiny.mb0 == t('account_reset.request.personal_key_trailer', \ + link: link_to(t('account_reset.request.access_your_account'), login_two_factor_personal_key_url)) +br +h4.my0 = t('account_reset.request.delete_account') +p.mt-tiny.mb0 == t('account_reset.request.delete_account_info') +br +h5.my2 = t('account_reset.request.are_you_sure') += button_to t('account_reset.request.no_cancel'), root_url, method: :get, + class: 'btn btn-primary btn-wide mb1 personal-key-continue', + 'data-toggle': 'modal' += button_to t('account_reset.request.yes_continue'), account_reset_request_path, \ + class: 'btn btn-link' +br +br +br +hr += link_to t('forms.buttons.back'), root_url diff --git a/app/views/idv/review/new.html.slim b/app/views/idv/review/new.html.slim index 8eed040222d..4e21dad468c 100644 --- a/app/views/idv/review/new.html.slim +++ b/app/views/idv/review/new.html.slim @@ -7,5 +7,5 @@ h1.h3 = t('idv.titles.session.review') .mt4 = accordion('review-verified-info', t('idv.messages.review.intro')) do - phone = @idv_params[:phone] - - formatted_phone = PhoneFormatter.new.format(phone) + - formatted_phone = PhoneFormatter.format(phone) = render 'shared/pii_review', pii: @idv_params, phone: formatted_phone diff --git a/app/views/user_mailer/account_reset_complete.html.slim b/app/views/user_mailer/account_reset_complete.html.slim new file mode 100644 index 00000000000..530ae2ec5f9 --- /dev/null +++ b/app/views/user_mailer/account_reset_complete.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/user_mailer/account_reset_granted.html.slim b/app/views/user_mailer/account_reset_granted.html.slim new file mode 100644 index 00000000000..e244672df0a --- /dev/null +++ b/app/views/user_mailer/account_reset_granted.html.slim @@ -0,0 +1,27 @@ +p.lead == t('.intro', app: link_to(APP_NAME, Figaro.env.mailer_domain_name, class: 'gray')) +table.button.expanded.large + tbody + tr + td + table + tbody + tr + td.btn-warn-bkg + == link_to t('.button'), account_reset_delete_account_url(token: @granted_token), \ + target: '_blank', class: 'btn-warn' + td.half +p = link_to account_reset_delete_account_url(token: @granted_token), + account_reset_delete_account_url(token: @granted_token), target: '_blank' +table.spacer + tbody + tr + td.s10 height="10px" + |   +table.hr + tr + th + |   +p= t('mailer.confirmation_instructions.footer', confirmation_period: '24 hours') +p== t('user_mailer.account_reset_granted.help', + cancel_account_reset: link_to(t('user_mailer.account_reset_granted.cancel_link_text'), + account_reset_cancel_url(token: @token))) diff --git a/app/views/user_mailer/account_reset_request.html.slim b/app/views/user_mailer/account_reset_request.html.slim new file mode 100644 index 00000000000..18d8b65d2cc --- /dev/null +++ b/app/views/user_mailer/account_reset_request.html.slim @@ -0,0 +1,18 @@ +p.lead == t('.intro', app: link_to(APP_NAME, Figaro.env.mailer_domain_name, class: 'gray'), \ + cancel_account_reset: link_to(t('user_mailer.account_reset_granted.cancel_link_text'), \ + account_reset_cancel_url(token: @token), class: 'blue')) + +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/config/application.yml.example b/config/application.yml.example index 654764aa85b..a4dcd267c5a 100644 --- a/config/application.yml.example +++ b/config/application.yml.example @@ -74,11 +74,16 @@ development: aamva_public_key: '123abc' aamva_private_key: '123abc' aamva_verification_url: 'https://example.org:12345/verification/url' + account_reset_auth_token: 'abc123' + account_reset_enabled: 'true' + account_reset_token_valid_for_days: '1' + account_reset_wait_period_days: '1' async_job_refresh_interval_seconds: '5' 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": "old-key-one", "cost": "4000$8$4$" }, { "key": "old-key-one", "cost": "4000$8$4$" }]' + attribute_encryption_key_queue: '[{ "key": "11111111111111111111111111111111", "cost": "4000$8$4$" }, { "key": "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' aws_region: 'us-east-1' @@ -119,7 +124,7 @@ development: equifax_ssh_passphrase: 'sekret' exception_recipients: 'test1@test.com' hmac_fingerprinter_key: 'a2c813d4dca919340866ba58063e4072adc459b767a74cf2666d5c1eef3861db26708e7437abde1755eb24f4034386b0fea1850a1cb7e56bff8fae3cc6ade96c' - hmac_fingerprinter_key_queue: '["old-key-one", "old-key-two"]' + hmac_fingerprinter_key_queue: '["11111111111111111111111111111111", "22222222222222222222222222222222"]' identity_pki_disabled: 'true' issuers_with_email_nameid_format: '' lexisnexis_account_id: 'test_account' @@ -185,6 +190,11 @@ development: usps_confirmation_max_days: '10' enable_i18n_mode: 'false' enable_load_testing_mode: 'false' + usps_upload_sftp_directory: '/directory' + usps_upload_sftp_host: 'example.com' + usps_upload_sftp_username: 'user' + usps_upload_sftp_password: 'pass' + usps_upload_token: '123ABC' # These values serve as defaults for all production-like environments, which # includes *.identitysandbox.gov and *.login.gov. @@ -198,11 +208,16 @@ production: aamva_public_key: # Base64 encoded public key for AAMVA aamva_private_key: # Base64 encoded private key for AAMVA aamva_verification_url: # DLDV Verification URL + account_reset_auth_token: + account_reset_enabled: 'true' + account_reset_token_valid_for_days: '1' + account_reset_wait_period_days: '1' async_job_refresh_interval_seconds: '5' async_job_refresh_max_wait_seconds: '15' attribute_cost: '4000$8$4$' # SCrypt::Engine.calibrate(max_time: 0.5) attribute_encryption_key: # generate via `rake secret` attribute_encryption_key_queue: # '[{ "key": "old-key-one", "cost": "4000$8$4$" }, { "key": "old-key-one", "cost": "4000$8$4$" }]' + attribute_encryption_without_kms: 'false' available_locales: 'en es fr' aws_kms_key_id: aws_region: @@ -291,21 +306,32 @@ production: twilio_auth_token: # Twilio auth token twilio_messaging_service_sid: # Twilio CoPilot SID twilio_record_voice: 'false' + twilio_verify_api_key: 'change-me' use_kms: 'true' usps_confirmation_max_days: '30' enable_i18n_mode: 'false' enable_load_testing_mode: 'false' + usps_upload_sftp_directory: + usps_upload_sftp_host: + usps_upload_sftp_username: + usps_upload_sftp_password: + usps_upload_token: test: aamva_cert_enabled: 'true' aamva_public_key: '123abc' aamva_private_key: '123abc' aamva_verification_url: 'https://example.org:12345/verification/url' + account_reset_auth_token: 'test' + account_reset_enabled: 'true' + account_reset_token_valid_for_days: '1' + account_reset_wait_period_days: '1' async_job_refresh_interval_seconds: '1' async_job_refresh_max_wait_seconds: '15' attribute_cost: '800$8$1$' # SCrypt::Engine.calibrate(max_time: 0.01) attribute_encryption_key: '2086dfbd15f5b0c584f3664422a1d3409a0d2aa6084f65b6ba57d64d4257431c124158670c7655e45cabe64194f7f7b6c7970153c285bdb8287ec0c4f7553e25' - attribute_encryption_key_queue: '[{ "key": "old-key-one", "cost": "4000$8$4$" }, { "key": "old-key-one", "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-test-keymaker' aws_region: 'us-east-1' @@ -401,7 +427,13 @@ test: twilio_auth_token: 'token1' twilio_messaging_service_sid: '123abc' twilio_record_voice: 'true' + twilio_verify_api_key: 'secret' use_kms: 'false' usps_confirmation_max_days: '10' enable_i18n_mode: 'false' enable_load_testing_mode: 'false' + usps_upload_sftp_directory: '/directory' + usps_upload_sftp_host: 'example.com' + usps_upload_sftp_username: 'user' + usps_upload_sftp_password: 'pass' + usps_upload_token: 'test_token' diff --git a/config/initializers/figaro.rb b/config/initializers/figaro.rb index c9b382e94e4..ed1d5602f6d 100644 --- a/config/initializers/figaro.rb +++ b/config/initializers/figaro.rb @@ -31,7 +31,6 @@ 'password_pepper', 'password_strength_enabled', 'queue_health_check_dead_interval_seconds', - 'queue_health_check_frequency_seconds', 'reauthn_window', 'recovery_code_length', 'redis_url', diff --git a/config/locales/account_reset/en.yml b/config/locales/account_reset/en.yml new file mode 100644 index 00000000000..fe5a2b35f08 --- /dev/null +++ b/config/locales/account_reset/en.yml @@ -0,0 +1,48 @@ +--- +en: + account_reset: + request: + title: Account deletion and reset + info: If you can't access your account through the security options you set + up previously, deleting your account and creating a new one is the only option. +

We can't undo an account delete, so please make sure you don't have + another security option you can use instead like your personal key. + personal_key: Do you have your personal key? + personal_key_info: Your personal key is a 16 character code that was given to + you at account creation as a recovery method--see example below. + personal_key_trailer: If you have your personal key, you can use it to %{link} + instead of resetting. + access_your_account: access your account + delete_account: Delete your account + delete_account_info: Deleting your existing account and creating a new one will + allow you to use the same email address and set up new security options. However, + deleting will remove any agency applications you have linked to your account + 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. + 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 + yes_continue: Yes, continue deletion. + confirm_request: + check_your_email: Check your email + instructions: We sent an email to %{email} to begin the account + delete process. Follow the instructions in your email to complete the process. +

As a security measure, we also sent a text to your registered phone + number.

You can close this window if you are done. + delete_account: + title: Deleting your account should be your last resort + info: Deleting your account should be your last resort if you are locked out + of your account. You will not be able to recover any information linked to + your account. Once your account is deleted, you can create a new one using + the same email address. + are_you_sure: Are you sure you want to delete your account? + delete_button: Delete account + cancel: Cancel + confirm_delete_account: + title: You have deleted your account + info: The account for %{email} has been deleted. We sent an + email confirmation of the account deletion.

You may %{link} or close + this window if you're done. + link_text: create a new account diff --git a/config/locales/account_reset/es.yml b/config/locales/account_reset/es.yml new file mode 100644 index 00000000000..a069a46a609 --- /dev/null +++ b/config/locales/account_reset/es.yml @@ -0,0 +1,52 @@ +--- +es: + account_reset: + request: + title: Eliminación y restablecimiento de cuenta + info: Si no puede acceder a su cuenta a través de las opciones de seguridad + que configuró anteriormente, eliminar la cuenta y crear una nueva es la única + opción.

No podemos deshacer la eliminación de una cuenta, así que + por favor asegúrese de no tener otra opción de seguridad que puede usar como + su clave personal. + personal_key: "¿Tienes tu clave personal?" + personal_key_info: Su clave personal es un código de 16 caracteres que se le + dio a en la creación de la cuenta como método de recuperación; consulte el + ejemplo a continuación. + personal_key_trailer: Si tiene su clave personal, puede usarla en %{link}          + en lugar de restablecer. + access_your_account: acceder a tu cuenta + delete_account: Eliminar su cuenta + delete_account_info: Eliminar su cuenta existente y crear una nueva          + le permite usar la misma dirección de correo electrónico y configurar nuevas + opciones de seguridad. Sin embargo, eliminar eliminará cualquier aplicación + de agencia que haya vinculado a su cuenta y deberá restaurar cada conexión. +

Si continúas, tú primero recibirá una confirmación por correo electrónico. + Como medida de seguridad, lo hará reciba otro correo electrónico con el enlace + para seguir eliminando su cuenta las 24 horas después del correo electrónico + de confirmación inicial llega. + are_you_sure: "¿Estás seguro de que no tienes acceso a ninguno de tus métodos + de seguridad? O tu clave personal?" + no_cancel: No, cancelar + yes_continue: Sí, continúa la eliminación. + confirm_request: + check_your_email: Consultar su correo electrónico + instructions: Enviamos un correo electrónico a %{email} para + comenzar el proceso de eliminación de cuenta. Siga las instrucciones en su + correo electrónico para completar el proceso.

Como medida de seguridad, + también enviamos un mensaje de texto a su registro número de teléfono.

+ Puede cerrar esta ventana si ha terminado. + delete_account: + title: Eliminar tu cuenta debería ser tu último recurso + info: Eliminar su cuenta debe ser su último recurso si está bloqueado          + de tu cuenta No podrá recuperar ninguna información vinculada a su cuenta. + Una vez que se elimine su cuenta, puede crear una nueva usando la misma dirección + de correo electrónico. + are_you_sure: "¿Seguro que quieres eliminar tu cuenta?" + delete_button: Borrar cuenta + cancel: Cancelar + confirm_delete_account: + title: Has eliminado tu cuenta + info: La cuenta para %{email} ha sido eliminada. Nosotros enviamos + una confirmación por correo electrónico de la eliminación de la cuenta.

+ Puede %{link} o cierra esta ventana si ya terminaste. + link_text: crea una cuenta nueva diff --git a/config/locales/account_reset/fr.yml b/config/locales/account_reset/fr.yml new file mode 100644 index 00000000000..fc837132905 --- /dev/null +++ b/config/locales/account_reset/fr.yml @@ -0,0 +1,53 @@ +--- +fr: + account_reset: + request: + title: Suppression de compte et réinitialisation + info: Si vous ne pouvez pas accéder à votre compte via les options de sécurité + que vous avez définies auparavant, la suppression de votre compte et la création + d'un nouveau compte est la seule option.

Nous ne pouvons pas annuler + un compte supprimer, alors s'il vous plaît assurez-vous que vous n'avez pas + une autre option de sécurité que vous pouvez utiliser à la place comme votre + clé personnelle. + personal_key: Avez-vous votre clé personnelle? + personal_key_info: Votre clé personnelle est un code de 16 caractères qui a + été donné à vous à la création de compte comme une méthode de récupération + - voir l'exemple ci-dessous. + personal_key_trailer: Si vous avez votre clé personnelle, vous pouvez l'utiliser + pour %{link} au lieu de réinitialiser. + access_your_account: accéder à votre compte + delete_account: Supprimer votre compte + delete_account_info: Supprimer votre compte existant et en créer un nouveau + vous permet d'utiliser la même adresse e-mail et de définir de nouvelles options + de sécurité. cependant, la suppression supprimera toutes les applications + d'agence que vous avez liées à votre compte et vous devrez restaurer chaque + connexion.

Si vous continuez, vous recevra d'abord un email de confirmation. + Par mesure de sécurité, vous devrez recevoir un autre e-mail avec le lien + pour continuer la suppression de votre compte 24 heures après l'email de confirmation + initial arrive. + are_you_sure: Êtes-vous sûr de n'avoir accès à aucune de vos méthodes de sécurité? + OU votre clé personnelle? + no_cancel: Non, annuler + yes_continue: Oui, continuez la suppression. + confirm_request: + check_your_email: Vérifiez votre email + instructions: Nous avons envoyé un e-mail à %{email} pour commencer + le compte. Supprimer le processus. Suivez les instructions dans votre e-mail + pour terminer le processus.

Par mesure de sécurité, nous avons également + envoyé un SMS sur votre téléphone enregistré nombre.

Vous pouvez + fermer cette fenêtre si vous avez terminé. + delete_account: + title: La suppression de votre compte devrait être votre dernier recours + info: La suppression de votre compte devrait être votre dernier recours si vous + êtes en lock-out de votre compte Vous ne pourrez pas récupérer les informations + liées à ton compte. Une fois votre compte supprimé, vous pouvez en créer un + nouveau en utilisant la même adresse e-mail. + are_you_sure: Êtes-vous sûr de vouloir supprimer votre compte? + delete_button: Supprimer le compte + cancel: Annuler + confirm_delete_account: + title: Vous avez supprimé votre compte + info: Le compte pour %{email} a été supprimé. Nous avons envoyé + un email de confirmation de la suppression du compte.

Vous pouvez + %{link} ou fermer cette fenêtre si vous avez terminé. + link_text: créer un nouveau compte diff --git a/config/locales/devise/en.yml b/config/locales/devise/en.yml index 85e3df9b9c8..e9602f0374c 100644 --- a/config/locales/devise/en.yml +++ b/config/locales/devise/en.yml @@ -131,8 +131,8 @@ en: link: Use a personal key instead text_html: Don’t have access to your phone? %{link}. personal_key_header_text: Enter your personal key - personal_key_prompt: You can use this personal key once. If you still need a - code after signing in, go to your account settings page to get a new one. + personal_key_prompt: You can use this personal key once. After you enter it, + you'll be provided a new key. phone_sms_label: Mobile phone number phone_sms_info_html: We'll text a security code each time you sign in. phone_voice_label: Phone number @@ -145,6 +145,16 @@ en: please_confirm: Your phone number has been set. Confirm it by entering the security 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 + has been cancelled. + link: deleting your account + text_html: If you can't use any of these security options above, you can reset + your preferences by %{link}. + cancel_link: Cancel your request + pending_html: You currently have a pending request to delete your account. + It takes 24 hours from the time you made the request to complete the process. + Please check back later. %{cancel_link} read_about_two_factor_authentication: link: read about two-factor authentication text_html: You can %{link} and why we use it at our Help page. @@ -169,6 +179,22 @@ en: 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 008ce5c4413..1e273df55ee 100644 --- a/config/locales/devise/es.yml +++ b/config/locales/devise/es.yml @@ -135,9 +135,8 @@ es: link: Use una clave personal en su lugar text_html: "¿No tiene acceso a su teléfono? %{link}." personal_key_header_text: Ingrese su clave personal - personal_key_prompt: Puede usar esta clave personal una vez. Si todavía necesita - un código después de iniciar una sesión, vaya a la página de configuración - de su cuenta para obtener una clave nueva. + personal_key_prompt: Puede usar esta clave personal una vez. Después de ingresarlo, + se le dará una nueva clave. phone_sms_label: Número de teléfono móvil phone_sms_info_html: Le enviaremos un mensaje de texto con un código de seguridad cada vez que inicie sesión. @@ -151,6 +150,16 @@ es: please_confirm: Su número de teléfono ha sido establecido. Confírmelo ingresando 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. + link: eliminando su cuenta + text_html: Si no puede usar ninguna de estas opciones de seguridad anteriores, + puede restablecer tus preferencias por %{link}. + cancel_link: Cancelar su solicitud + pending_html: Actualmente tiene una solicitud pendiente para eliminar su cuenta. + Se necesitan 24 horas desde el momento en que realizó la solicitud para + completar el proceso. Por favor, vuelva más tarde. %{cancel_link} read_about_two_factor_authentication: link: leer acerca de la autenticación de dos factores text_html: Puede %{link} y por qué la utilizamos en nuestra página de Ayuda. @@ -176,6 +185,23 @@ es: 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 fefc7cd17b1..f9f764116c5 100644 --- a/config/locales/devise/fr.yml +++ b/config/locales/devise/fr.yml @@ -143,8 +143,7 @@ fr: text_html: Vous n'avez pas accès à votre téléphone? %{link}. personal_key_header_text: Entrez votre clé personnelle personal_key_prompt: Vous pouvez utiliser cette clé personnelle une fois seulement. - Si vous avez toujours besoin d'un code après votre connexion, allez à la page - des réglages de votre compte pour en obtenir un nouveau. + Une fois que vous l'entrez, vous recevrez une nouvelle clé. phone_sms_label: Numéro de téléphone portable phone_sms_info_html: Nous vous enverrons un code de sécurité chaque fois que vous vous connectez. @@ -158,6 +157,17 @@ fr: please_confirm: Votre numéro de téléphone a été entré. Confirmez-le en entrant 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é. + 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}. + cancel_link: Annuler votre demande + pending_html: Vous avez actuellement une demande en attente pour supprimer + votre compte. Il faut compter 24 heures à partir du moment où vous avez + fait la demande pour terminer le processus. Veuillez vérifier plus tard. + %{cancel_link} read_about_two_factor_authentication: link: lire sur l'authentification à deux facteurs text_html: Vous pouvez %{link} et pourquoi nous l'utilisons sur notre page @@ -184,6 +194,23 @@ fr: 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/help_text/en.yml b/config/locales/help_text/en.yml index 8e53129fb84..5943d76e5b0 100644 --- a/config/locales/help_text/en.yml +++ b/config/locales/help_text/en.yml @@ -11,6 +11,7 @@ en: intro_html: 'This is the only information %{app_name} will share with %{sp}:' phone: Phone number social_security_number: Social Security number + x509_subject: PIV/CAC Identity no_factor: delete_account: To delete your account, please confirm your password and security code. diff --git a/config/locales/help_text/es.yml b/config/locales/help_text/es.yml index 35efc5fde0b..cfc2b3207af 100644 --- a/config/locales/help_text/es.yml +++ b/config/locales/help_text/es.yml @@ -11,6 +11,7 @@ es: intro_html: 'Esta es la única información que %{app_name} compartirá con %{sp}:' phone: Teléfono social_security_number: Número de Seguro Social + x509_subject: NOT TRANSLATED YET no_factor: delete_account: Para eliminar su cuenta, confirme su contraseña y código de seguridad. diff --git a/config/locales/help_text/fr.yml b/config/locales/help_text/fr.yml index 49526b9880f..2ddba3f11bb 100644 --- a/config/locales/help_text/fr.yml +++ b/config/locales/help_text/fr.yml @@ -12,6 +12,7 @@ fr: %{sp}:' phone: Numéro de téléphone social_security_number: Numéro de sécurité sociale + x509_subject: NOT TRANSLATED YET no_factor: delete_account: Pour supprimer votre compte, veuillez confirmer votre mot de passe et votre code de sécurité. diff --git a/config/locales/jobs/en.yml b/config/locales/jobs/en.yml index d1394268e98..d7d47937775 100644 --- a/config/locales/jobs/en.yml +++ b/config/locales/jobs/en.yml @@ -4,3 +4,7 @@ en: sms_otp_sender_job: message: "%{code} is your %{app} one-time security code. This code will expire in %{expiration} minutes." + 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 + cancel: %{cancel_link}' diff --git a/config/locales/jobs/es.yml b/config/locales/jobs/es.yml index 4a8b6de4cd5..e0f5c767b71 100644 --- a/config/locales/jobs/es.yml +++ b/config/locales/jobs/es.yml @@ -4,3 +4,7 @@ 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_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: + %{cancel_link}' diff --git a/config/locales/jobs/fr.yml b/config/locales/jobs/fr.yml index 51a68e33671..76c1a828e42 100644 --- a/config/locales/jobs/fr.yml +++ b/config/locales/jobs/fr.yml @@ -4,3 +4,7 @@ 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_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, + veuillez cancel: %{cancel_link}' diff --git a/config/locales/user_mailer/en.yml b/config/locales/user_mailer/en.yml index 1acb8d977cc..0ba57faca25 100644 --- a/config/locales/user_mailer/en.yml +++ b/config/locales/user_mailer/en.yml @@ -8,6 +8,27 @@ 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_request: + help: '' + intro: 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, %{cancel_account_reset}. + subject: Delete your account + account_reset_granted: + cancel_link_text: please cancel + help: If you don’t want to delete your account, %{cancel_account_reset}. + intro: You are receiving this email because you requested to delete and reset + your login.gov account.

Deleting your account should be your last + resort if you are locked out of your account. You will not be able to recover + any information linked to your account. Once your account is deleted, you + can create a new one using the same email address.

Are you sure you + want to delete your account? + button: Yes, continue deleting + subject: Delete your account + account_reset_complete: + intro: This email confirms you have deleted your login.gov account. + subject: Account deleted + help: '' contact_link_text: contact us email_changed: help: If you did not want to change your email address, please visit the %{app} diff --git a/config/locales/user_mailer/es.yml b/config/locales/user_mailer/es.yml index 09b08534852..0c4d9b723a7 100644 --- a/config/locales/user_mailer/es.yml +++ b/config/locales/user_mailer/es.yml @@ -5,6 +5,27 @@ es: intro: NOT TRANSLATED YET link_text: NOT TRANSLATED YET subject: NOT TRANSLATED YET + account_reset_request: + help: '' + intro: Has solicitado eliminar tu cuenta de login.gov.

Su la + solicitud se procesará en 24 horas.

Si no quieres para eliminar + su cuenta, %{cancel_account_reset}. + subject: Eliminar su cuenta + account_reset_granted: + cancel_link_text: por favor cancele + help: Si no desea eliminar su cuenta, %{cancel_account_reset}. + intro: Recibes este correo electrónico porque solicitaste eliminar y restablecer + su cuenta de login.gov

Eliminar tu cuenta debería ser tu último recurso + si está bloqueado de su cuenta. No podrás recuperar cualquier información + vinculada a su cuenta. Una vez que su cuenta es eliminada, usted puede crear + uno nuevo usando la misma dirección de correo electrónico.

¿Estás seguro + de que ¿Desea eliminar su cuenta? + button: Sí, continúa eliminando + subject: Eliminar su cuenta + account_reset_complete: + intro: Este correo electrónico confirma que ha eliminado su cuenta de login.gov. + subject: Cuenta borrada + help: '' contact_link_text: Contáctenos email_changed: help: Si no desea cambiar su email, visite el %{app} %{help_link} o el %{contact_link}. diff --git a/config/locales/user_mailer/fr.yml b/config/locales/user_mailer/fr.yml index 323d2dac763..632f237141b 100644 --- a/config/locales/user_mailer/fr.yml +++ b/config/locales/user_mailer/fr.yml @@ -5,6 +5,27 @@ fr: intro: NOT TRANSLATED YET link_text: NOT TRANSLATED YET subject: NOT TRANSLATED YET + account_reset_request: + help: '' + intro: Vous avez demandé à supprimer votre compte login.gov.

Votre + la demande sera traitée dans les 24 heures.

Si vous ne voulez + pas pour supprimer votre compte, %{cancel_account_reset}. + subject: Supprimer votre compte + account_reset_granted: + cancel_link_text: veuillez annuler + help: Si vous ne souhaitez pas supprimer votre compte, %{cancel_account_reset}. + intro: Vous recevez cet e-mail parce que vous avez demandé à supprimer et à + réinitialiser votre compte login.gov.

Supprimer votre compte devrait + être votre dernier recours si vous êtes exclu de votre compte. Vous ne serez + pas en mesure de récupérer toute information liée à votre compte. Une fois + votre compte supprimé, vous pouvez en créer un nouveau en utilisant la même + adresse email.

Es-tu sûr de toi voulez-vous supprimer votre compte? + button: Oui, continuez la suppression + subject: Supprimer votre compte + account_reset_complete: + intro: Cet e-mail confirme que vous avez supprimé votre compte login.gov. + subject: Compte supprimé + help: '' contact_link_text: communiquez avec nous email_changed: help: Si vous préférez ne pas changer votre adresse courriel, veuillez visiter diff --git a/config/routes.rb b/config/routes.rb index 5e2c3f26cdc..94d589e86c9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -32,6 +32,8 @@ as: :voice_otp, defaults: { format: :xml } + post '/api/usps_upload' => 'usps_upload#create' + get '/openid_connect/authorize' => 'openid_connect/authorization#index' get '/openid_connect/logout' => 'openid_connect/logout#index' @@ -51,6 +53,16 @@ post '/' => 'users/sessions#create', as: :user_session get '/active' => 'users/sessions#active' + get '/account_reset/request' => 'account_reset/request#show' + post '/account_reset/request' => 'account_reset/request#create' + get '/account_reset/cancel' => 'account_reset/cancel#cancel' + get '/account_reset/report_fraud' => 'account_reset/report_fraud#update' + get '/account_reset/confirm_request' => 'account_reset/confirm_request#show' + get '/account_reset/delete_account' => 'account_reset/delete_account#show' + delete '/account_reset/delete_account' => 'account_reset/delete_account#delete' + 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/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/schedule.rb b/config/schedule.rb deleted file mode 100644 index 0ae5efdcab1..00000000000 --- a/config/schedule.rb +++ /dev/null @@ -1,24 +0,0 @@ -# Use this file to easily define all of your cron jobs. -# -# It's helpful, but not entirely necessary to understand cron before proceeding. -# http://en.wikipedia.org/wiki/Cron -# Learn more: http://github.com/javan/whenever - -set :output, '/srv/idp/shared/log/cron.log' -env :PATH, ENV['PATH'] - -require File.expand_path(File.dirname(__FILE__) + '/environment') - -health_check = Whenever.seconds(Figaro.env.queue_health_check_frequency_seconds.to_i, :seconds) - -every health_check, roles: [:job_creator] do - runner 'WorkerHealthChecker.enqueue_dummy_jobs' -end - -if FeatureManagement.enable_usps_verification? - mail_batch = Whenever.seconds(Figaro.env.usps_mail_batch_hours.to_i, :hours) - - every mail_batch, roles: [:job_creator] do - runner 'UspsUploader.new.run' - end -end diff --git a/config/service_providers.yml b/config/service_providers.yml index 4d414089b72..d34bef38a94 100644 --- a/config/service_providers.yml +++ b/config/service_providers.yml @@ -584,7 +584,7 @@ production: - 'https://portal.dot.gov/' restrict_to_deploy_env: 'prod' - # NGA GEOWorks Symphony + # NGA GEOWorks Symphony 'urn:gov:gsa:openidconnect.profiles:sp:sso:mitre:symphony': agency_id: 5 friendly_name: 'GEOWorks/Symphony' @@ -628,7 +628,10 @@ production: - 'https://office.dp3.us' - 'https://office.dp3.us/auth/login-gov/callback' restrict_to_deploy_env: 'prod' - + attribute_bundle: + - x509_subject + - x509_presented + # My Move.mil 'urn:gov:gsa:openidconnect.profiles:sp:sso:dod:mymovemilprod': agency_id: 8 @@ -643,6 +646,9 @@ production: - 'https://my.dp3.us' - 'https://my.dp3.us/auth/login-gov/callback' restrict_to_deploy_env: 'prod' + attribute_bundle: + - x509_subject + - x509_presented # DOT – National Registry of Certified Medical Examiners App 'urn:gov:dot:openidconnect.profiles:sp:sso:dot:nr_auth': @@ -700,3 +706,14 @@ production: - 'https://fossil.energy.gov/fergas-fe/#/login_redirect' - 'https://fossil.energy.gov/fergas-fe/#/notice/15' restrict_to_deploy_env: 'prod' + + # MY CBP + 'urn:gov:dhs.cbp.opa.mycbp:openidconnect:prod': + agency_id: 1 + friendly_name: 'MyCBP' + agency: 'DHS' + logo: 'mycbp.png' + return_to_sp_url: 'gov.dhs.cbp.opa.mycbp://result' + redirect_uris: + - 'gov.dhs.cbp.opa.mycbp://result' + restrict_to_deploy_env: 'prod' diff --git a/db/migrate/20180619145839_create_password_metrics.rb b/db/migrate/20180619145839_create_password_metrics.rb new file mode 100644 index 00000000000..a8492d952fe --- /dev/null +++ b/db/migrate/20180619145839_create_password_metrics.rb @@ -0,0 +1,12 @@ +class CreatePasswordMetrics < ActiveRecord::Migration[5.1] + def change + create_table :password_metrics do |t| + t.integer :metric, null: false + t.float :value, null: false + t.integer :count, null: false + t.index :metric + t.index :value + t.index [:metric, :value], unique: true + end + end +end diff --git a/db/migrate/20180620233914_create_account_reset_requests.rb b/db/migrate/20180620233914_create_account_reset_requests.rb new file mode 100644 index 00000000000..1d70fde3b56 --- /dev/null +++ b/db/migrate/20180620233914_create_account_reset_requests.rb @@ -0,0 +1,18 @@ +class CreateAccountResetRequests < ActiveRecord::Migration[5.1] + def change + create_table :account_reset_requests do |t| + t.integer :user_id, null: false + t.datetime :requested_at + t.string :request_token + t.datetime :cancelled_at + t.datetime :reported_fraud_at + t.datetime :granted_at + t.string :granted_token + t.timestamps + t.index ['user_id'], unique: true + t.index ['cancelled_at','granted_at','requested_at'], name: 'index_account_reset_requests_on_timestamps' + t.index ['request_token'], unique: true + t.index ['granted_token'], unique: true + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 62ed4fc141c..b5d46bfb506 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,11 +10,27 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20180607144007) do +ActiveRecord::Schema.define(version: 20180620233914) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" + create_table "account_reset_requests", force: :cascade do |t| + t.integer "user_id", null: false + t.datetime "requested_at" + t.string "request_token" + t.datetime "cancelled_at" + t.datetime "reported_fraud_at" + t.datetime "granted_at" + t.string "granted_token" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["cancelled_at", "granted_at", "requested_at"], name: "index_account_reset_requests_on_timestamps" + t.index ["granted_token"], name: "index_account_reset_requests_on_granted_token", unique: true + t.index ["request_token"], name: "index_account_reset_requests_on_request_token", unique: true + t.index ["user_id"], name: "index_account_reset_requests_on_user_id", unique: true + end + create_table "agencies", force: :cascade do |t| t.string "name", null: false t.index ["name"], name: "index_agencies_on_name", unique: true @@ -80,6 +96,15 @@ t.index ["updated_at"], name: "index_otp_requests_trackers_on_updated_at" end + create_table "password_metrics", force: :cascade do |t| + t.integer "metric", null: false + t.float "value", null: false + t.integer "count", null: false + t.index ["metric", "value"], name: "index_password_metrics_on_metric_and_value", unique: true + t.index ["metric"], name: "index_password_metrics_on_metric" + t.index ["value"], name: "index_password_metrics_on_value" + end + create_table "profiles", force: :cascade do |t| t.integer "user_id", null: false t.boolean "active", default: false, null: false diff --git a/lib/feature_management.rb b/lib/feature_management.rb index 55eae529a8e..b0df4bc0544 100644 --- a/lib/feature_management.rb +++ b/lib/feature_management.rb @@ -101,4 +101,8 @@ def self.use_cloudhsm? def self.disallow_all_web_crawlers? Figaro.env.disallow_all_web_crawlers == 'true' end + + def self.account_reset_enabled? + Figaro.env.account_reset_enabled != 'false' # if value not set it defaults to enabled + end end diff --git a/lib/proofer_mocks/address_mock.rb b/lib/proofer_mocks/address_mock.rb index ccb3649ca52..2a4983c84c8 100644 --- a/lib/proofer_mocks/address_mock.rb +++ b/lib/proofer_mocks/address_mock.rb @@ -5,7 +5,7 @@ class AddressMock < Proofer::Base proof do |applicant, result| plain_phone = applicant[:phone].gsub(/\D/, '').gsub(/\A1/, '') - if plain_phone == '5555555555' + if plain_phone == '7035555555' result.add_error(:phone, 'The phone number could not be verified.') end result.context[:message] = 'some context for the mock address proofer' diff --git a/lib/rspec/formatters/user_flow_formatter.rb b/lib/rspec/formatters/user_flow_formatter.rb index 3176080cdfc..0c54939a13d 100644 --- a/lib/rspec/formatters/user_flow_formatter.rb +++ b/lib/rspec/formatters/user_flow_formatter.rb @@ -70,7 +70,7 @@ def stop(_notification) def flows_output_url host = Rails.application.config.action_controller.asset_host || 'localhost:3000' - protocol = host =~ /localhost/ ? 'http://' : 'https://' + protocol = host.match?(/localhost/) ? 'http://' : 'https://' "#{protocol}#{host}/user_flows" end diff --git a/lib/tasks/account_reset.rake b/lib/tasks/account_reset.rake new file mode 100644 index 00000000000..5b8e5ceb4f2 --- /dev/null +++ b/lib/tasks/account_reset.rake @@ -0,0 +1,6 @@ +namespace :account_reset do + desc 'Send Notifications' + task send_notifications: :environment do + AccountResetService.grant_tokens_and_send_notifications + end +end diff --git a/lib/tasks/remote_settings.rake b/lib/tasks/remote_settings.rake index f43c6db5593..9ea98455b48 100644 --- a/lib/tasks/remote_settings.rake +++ b/lib/tasks/remote_settings.rake @@ -1,11 +1,11 @@ namespace :remote_settings do - task :update, [:name, :url] => [:environment] do |task, args| + task :update, %i[name url] => [:environment] do |_task, args| RemoteSettingsService.update_setting(args[:name], args[:url]) - Kernel.puts "Update successful" + Kernel.puts 'Update successful' end - task :view, [:name] => [:environment] do |task, args| - Kernel.puts RemoteSetting.find_by(name: args[:name])&.contents + task :view, [:name] => [:environment] do |_task, args| + Kernel.puts RemoteSetting.find_by(name: args[:name])&.contents end task list: :environment do @@ -14,13 +14,15 @@ namespace :remote_settings do end end - task :delete, [:name] => [:environment] do |task, args| + task :delete, [:name] => [:environment] do |_task, args| RemoteSetting.where(name: args[:name]).delete_all - Kernel.puts "Delete successful" + Kernel.puts 'Delete successful' end end +# rubocop:disable Metrics/LineLength # example invocations: # rake "remote_settings:update[agencies.yml,https://raw.githubusercontent.com/18F/identity-idp/master/config/agencies.yml]" # rake "remote_settings:update[agencies.yml,https://login.gov/assets/idp/config/agencies.yml" # rake "remote_settings:update[service_providers.yml,https://raw.githubusercontent.com/18F/identity-idp/master/config/service_providers.yml]" +# rubocop:enable Metrics/LineLength diff --git a/spec/controllers/account_reset/cancel_controller_spec.rb b/spec/controllers/account_reset/cancel_controller_spec.rb new file mode 100644 index 00000000000..521243c1c37 --- /dev/null +++ b/spec/controllers/account_reset/cancel_controller_spec.rb @@ -0,0 +1,31 @@ +require 'rails_helper' + +describe AccountReset::CancelController do + 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}) + + 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}) + + post :cancel, params: {token:'FOO'} + end + + it 'redirects to the root' do + + post :cancel + expect(response).to redirect_to root_url + end + end +end diff --git a/spec/controllers/account_reset/confirm_delete_account_controller_spec.rb b/spec/controllers/account_reset/confirm_delete_account_controller_spec.rb new file mode 100644 index 00000000000..1f3fcd84386 --- /dev/null +++ b/spec/controllers/account_reset/confirm_delete_account_controller_spec.rb @@ -0,0 +1,23 @@ +require 'rails_helper' + +RSpec.describe AccountReset::ConfirmDeleteAccountController do + describe '#show' do + context 'email in session' do + it 'renders the page and deletes the email from the session' do + allow(controller).to receive(:flash).and_return(email: 'test@example.com') + + get :show + + expect(response).to render_template(:show) + end + end + + context 'no email in session' do + it 'redirects to the new user registration path' do + get :show + + expect(response).to redirect_to(root_url) + end + end + end +end diff --git a/spec/controllers/account_reset/confirm_request_controller_spec.rb b/spec/controllers/account_reset/confirm_request_controller_spec.rb new file mode 100644 index 00000000000..fc582f00a8e --- /dev/null +++ b/spec/controllers/account_reset/confirm_request_controller_spec.rb @@ -0,0 +1,23 @@ +require 'rails_helper' + +RSpec.describe AccountReset::ConfirmRequestController do + describe '#show' do + context 'email in session' do + it 'renders the page and deletes the email from the session' do + allow(controller).to receive(:flash).and_return(email: 'test@example.com') + + get :show + + expect(response).to render_template(:show) + end + end + + context 'no email in session' do + it 'redirects to the new user registration path' do + get :show + + expect(response).to redirect_to(root_url) + end + 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 new file mode 100644 index 00000000000..e045fcccc91 --- /dev/null +++ b/spec/controllers/account_reset/delete_account_controller_spec.rb @@ -0,0 +1,61 @@ +require 'rails_helper' + +describe AccountReset::DeleteAccountController do + describe '#delete' do + it 'logs a good token to the analytics' do + user = create(:user) + AccountResetService.new(user).create_request + AccountResetService.new(user).grant_request + + 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}) + + delete :delete + end + + 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}) + + delete :delete, params: {token:'FOO'} + end + + it 'redirects to root if there is no token' do + delete :delete + + expect(response).to redirect_to(root_url) + end + end + + describe '#show' do + it 'prevents parameter leak' do + user = create(:user) + AccountResetService.new(user).create_request + AccountResetService.new(user).grant_request + + 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'} + + expect(response).to redirect_to(root_url) + end + + it 'renders the page' do + user = create(:user) + AccountResetService.new(user).create_request + AccountResetService.new(user).grant_request + session[:granted_token] = AccountResetRequest.all[0].granted_token + + get :show + + expect(response).to render_template(:show) + end + end +end diff --git a/spec/controllers/account_reset/report_fraud_controller_spec.rb b/spec/controllers/account_reset/report_fraud_controller_spec.rb new file mode 100644 index 00000000000..8268c91bb3f --- /dev/null +++ b/spec/controllers/account_reset/report_fraud_controller_spec.rb @@ -0,0 +1,31 @@ +require 'rails_helper' + +describe AccountReset::ReportFraudController do + describe '#update' 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: :fraud, token_valid: true}) + + 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}) + + post :update, params: {token:'FOO'} + end + + it 'redirects to the root' do + + post :update + expect(response).to redirect_to root_url + end + end +end diff --git a/spec/controllers/account_reset/request_controller_spec.rb b/spec/controllers/account_reset/request_controller_spec.rb new file mode 100644 index 00000000000..d002d1675ff --- /dev/null +++ b/spec/controllers/account_reset/request_controller_spec.rb @@ -0,0 +1,54 @@ +require 'rails_helper' + +describe AccountReset::RequestController do + describe '#show' do + it 'renders the page' do + sign_in_before_2fa + + get :show + + expect(response).to render_template(:show) + end + + it 'redirects to root without 2fa' do + get :show + + expect(response).to redirect_to root_url + end + + it 'redirects to phone setup url if 2fa not setup' do + user = create(:user) + sign_in_before_2fa(user) + get :show + + expect(response).to redirect_to phone_setup_url + end + end + + describe '#create' do + it 'logs the request in the analytics' do + TwilioService.telephony_service = FakeSms + sign_in_before_2fa + + stub_analytics + expect(@analytics).to receive(:track_event). + with(Analytics::ACCOUNT_RESET, {event: :request}) + + post :create + end + + it 'redirects to root without 2fa' do + post :create + + expect(response).to redirect_to root_url + end + + it 'redirects to phone setup url if 2fa not setup' do + user = create(:user) + sign_in_before_2fa(user) + post :create + + expect(response).to redirect_to phone_setup_url + end + end +end diff --git a/spec/controllers/account_reset/send_notifications_controller_spec.rb b/spec/controllers/account_reset/send_notifications_controller_spec.rb new file mode 100644 index 00000000000..b32b17be624 --- /dev/null +++ b/spec/controllers/account_reset/send_notifications_controller_spec.rb @@ -0,0 +1,45 @@ +require 'rails_helper' + +describe AccountReset::SendNotificationsController do + describe '#update' do + context 'with a good auth token' do + before do + headers(Figaro.env.account_reset_auth_token) + end + + it 'returns ok' do + headers(Figaro.env.account_reset_auth_token) + post :update + + expect(response).to be_ok + end + + it 'logs the number of notifications sent in the analytics' do + allow(AccountResetService).to receive(:grant_tokens_and_send_notifications).and_return(7) + + stub_analytics + expect(@analytics).to receive(:track_event). + with(Analytics::ACCOUNT_RESET, event: :notifications, count: 7) + + post :update + end + end + + context 'with a bad auth token' do + before do + headers('foo') + end + + it 'returns unauthorized' do + headers('foo') + post :update + + expect(response.status).to eq 401 + end + end + end + + def headers(token) + request.headers['X-API-AUTH-TOKEN'] = token + end +end diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index 13f5d1437e7..f809aa3602f 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -244,6 +244,16 @@ def index end end + it 'returns a 400 bad request when a url generation error is raised on the redirect' do + allow_any_instance_of(ApplicationController).to \ + receive(:redirect_to).and_raise(ActionController::UrlGenerationError) + allow(subject).to receive(:current_user).and_return(user) + + get :index, params: { timeout: true, request_id: '123' } + + expect(response).to be_bad_request + end + context 'when there is no current user' do it 'displays a flash message' do allow(subject).to receive(:current_user).and_return(nil) diff --git a/spec/controllers/idv/phone_controller_spec.rb b/spec/controllers/idv/phone_controller_spec.rb index bcff48260b4..3b446905f8b 100644 --- a/spec/controllers/idv/phone_controller_spec.rb +++ b/spec/controllers/idv/phone_controller_spec.rb @@ -2,11 +2,12 @@ describe Idv::PhoneController do include Features::LocalizationHelper + include IdvHelper let(:max_attempts) { Idv::Attempter.idv_max_attempts } - let(:good_phone) { '+1 (555) 555-0000' } - let(:normalized_phone) { '5555550000' } - let(:bad_phone) { '+1 (555) 555-5555' } + let(:good_phone) { '+1 (703) 555-0000' } + let(:normalized_phone) { '7035550000' } + let(:bad_phone) { '+1 (703) 555-5555' } describe 'before_actions' do it 'includes authentication before_action' do @@ -84,7 +85,7 @@ result = { success: false, errors: { - phone: [invalid_phone_message], + phone: [t('errors.messages.must_have_us_country_code')], }, } diff --git a/spec/controllers/sign_up/passwords_controller_spec.rb b/spec/controllers/sign_up/passwords_controller_spec.rb index f36d41c7944..a092d064990 100644 --- a/spec/controllers/sign_up/passwords_controller_spec.rb +++ b/spec/controllers/sign_up/passwords_controller_spec.rb @@ -47,6 +47,17 @@ post :create, params: { password_form: { password: 'NewVal' }, confirmation_token: token } end + + it 'saves password metrics' do + token = 'new token' + params = { password_form: { password: 'saltypickles' }, confirmation_token: token } + create(:user, confirmation_token: token, confirmation_sent_at: Time.zone.now) + + post :create, params: params + + expect(PasswordMetric.where(metric: 'length', value: 12, count: 1).count).to eq(1) + expect(PasswordMetric.where(metric: 'guesses_log10', value: 7.1, count: 1).count).to eq(1) + end end describe '#new' do diff --git a/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb b/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb index 626233dae02..5cee782aa58 100644 --- a/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb +++ b/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb @@ -262,7 +262,7 @@ context 'phone confirmation' do before do sign_in_as_user - subject.user_session[:unconfirmed_phone] = '+1 (555) 555-5555' + subject.user_session[:unconfirmed_phone] = '+1 (703) 555-5555' subject.user_session[:context] = 'confirmation' @previous_phone_confirmed_at = subject.current_user.phone_confirmed_at subject.current_user.create_direct_otp @@ -318,11 +318,11 @@ end it 'does not clear session data' do - expect(subject.user_session[:unconfirmed_phone]).to eq('+1 (555) 555-5555') + expect(subject.user_session[:unconfirmed_phone]).to eq('+1 (703) 555-5555') end it 'does not update user phone or phone_confirmed_at attributes' do - expect(subject.current_user.phone).to eq('+1 (202) 555-1212') + expect(subject.current_user.phone).to eq('+1 202-555-1212') expect(subject.current_user.phone_confirmed_at).to eq(@previous_phone_confirmed_at) end @@ -433,8 +433,8 @@ idv_session = Idv::Session.new( user_session: subject.user_session, current_user: user, issuer: nil ) - idv_session.params = { 'phone' => '+1 (555) 555-5555' } - subject.user_session[:unconfirmed_phone] = '+1 (555) 555-5555' + idv_session.params = { 'phone' => '+1 (703) 555-5555' } + subject.user_session[:unconfirmed_phone] = '+1 (703) 555-5555' subject.user_session[:context] = 'idv' @previous_phone_confirmed_at = subject.current_user.phone_confirmed_at allow(subject).to receive(:idv_session).and_return(idv_session) @@ -489,7 +489,7 @@ end it 'does not update user phone attributes' do - expect(subject.current_user.reload.phone).to eq '+1 (202) 555-1212' + expect(subject.current_user.reload.phone).to eq '+1 202-555-1212' expect(subject.current_user.reload.phone_confirmed_at).to eq @previous_phone_confirmed_at end @@ -510,11 +510,11 @@ end it 'does not clear session data' do - expect(subject.user_session[:unconfirmed_phone]).to eq('+1 (555) 555-5555') + expect(subject.user_session[:unconfirmed_phone]).to eq('+1 (703) 555-5555') end it 'does not update user phone or phone_confirmed_at attributes' do - expect(subject.current_user.phone).to eq('+1 (202) 555-1212') + expect(subject.current_user.phone).to eq('+1 202-555-1212') expect(subject.current_user.phone_confirmed_at).to eq(@previous_phone_confirmed_at) expect(subject.idv_session.params['phone_confirmed_at']).to be_nil end diff --git a/spec/controllers/two_factor_authentication/piv_cac_verification_controller_spec.rb b/spec/controllers/two_factor_authentication/piv_cac_verification_controller_spec.rb index b77584f0a17..6d86cd32d07 100644 --- a/spec/controllers/two_factor_authentication/piv_cac_verification_controller_spec.rb +++ b/spec/controllers/two_factor_authentication/piv_cac_verification_controller_spec.rb @@ -3,7 +3,7 @@ describe TwoFactorAuthentication::PivCacVerificationController do let(:user) do create(:user, :signed_up, :with_piv_or_cac, - phone: '+1 (555) 555-0000') + phone: '+1 (703) 555-0000') end let(:nonce) { 'once' } @@ -15,22 +15,22 @@ allow(subject).to receive(:user_session).and_return(session_info) allow(PivCacService).to receive(:decode_token).with('good-token').and_return( 'uuid' => user.x509_dn_uuid, - 'dn' => x509_subject, + 'subject' => x509_subject, 'nonce' => nonce ) allow(PivCacService).to receive(:decode_token).with('good-other-token').and_return( 'uuid' => user.x509_dn_uuid + 'X', - 'dn' => x509_subject + 'X', + 'subject' => x509_subject + 'X', 'nonce' => nonce ) allow(PivCacService).to receive(:decode_token).with('bad-token').and_return( 'uuid' => 'bad-uuid', - 'dn' => 'bad-dn', + 'subject' => 'bad-dn', 'nonce' => nonce ) allow(PivCacService).to receive(:decode_token).with('bad-nonce').and_return( 'uuid' => user.x509_dn_uuid, - 'dn' => x509_subject, + 'subject' => x509_subject, 'nonce' => 'bad-' + nonce ) end diff --git a/spec/controllers/users/phones_controller_spec.rb b/spec/controllers/users/phones_controller_spec.rb index 320fe0ac5e7..d1cbcccf8e9 100644 --- a/spec/controllers/users/phones_controller_spec.rb +++ b/spec/controllers/users/phones_controller_spec.rb @@ -7,7 +7,7 @@ describe '#phone' do let(:user) { create(:user, :signed_up, phone: '+1 (202) 555-1234') } let(:second_user) { create(:user, :signed_up, phone: '+1 (202) 555-5678') } - let(:new_phone) { '555-555-5555' } + let(:new_phone) { '202-555-4321' } context 'user changes phone' do before do @@ -25,7 +25,7 @@ it 'lets user know they need to confirm their new phone' do expect(flash[:notice]).to eq t('devise.registrations.phone_update_needs_confirmation') - expect(user.reload.phone).to_not eq '+1 (555) 555-5555' + expect(user.reload.phone).to_not eq '+1 202-555-4321' expect(@analytics).to have_received(:track_event). with(Analytics::PHONE_CHANGE_REQUESTED) expect(response).to redirect_to( diff --git a/spec/controllers/users/piv_cac_authentication_setup_controller_spec.rb b/spec/controllers/users/piv_cac_authentication_setup_controller_spec.rb index bc5c746dba8..03d9a4afbb2 100644 --- a/spec/controllers/users/piv_cac_authentication_setup_controller_spec.rb +++ b/spec/controllers/users/piv_cac_authentication_setup_controller_spec.rb @@ -32,7 +32,7 @@ describe 'when signing in' do before(:each) { stub_sign_in_before_2fa(user) } let(:user) do - create(:user, :signed_up, :with_piv_or_cac, phone: '+1 (555) 555-0000') + create(:user, :signed_up, :with_piv_or_cac, phone: '+1 (703) 555-0000') end describe 'GET index' do @@ -55,7 +55,7 @@ context 'without associated piv/cac' do let(:user) do - create(:user, :signed_up, phone: '+1 (555) 555-0000') + create(:user, :signed_up, phone: '+1 (703) 555-0000') end before(:each) do @@ -69,7 +69,7 @@ let(:good_token) { 'good-token' } let(:good_token_response) do { - 'dn' => 'some dn', + 'subject' => 'some dn', 'uuid' => 'some-random-string', 'nonce' => nonce, } diff --git a/spec/controllers/users/reset_passwords_controller_spec.rb b/spec/controllers/users/reset_passwords_controller_spec.rb index 4cbcbda5b0a..db8d5220f1e 100644 --- a/spec/controllers/users/reset_passwords_controller_spec.rb +++ b/spec/controllers/users/reset_passwords_controller_spec.rb @@ -270,6 +270,29 @@ expect(response).to redirect_to new_user_session_path end end + + it 'increments password metrics' do + raw_reset_token, db_confirmation_token = + Devise.token_generator.generate(User, :reset_password_token) + + user = create( + :user, + :unconfirmed, + reset_password_token: db_confirmation_token, + reset_password_sent_at: Time.zone.now + ) + + stub_email_notifier(user) + + password = 'saltypickles' + params = { password: password } + + get :edit, params: { reset_password_token: raw_reset_token } + put :update, params: { reset_password_form: params } + + expect(PasswordMetric.where(metric: 'length', value: 12, count: 1).count).to eq(1) + expect(PasswordMetric.where(metric: 'guesses_log10', value: 7.1, count: 1).count).to eq(1) + end end describe '#create' do diff --git a/spec/controllers/users/two_factor_authentication_controller_spec.rb b/spec/controllers/users/two_factor_authentication_controller_spec.rb index 66f716b905c..c87b128c9da 100644 --- a/spec/controllers/users/two_factor_authentication_controller_spec.rb +++ b/spec/controllers/users/two_factor_authentication_controller_spec.rb @@ -135,7 +135,8 @@ def index expect(SmsOtpSenderJob).to have_received(:perform_later).with( code: subject.current_user.direct_otp, phone: subject.current_user.phone, - otp_created_at: subject.current_user.direct_otp_sent_at.to_s + otp_created_at: subject.current_user.direct_otp_sent_at.to_s, + locale: nil ) expect(subject.current_user.direct_otp).not_to eq(@old_otp) expect(subject.current_user.direct_otp).not_to be_nil @@ -203,7 +204,8 @@ def index expect(VoiceOtpSenderJob).to have_received(:perform_later).with( code: subject.current_user.direct_otp, phone: subject.current_user.phone, - otp_created_at: subject.current_user.direct_otp_sent_at.to_s + otp_created_at: subject.current_user.direct_otp_sent_at.to_s, + locale: nil ) expect(subject.current_user.direct_otp).not_to eq(@old_otp) expect(subject.current_user.direct_otp).not_to be_nil @@ -251,13 +253,14 @@ def index expect(SmsOtpSenderJob).to have_received(:perform_now).with( code: subject.current_user.direct_otp, phone: @unconfirmed_phone, - otp_created_at: subject.current_user.direct_otp_sent_at.to_s + otp_created_at: subject.current_user.direct_otp_sent_at.to_s, + locale: nil ) end it 'flashes an sms error when twilio responds with an sms error' do twilio_error = Twilio::REST::RestError.new( - '', FakeTwilioErrorResponse.new(TwilioService::SMS_ERROR_CODE) + '', FakeTwilioErrorResponse.new(21_614) ) allow(SmsOtpSenderJob).to receive(:perform_now).and_raise(twilio_error) @@ -268,7 +271,7 @@ def index it 'flashes an invalid error when twilio responds with an invalid error' do twilio_error = Twilio::REST::RestError.new( - '', FakeTwilioErrorResponse.new(TwilioService::INVALID_ERROR_CODE) + '', FakeTwilioErrorResponse.new(21_211) ) allow(SmsOtpSenderJob).to receive(:perform_now).and_raise(twilio_error) @@ -279,7 +282,7 @@ def index it 'flashes an error when twilio responds with an invalid calling area error' do twilio_error = Twilio::REST::RestError.new( - '', FakeTwilioErrorResponse.new(TwilioService::INVALID_CALLING_AREA_ERROR_CODE) + '', FakeTwilioErrorResponse.new(21_215) ) allow(VoiceOtpSenderJob).to receive(:perform_now).and_raise(twilio_error) @@ -291,7 +294,7 @@ def index it 'flashes an error when twilio responds with an invalid voice number' do twilio_error = Twilio::REST::RestError.new( - '', FakeTwilioErrorResponse.new(TwilioService::INVALID_VOICE_NUMBER_ERROR_CODE) + '', FakeTwilioErrorResponse.new(13_224) ) allow(VoiceOtpSenderJob).to receive(:perform_now).and_raise(twilio_error) @@ -312,7 +315,7 @@ def index expect(flash[:error]).to eq(failed_to_send_otp) end - it 'records an analytics event when Twilio responds with an error' do + it 'records an analytics event when Twilio responds with a RestError' do stub_analytics twilio_error = Twilio::REST::RestError.new( 'error message', FakeTwilioErrorResponse.new @@ -337,6 +340,32 @@ def index get :send_code, params: { otp_delivery_selection_form: { otp_delivery_preference: 'sms' } } end + + it 'records an analytics event when Twilio responds with a VerifyError' do + stub_analytics + code = 60_033 + error_message = 'error' + verify_error = PhoneVerification::VerifyError.new(code: code, message: error_message) + + allow(SmsOtpSenderJob).to receive(:perform_now).and_raise(verify_error) + analytics_hash = { + success: true, + errors: {}, + otp_delivery_preference: 'sms', + resend: nil, + context: 'confirmation', + country_code: '1', + area_code: '202', + } + + 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) + + get :send_code, params: { otp_delivery_selection_form: { otp_delivery_preference: 'sms' } } + end end context 'when selecting an invalid delivery method' do diff --git a/spec/controllers/users/verify_profile_phone_controller_spec.rb b/spec/controllers/users/verify_profile_phone_controller_spec.rb index a7621443198..980d2b084f6 100644 --- a/spec/controllers/users/verify_profile_phone_controller_spec.rb +++ b/spec/controllers/users/verify_profile_phone_controller_spec.rb @@ -30,7 +30,7 @@ end context 'phone is confirmed and different than 2FA' do - let(:profile_phone) { '555-555-9999' } + let(:profile_phone) { '703-555-9999' } let(:phone_confirmed) { true } it 'redirects to OTP confirmation flow' do diff --git a/spec/controllers/usps_upload_controller_spec.rb b/spec/controllers/usps_upload_controller_spec.rb new file mode 100644 index 00000000000..5a9d692af91 --- /dev/null +++ b/spec/controllers/usps_upload_controller_spec.rb @@ -0,0 +1,59 @@ +require 'rails_helper' + +describe UspsUploadController do + describe '#create' do + context 'with no token' do + it 'returns unauthorized' do + post :create + + expect(response.status).to eq 401 + end + end + + context 'with an invalid token' do + before do + headers('foobar') + end + + it 'returns unauthorized' do + post :create + + expect(response.status).to eq 401 + end + end + + context 'with a valid token' do + before do + headers(Figaro.env.usps_upload_token) + end + + context 'on a federal workday' do + it 'runs the uploader' do + usps_uploader = instance_double(UspsUploader) + expect(usps_uploader).to receive(:run) + expect(UspsUploader).to receive(:new).and_return(usps_uploader) + + post :create + + expect(response).to have_http_status(:ok) + end + end + + context 'on a federal holiday' do + it 'does not run the uploader' do + expect(controller).to receive(:today).and_return(Date.new(2019, 1, 1)) + + expect(UspsUploader).not_to receive(:new) + + post :create + + expect(response).to have_http_status(:ok) + end + end + end + end + + def headers(token) + request.headers['X-USPS-UPLOAD-TOKEN'] = token + end +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 6410f18f62e..6e640a3f68e 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -7,7 +7,7 @@ password '!1a Z@6s' * 16 # Maximum length password. trait :with_phone do - phone '+1 (202) 555-1212' + phone '+1 202-555-1212' phone_confirmed_at Time.zone.now end diff --git a/spec/features/flows/sp_authentication_flows_spec.rb b/spec/features/flows/sp_authentication_flows_spec.rb index 6bffa8adce9..406395fcdae 100644 --- a/spec/features/flows/sp_authentication_flows_spec.rb +++ b/spec/features/flows/sp_authentication_flows_spec.rb @@ -334,9 +334,7 @@ def complete_phone_form_with_valid_phone phone = Faker::PhoneNumber.cell_phone - until PhonyRails.plausible_number? phone, country_code: :us - phone = Faker::PhoneNumber.cell_phone - end + phone = Faker::PhoneNumber.cell_phone until Phonelib.valid_for_country?(phone, 'US') fill_in 'user_phone_form_phone', with: phone end end diff --git a/spec/features/idv/steps/phone_otp_delivery_selection_step_spec.rb b/spec/features/idv/steps/phone_otp_delivery_selection_step_spec.rb index fb2597d6764..493eebee4f7 100644 --- a/spec/features/idv/steps/phone_otp_delivery_selection_step_spec.rb +++ b/spec/features/idv/steps/phone_otp_delivery_selection_step_spec.rb @@ -31,24 +31,21 @@ end end - context 'with a voice unsupported number' do - let(:unsupported_phone) { '242-555-5000' } + context 'with a non-US number' do + let(:bahamas_phone) { '+12423270143' } before do start_idv_from_sp complete_idv_steps_before_phone_step - fill_out_phone_form_ok(unsupported_phone) + fill_out_phone_form_ok(bahamas_phone) click_idv_continue end - it 'sends a sms even if the user chooses voice' do + it 'displays an error message' do expect(VoiceOtpSenderJob).to_not receive(:perform_later) - expect(SmsOtpSenderJob).to receive(:perform_later) - - choose_idv_otp_delivery_method_voice - - expect(page).to_not have_content(t('links.two_factor_authentication.resend_code.phone')) - expect(current_path).to eq(login_two_factor_path(otp_delivery_preference: :sms)) + expect(SmsOtpSenderJob).to_not receive(:perform_later) + expect(page).to have_content(t('errors.messages.must_have_us_country_code')) + expect(current_path).to eq(idv_phone_path) end end diff --git a/spec/features/idv/steps/phone_step_spec.rb b/spec/features/idv/steps/phone_step_spec.rb index 6de613e9e56..29c4cfbd80c 100644 --- a/spec/features/idv/steps/phone_step_spec.rb +++ b/spec/features/idv/steps/phone_step_spec.rb @@ -2,6 +2,7 @@ feature 'idv profile step', :idv_job do include IdvStepHelper + include IdvHelper context 'with valid information' do it 'allows the user to continue to the phone otp delivery selection step' do @@ -28,8 +29,8 @@ context 'after submitting valid information' do it 'is re-entrant before confirming OTP' do - first_phone_number = '5551231234' - second_phone_number = '5557897890' + first_phone_number = '7032231234' + second_phone_number = '7037897890' start_idv_from_sp complete_idv_steps_before_phone_step @@ -37,7 +38,7 @@ click_idv_continue choose_idv_otp_delivery_method_sms - expect(page).to have_content(first_phone_number) + expect(page).to have_content('+1 703-223-1234') click_link t('forms.two_factor.try_again') @@ -48,7 +49,7 @@ click_idv_continue choose_idv_otp_delivery_method_sms - expect(page).to have_content(second_phone_number) + expect(page).to have_content('+1 703-789-7890') end it 'is not re-entrant after confirming OTP' do diff --git a/spec/features/idv/steps/review_step_spec.rb b/spec/features/idv/steps/review_step_spec.rb index 595c6f1232c..1363a55045d 100644 --- a/spec/features/idv/steps/review_step_spec.rb +++ b/spec/features/idv/steps/review_step_spec.rb @@ -13,7 +13,7 @@ expect(page).to have_content('Nowhere, VA 6604') expect(page).to have_content('January 02, 1980') expect(page).to have_content('666-66-1234') - expect(page).to have_content('+1 (555) 555-0000') + expect(page).to have_content('+1 202-555-1212') fill_in 'Password', with: 'this is not the right password' click_idv_continue @@ -80,13 +80,13 @@ fill_in 'Password', with: user_password click_continue - usps_confirmation_entry = UspsConfirmation.last.decrypted_entry + usps_confirmation_entry = UspsConfirmation.last.entry if sp == :saml - expect(usps_confirmation_entry.issuer). + expect(usps_confirmation_entry[:issuer]). to eq('https://rp1.serviceprovider.com/auth/saml/metadata') else - expect(usps_confirmation_entry.issuer). + expect(usps_confirmation_entry[:issuer]). to eq('urn:gov:gsa:openidconnect:sp:server') end end @@ -99,9 +99,9 @@ fill_in 'Password', with: user_password click_continue - usps_confirmation_entry = UspsConfirmation.last.decrypted_entry + usps_confirmation_entry = UspsConfirmation.last.entry - expect(usps_confirmation_entry.issuer).to eq(nil) + expect(usps_confirmation_entry[:issuer]).to eq(nil) 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 c0a6be1c74c..c4ca885c936 100644 --- a/spec/features/two_factor_authentication/change_factor_spec.rb +++ b/spec/features/two_factor_authentication/change_factor_spec.rb @@ -21,7 +21,7 @@ allow(UserMailer).to receive(:phone_changed).with(user).and_return(mailer) @previous_phone_confirmed_at = user.reload.phone_confirmed_at - new_phone = '+1 (703) 555-0100' + new_phone = '+1 703-555-0100' visit manage_phone_path @@ -56,7 +56,7 @@ scenario 'editing phone number with no voice otp support only allows sms delivery' do user.update(otp_delivery_preference: 'voice') - unsupported_phone = '242-555-5000' + unsupported_phone = '242-327-0143' visit manage_phone_path complete_2fa_confirmation @@ -64,7 +64,9 @@ allow(VoiceOtpSenderJob).to receive(:perform_later) allow(SmsOtpSenderJob).to receive(:perform_now) - update_phone_number(unsupported_phone) + select 'Bahamas', from: 'user_phone_form_international_code' + fill_in 'Phone', with: unsupported_phone + click_button t('forms.buttons.submit.confirm_change') expect(current_path).to eq login_two_factor_path(otp_delivery_preference: :sms) expect(VoiceOtpSenderJob).to_not have_received(:perform_later) @@ -88,7 +90,8 @@ with( code: user.reload.direct_otp, phone: old_phone, - otp_created_at: user.reload.direct_otp_sent_at.to_s + otp_created_at: user.reload.direct_otp_sent_at.to_s, + locale: nil ) expect(page).to have_content UserDecorator.new(user).masked_two_factor_phone_number @@ -112,7 +115,8 @@ with( code: user.reload.direct_otp, phone: old_phone, - otp_created_at: user.reload.direct_otp_sent_at.to_s + otp_created_at: user.reload.direct_otp_sent_at.to_s, + locale: nil ) expect(current_path). diff --git a/spec/features/two_factor_authentication/remember_device_spec.rb b/spec/features/two_factor_authentication/remember_device_spec.rb index 0e965c28f49..69b0c22013a 100644 --- a/spec/features/two_factor_authentication/remember_device_spec.rb +++ b/spec/features/two_factor_authentication/remember_device_spec.rb @@ -29,7 +29,7 @@ def remember_device_and_sign_out_user user = sign_up_and_set_password user.password = Features::SessionHelper::VALID_PASSWORD select_2fa_option('sms') - fill_in :user_phone_form_phone, with: '5551231234' + fill_in :user_phone_form_phone, with: '2025551212' click_send_security_code check :remember_device click_submit_default @@ -46,7 +46,7 @@ def remember_device_and_sign_out_user user = user_with_2fa sign_in_and_2fa_user(user) visit manage_phone_path - fill_in 'user_phone_form_phone', with: '5552347193' + fill_in 'user_phone_form_phone', with: '2022347193' click_button t('forms.buttons.submit.confirm_change') check :remember_device click_submit_default @@ -63,7 +63,7 @@ def remember_device_and_sign_out_user click_submit_default visit manage_phone_path - fill_in 'user_phone_form_phone', with: '5552347193' + fill_in 'user_phone_form_phone', with: '2022347193' click_button t('forms.buttons.submit.confirm_change') expect(current_path).to eq(login_two_factor_path(otp_delivery_preference: :sms)) @@ -81,7 +81,7 @@ def remember_device_and_sign_out_user fill_out_idv_form_ok click_idv_continue click_idv_continue - fill_out_phone_form_ok('5551603829') + fill_out_phone_form_ok('2022603829') click_idv_continue choose_idv_otp_delivery_method_sms end diff --git a/spec/features/two_factor_authentication/sign_in_spec.rb b/spec/features/two_factor_authentication/sign_in_spec.rb index b4b2edf38f0..61cf0da6b8a 100644 --- a/spec/features/two_factor_authentication/sign_in_spec.rb +++ b/spec/features/two_factor_authentication/sign_in_spec.rb @@ -24,17 +24,17 @@ submit_2fa_setup_form_with_empty_string_phone - expect(page).to have_content invalid_phone_message + expect(page).to have_content t('errors.messages.missing_field') submit_2fa_setup_form_with_invalid_phone - expect(page).to have_content invalid_phone_message + expect(page).to have_content t('errors.messages.missing_field') submit_2fa_setup_form_with_valid_phone expect(page).to_not have_content invalid_phone_message expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'sms') - expect(user.reload.phone).to_not eq '+1 (555) 555-1212' + expect(user.reload.phone).to_not eq '+1 (703) 555-1212' expect(user.sms?).to eq true end @@ -53,17 +53,17 @@ end end - context 'with U.S. phone that does not support voice delivery method' do - let(:unsupported_phone) { '242-555-5555' } + context 'with number that does not support phone delivery method' do + let(:unsupported_phone) { '242-327-0143' } - scenario 'renders an error if a user submits with voice selected' do + scenario 'renders an error if a user submits with JS disabled' do sign_in_before_2fa select_2fa_option('voice') + select 'Bahamas', from: 'user_phone_form_international_code' fill_in 'Phone', with: unsupported_phone click_send_security_code expect(current_path).to eq phone_setup_path - expect(page).to have_content t( 'devise.two_factor_authentication.otp_delivery_preference.phone_unsupported', location: 'Bahamas' @@ -103,6 +103,39 @@ expect(phone_field.value).to eq('12345678901234567890') end end + + context 'with SMS option, international number, and locale header' do + it 'passes locale to SmsOtpSenderJob' do + page.driver.header 'Accept-Language', 'ar' + PhoneVerification.adapter = FakeAdapter + allow(SmsOtpSenderJob).to receive(:perform_now) + + user = sign_in_before_2fa + select_2fa_option('sms') + select 'Morocco', from: 'user_phone_form_international_code' + fill_in 'user_phone_form_phone', with: '6 61 28 93 24' + click_send_security_code + + expect(SmsOtpSenderJob).to have_received(:perform_now).with( + code: user.reload.direct_otp, + phone: '+212 661-289324', + otp_created_at: user.direct_otp_sent_at.to_s, + locale: 'ar' + ) + expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'sms') + end + end + + context 'with voice option and US number' do + it 'sends the code via VoiceOtpSenderJob and redirects to prompt for the code' do + sign_in_before_2fa + select_2fa_option('voice') + fill_in 'user_phone_form_phone', with: '7035551212' + click_send_security_code + + expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'voice') + end + end end def phone_field @@ -134,7 +167,7 @@ def submit_2fa_setup_form_with_invalid_phone end def submit_2fa_setup_form_with_valid_phone - fill_in 'user_phone_form_phone', with: '555-555-1212' + fill_in 'user_phone_form_phone', with: '703-555-1212' click_send_security_code end @@ -204,7 +237,7 @@ def submit_prefilled_otp_code end scenario 'the user cannot change delivery method if phone is unsupported' do - unsupported_phone = '+1 (242) 555-5000' + unsupported_phone = '+1 (242) 327-0143' user = create(:user, :signed_up, phone: unsupported_phone) sign_in_before_2fa(user) @@ -444,8 +477,6 @@ def submit_prefilled_otp_code user = user_with_piv_cac sign_in_before_2fa(user) - click_link t('devise.two_factor_authentication.piv_cac_fallback.link') - expect(current_path).to eq login_two_factor_piv_cac_path expect(page).not_to have_link(t('links.two_factor_authentication.app')) @@ -465,8 +496,6 @@ def submit_prefilled_otp_code user = create(:user, :signed_up, :with_piv_or_cac, otp_secret_key: 'foo') sign_in_before_2fa(user) - click_link t('devise.two_factor_authentication.piv_cac_fallback.link') - expect(current_path).to eq login_two_factor_piv_cac_path click_link t('links.two_factor_authentication.app') @@ -477,7 +506,6 @@ def submit_prefilled_otp_code scenario 'user can cancel PIV/CAC process' do user = create(:user, :signed_up, :with_piv_or_cac) sign_in_before_2fa(user) - click_link t('devise.two_factor_authentication.piv_cac_fallback.link') expect(current_path).to eq login_two_factor_piv_cac_path click_link t('links.cancel') @@ -515,6 +543,25 @@ def submit_prefilled_otp_code expect(current_path).to eq login_two_factor_piv_cac_path expect(page).to have_content(t('devise.two_factor_authentication.invalid_piv_cac')) end + + context 'with SMS, international number, and locale header' do + it 'passes locale to SmsOtpSenderJob' do + page.driver.header 'Accept-Language', 'ar' + PhoneVerification.adapter = FakeAdapter + allow(SmsOtpSenderJob).to receive(:perform_later) + + user = create(:user, :signed_up, phone: '+212 661-289324') + sign_in_user(user) + + expect(SmsOtpSenderJob).to have_received(:perform_later).with( + code: user.reload.direct_otp, + phone: '+212 661-289324', + otp_created_at: user.direct_otp_sent_at.to_s, + locale: 'ar' + ) + expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'sms') + end + end end describe 'when the user is not piv/cac enabled' do diff --git a/spec/features/users/sign_up_spec.rb b/spec/features/users/sign_up_spec.rb index 4d2ae4ccb76..575ded876b6 100644 --- a/spec/features/users/sign_up_spec.rb +++ b/spec/features/users/sign_up_spec.rb @@ -50,7 +50,7 @@ scenario 'renders an error when twilio api responds with an error' do twilio_error = Twilio::REST::RestError.new( - '', FakeTwilioErrorResponse.new(TwilioService::SMS_ERROR_CODE) + '', FakeTwilioErrorResponse.new(21_614) ) allow(SmsOtpSenderJob).to receive(:perform_now).and_raise(twilio_error) diff --git a/spec/features/users/user_edit_spec.rb b/spec/features/users/user_edit_spec.rb index 86c012d5814..f2a783447d1 100644 --- a/spec/features/users/user_edit_spec.rb +++ b/spec/features/users/user_edit_spec.rb @@ -53,7 +53,7 @@ allow(SmsOtpSenderJob).to receive(:perform_later) allow(VoiceOtpSenderJob).to receive(:perform_now) - fill_in 'user_phone_form_phone', with: '555-555-5000' + fill_in 'user_phone_form_phone', with: '703-555-5000' choose 'Phone call' click_button t('forms.buttons.submit.confirm_change') diff --git a/spec/features/users/verify_profile_spec.rb b/spec/features/users/verify_profile_spec.rb index e5c5d330b29..f02f6149ca2 100644 --- a/spec/features/users/verify_profile_spec.rb +++ b/spec/features/users/verify_profile_spec.rb @@ -8,7 +8,7 @@ profile = create( :profile, deactivation_reason: :verification_pending, - pii: { ssn: '666-66-1234', dob: '1920-01-01', phone: '555-555-9999' }, + pii: { ssn: '666-66-1234', dob: '1920-01-01', phone: '703-555-9999' }, phone_confirmed: phone_confirmed, user: user ) diff --git a/spec/features/visitors/phone_confirmation_spec.rb b/spec/features/visitors/phone_confirmation_spec.rb index c9e67734309..a4b6fea9395 100644 --- a/spec/features/visitors/phone_confirmation_spec.rb +++ b/spec/features/visitors/phone_confirmation_spec.rb @@ -7,13 +7,14 @@ allow(SmsOtpSenderJob).to receive(:perform_now) @user = sign_in_before_2fa select_2fa_option('sms') - fill_in 'user_phone_form_phone', with: '555-555-5555' + fill_in 'user_phone_form_phone', with: '703-555-5555' click_send_security_code expect(SmsOtpSenderJob).to have_received(:perform_now).with( code: @user.reload.direct_otp, - phone: '+1 (555) 555-5555', - otp_created_at: @user.direct_otp_sent_at.to_s + phone: '+1 703-555-5555', + otp_created_at: @user.direct_otp_sent_at.to_s, + locale: nil ) end @@ -49,7 +50,7 @@ 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 (555) 555-5555', + number: '+1 (703) 555-5555', resend_code_link: t('links.two_factor_authentication.resend_code.sms')) ) end diff --git a/spec/forms/idv/phone_form_spec.rb b/spec/forms/idv/phone_form_spec.rb index d4ac7eeabc1..257b0df8bbd 100644 --- a/spec/forms/idv/phone_form_spec.rb +++ b/spec/forms/idv/phone_form_spec.rb @@ -2,7 +2,7 @@ describe Idv::PhoneForm do let(:user) { build_stubbed(:user, :signed_up) } - let(:params) { { phone: '555-555-5000' } } + let(:params) { { phone: '703-555-5000' } } subject { Idv::PhoneForm.new({}, user) } @@ -52,10 +52,10 @@ end it 'uses the user phone number as the initial phone value' do - user = build_stubbed(:user, :signed_up, phone: '555-555-1234') + user = build_stubbed(:user, :signed_up, phone: '7035551234') subject = Idv::PhoneForm.new({}, user) - expect(subject.phone).to eq('+1 (555) 555-1234') + expect(subject.phone).to eq('+1 703-555-1234') end it 'does not use an international number as the initial phone value' do @@ -65,11 +65,14 @@ expect(subject.phone).to eq(nil) end - it 'does not allow numbers with a non-US country code' do - result = subject.submit(phone: '+81 54 354 3643') + it 'does not allow non-US numbers' do + invalid_phones = ['+81 54 354 3643', '+12423270143'] + invalid_phones.each do |phone| + result = subject.submit(phone: phone) - expect(result.success?).to eq(false) - expect(result.errors[:phone]).to include(t('errors.messages.must_have_us_country_code')) + expect(result.success?).to eq(false) + expect(result.errors[:phone]).to include(t('errors.messages.must_have_us_country_code')) + end end end end diff --git a/spec/forms/update_user_password_form_spec.rb b/spec/forms/update_user_password_form_spec.rb index 5ab385548f6..d49045b5632 100644 --- a/spec/forms/update_user_password_form_spec.rb +++ b/spec/forms/update_user_password_form_spec.rb @@ -65,6 +65,16 @@ expect(email_notifier).to have_received(:send_password_changed_email) end + + it 'increments password metrics for the password' do + params[:password] = 'saltypickles' + stub_email_delivery + + subject.submit(params) + + expect(PasswordMetric.where(metric: 'length', value: 12, count: 1).count).to eq(1) + expect(PasswordMetric.where(metric: 'guesses_log10', value: 7.1, count: 1).count).to eq(1) + end end context 'when the user has an active profile' do diff --git a/spec/forms/user_phone_form_spec.rb b/spec/forms/user_phone_form_spec.rb index 67a72c864e2..3f33cd69e19 100644 --- a/spec/forms/user_phone_form_spec.rb +++ b/spec/forms/user_phone_form_spec.rb @@ -1,10 +1,12 @@ require 'rails_helper' describe UserPhoneForm do + include Shoulda::Matchers::ActiveModel + let(:user) { build(:user, :signed_up) } let(:params) do { - phone: '555-555-5000', + phone: '703-555-5000', international_code: 'US', otp_delivery_preference: 'sms', } @@ -16,7 +18,7 @@ it 'loads initial values from the user object' do user = build_stubbed( :user, - phone: '+1 (555) 500-5000', + phone: '+1 (703) 500-5000', otp_delivery_preference: 'voice' ) subject = UserPhoneForm.new(user) @@ -33,6 +35,23 @@ expect(subject.international_code).to eq('JP') end + describe 'phone validation' do + it do + should validate_inclusion_of(:international_code). + in_array(PhoneNumberCapabilities::INTERNATIONAL_CODES.keys) + end + + it 'validates that the number matches the requested international code' do + params[:phone] = '123 123 1234' + params[:international_code] = 'MA' + result = subject.submit(params) + + expect(result).to be_kind_of(FormResponse) + expect(result.success?).to eq(false) + expect(result.errors).to include(:phone) + end + end + describe '#submit' do context 'when phone is valid' do it 'is valid' do @@ -74,7 +93,7 @@ end context 'when otp_delivery_preference is voice and phone number does not support voice' do - let(:unsupported_phone) { '242-555-5000' } + let(:unsupported_phone) { '242-327-0143' } let(:params) do { phone: unsupported_phone, @@ -154,7 +173,7 @@ expect(user_updater).to receive(:call) params = { - phone: '555-555-5000', + phone: '703-555-5000', international_code: 'US', otp_delivery_preference: 'voice', } diff --git a/spec/jobs/sms_account_reset_notifier_job_spec.rb b/spec/jobs/sms_account_reset_notifier_job_spec.rb new file mode 100644 index 00000000000..1c220653cd5 --- /dev/null +++ b/spec/jobs/sms_account_reset_notifier_job_spec.rb @@ -0,0 +1,40 @@ +require 'rails_helper' + +describe SmsAccountResetNotifierJob do + include Features::ActiveJobHelper + include Rails.application.routes.url_helpers + + describe '.perform' do + before do + reset_job_queues + TwilioService.telephony_service = FakeSms + FakeSms.messages = [] + end + + subject(:perform) do + SmsAccountResetNotifierJob.perform_now( + phone: '+1 (888) 555-5555', + cancel_token: 'UUID1' + ) + end + + 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 + + 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_notifier_job.message', app: APP_NAME, + cancel_link: account_reset_cancel_url(token: 'UUID1'))) + end + end +end \ No newline at end of file diff --git a/spec/jobs/sms_otp_sender_job_spec.rb b/spec/jobs/sms_otp_sender_job_spec.rb index 28b3cd46943..288fa3bd67a 100644 --- a/spec/jobs/sms_otp_sender_job_spec.rb +++ b/spec/jobs/sms_otp_sender_job_spec.rb @@ -82,5 +82,27 @@ expect(ActiveJob::Base.queue_adapter.enqueued_jobs).to eq [] end end + + context 'when the parsed country of the phone number is not US' do + it 'sends the SMS via PhoneVerification class' do + PhoneVerification.adapter = FakeAdapter + phone = '+1 787-327-0143' + code = '123456' + verification = instance_double(PhoneVerification) + locale = 'fr' + + expect(PhoneVerification).to receive(:new). + with(phone: phone, locale: locale, code: code). + and_return(verification) + expect(verification).to receive(:send_sms) + + SmsOtpSenderJob.perform_now( + code: code, + phone: phone, + otp_created_at: otp_created_at, + locale: locale + ) + end + end end end diff --git a/spec/lib/feature_management_spec.rb b/spec/lib/feature_management_spec.rb index b2cf426938b..59d208348d7 100644 --- a/spec/lib/feature_management_spec.rb +++ b/spec/lib/feature_management_spec.rb @@ -431,4 +431,26 @@ expect(FeatureManagement.disallow_all_web_crawlers?).to eq(false) end end + + describe '#account_reset_enabled?' do + context 'when enabled' do + before do + allow(Figaro.env).to receive(:account_reset_enabled).and_return('true') + end + + it 'enables the feature' do + expect(FeatureManagement.account_reset_enabled?).to eq(true) + end + end + + context 'when disabled' do + before do + allow(Figaro.env).to receive(:account_reset_enabled).and_return('false') + end + + it 'disables the feature' do + expect(FeatureManagement.account_reset_enabled?).to eq(false) + end + end + end end diff --git a/spec/lib/tasks/rotate_rake_spec.rb b/spec/lib/tasks/rotate_rake_spec.rb index b12a0b3d2e2..ae7fb955892 100644 --- a/spec/lib/tasks/rotate_rake_spec.rb +++ b/spec/lib/tasks/rotate_rake_spec.rb @@ -9,7 +9,7 @@ Rake.application.rake_require('lib/tasks/rotate', [Rails.root.to_s]) Rake::Task.define_task(:environment) - user = create(:user, phone: '555-555-5555') + user = create(:user, phone: '703-555-5555') old_email = user.email old_phone = user.phone old_encrypted_email = user.encrypted_email diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb index 1d1979e6e30..13e6c1ac0c2 100644 --- a/spec/mailers/user_mailer_spec.rb +++ b/spec/mailers/user_mailer_spec.rb @@ -130,4 +130,65 @@ def expect_email_body_to_have_help_and_contact_links t('user_mailer.contact_link_text'), href: MarketingSite.contact_url ) end + + describe 'account_reset_request' do + let(:mail) { UserMailer.account_reset_request(user) } + + it_behaves_like 'a system email' + + it 'sends to the current email' do + expect(mail.to).to eq [user.email] + end + + it 'renders the subject' do + expect(mail.subject).to eq t('user_mailer.account_reset_request.subject') + 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')))) + end + end + + describe 'account_reset_granted' do + let(:mail) { UserMailer.account_reset_granted(user, user.account_reset_request) } + + it_behaves_like 'a system email' + + it 'sends to the current email' do + expect(mail.to).to eq [user.email] + end + + it 'renders the subject' do + expect(mail.subject).to eq t('user_mailer.account_reset_granted.subject') + end + + it 'renders the body' do + expect(mail.html_part.body).to \ + have_content(strip_tags(t('user_mailer.account_reset_granted.intro'))) + end + end + + describe 'account_reset_complete' do + let(:mail) { UserMailer.account_reset_complete(user.email) } + + it_behaves_like 'a system email' + + it 'sends to the current email' do + expect(mail.to).to eq [user.email] + end + + it 'renders the subject' do + expect(mail.subject).to eq t('user_mailer.account_reset_complete.subject') + end + + it 'renders the body' do + expect(mail.html_part.body).to have_content(strip_tags(t('user_mailer.account_reset_complete.intro'))) + end + end + + def strip_tags(str) + ActionController::Base.helpers.strip_tags(str) + end end diff --git a/spec/models/account_reset_request_spec.rb b/spec/models/account_reset_request_spec.rb new file mode 100644 index 00000000000..41ede864ac7 --- /dev/null +++ b/spec/models/account_reset_request_spec.rb @@ -0,0 +1,49 @@ +require 'rails_helper' + +describe AccountResetRequest do + it { is_expected.to belong_to(:user) } + + let(:subject) { AccountResetRequest.new } + + describe '#granted_token_valid?' do + it 'returns false if the token does not exist' do + subject.granted_token = nil + subject.granted_at = Time.zone.now + + expect(subject.granted_token_valid?).to eq(false) + end + + it 'returns false if the token is expired' do + subject.granted_token = '123' + subject.granted_at = Time.zone.now - 7.days + + expect(subject.granted_token_valid?).to eq(false) + end + + it 'returns true if the token is valid' do + subject.granted_token = '123' + subject.granted_at = Time.zone.now + + expect(subject.granted_token_valid?).to eq(true) + end + end + + describe '.from_valid_granted_token' do + it 'returns nil if the token does not exist' do + expect(AccountResetRequest.from_valid_granted_token('123')).to eq(nil) + end + + it 'returns nil if the token is expired' do + granted_at = Time.zone.now - 7.days + AccountResetRequest.create(id: 1, user_id: 2, granted_token: '123', granted_at: granted_at) + + expect(AccountResetRequest.from_valid_granted_token('123')).to eq(nil) + 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) + + expect(AccountResetRequest.from_valid_granted_token('123')).to eq(arr) + end + end +end diff --git a/spec/models/password_metric_spec.rb b/spec/models/password_metric_spec.rb new file mode 100644 index 00000000000..50a04f565de --- /dev/null +++ b/spec/models/password_metric_spec.rb @@ -0,0 +1,48 @@ +require 'rails_helper' + +describe PasswordMetric do + describe '.increment' do + context 'when a metric with the given metric and value does not exists' do + it 'creates the metric with a count of 1' do + PasswordMetric.increment(:length, 10) + + expect(PasswordMetric.count).to eq(1) + expect(PasswordMetric.find_by(metric: :length, value: 10).count).to eq(1) + end + end + + context 'when a metric with the same value exists' do + before do + PasswordMetric.create( + metric: 'length', + value: 9.0, + count: 2 + ) + end + + it 'creates the metric with a value of 1' do + PasswordMetric.increment(:length, 10) + + expect(PasswordMetric.count).to eq(2) + expect(PasswordMetric.find_by(metric: :length, value: 10).count).to eq(1) + end + end + + context 'when a metric with the given category and value does exist' do + before do + PasswordMetric.create( + metric: 'length', + value: 10.0, + count: 1 + ) + end + + it 'increments the value' do + PasswordMetric.increment(:length, 10) + + expect(PasswordMetric.count).to eq(1) + expect(PasswordMetric.find_by(metric: :length, value: 10).count).to eq(2) + end + end + end +end diff --git a/spec/models/remote_setting_spec.rb b/spec/models/remote_setting_spec.rb index d3dd53de2b9..326627bccea 100644 --- a/spec/models/remote_setting_spec.rb +++ b/spec/models/remote_setting_spec.rb @@ -4,19 +4,19 @@ describe 'validations' do it 'validates that our github repo is white listed' do location = 'https://raw.githubusercontent.com/18F/identity-idp/master/config/agencies.yml' - valid_setting = RemoteSetting.create(name: 'agencies.yml', url: location, contents:'') + valid_setting = RemoteSetting.create(name: 'agencies.yml', url: location, contents: '') expect(valid_setting).to be_valid end it 'validates that the login.gov static site is white listed' do location = 'https://login.gov/agencies.yml' - valid_setting = RemoteSetting.create(name: 'agencies.yml', url: location, contents:'') + valid_setting = RemoteSetting.create(name: 'agencies.yml', url: location, contents: '') expect(valid_setting).to be_valid end it 'does not accept http' do location = 'http://login.gov/agencies.yml' - valid_setting = RemoteSetting.create(name: 'agencies.yml', url: location, contents:'') + valid_setting = RemoteSetting.create(name: 'agencies.yml', url: location, contents: '') expect(valid_setting).to_not be_valid end end diff --git a/spec/models/service_provider_request_spec.rb b/spec/models/service_provider_request_spec.rb index d42762929a9..f0799e39305 100644 --- a/spec/models/service_provider_request_spec.rb +++ b/spec/models/service_provider_request_spec.rb @@ -17,6 +17,11 @@ expect(ServiceProviderRequest.from_uuid('123')). to be_an_instance_of NullServiceProviderRequest end + + it 'returns an instance of NullServiceProviderRequest when the uuid contains a null byte' do + expect(ServiceProviderRequest.from_uuid("\0")). + to be_an_instance_of NullServiceProviderRequest + end end end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 05cd592d676..8536068dcad 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -10,6 +10,7 @@ it { is_expected.to have_many(:agency_identities) } it { is_expected.to have_many(:profiles) } it { is_expected.to have_many(:events) } + it { is_expected.to have_one(:account_reset_request) } end it 'does not send an email when #create is called' do @@ -417,19 +418,4 @@ expect(user.authenticatable_salt).to eq(salt) end end - - context 'when a password is updated' do - it 'writes encrypted_password_digest and the legacy password attributes' do - user = create(:user) - - expected = { - encrypted_password: user.encrypted_password, - encryption_key: user.encryption_key, - password_salt: user.password_salt, - password_cost: user.password_cost, - }.to_json - - expect(user.encrypted_password_digest).to eq(expected) - end - end end diff --git a/spec/models/usps_confirmation_spec.rb b/spec/models/usps_confirmation_spec.rb deleted file mode 100644 index 6bfca07367c..00000000000 --- a/spec/models/usps_confirmation_spec.rb +++ /dev/null @@ -1,14 +0,0 @@ -require 'rails_helper' - -describe UspsConfirmation do - describe '#decrypted_entry' do - it 'returns plain entry' do - usps_confirmation = subject - usps_confirmation.entry = UspsConfirmationEntry.new_from_hash(otp: 123).encrypted - - plain_entry = usps_confirmation.decrypted_entry - - expect(plain_entry.otp).to eq 123 - end - end -end diff --git a/spec/presenters/idv/otp_delivery_method_presenter_spec.rb b/spec/presenters/idv/otp_delivery_method_presenter_spec.rb index 597e93f87aa..e547547e1a1 100644 --- a/spec/presenters/idv/otp_delivery_method_presenter_spec.rb +++ b/spec/presenters/idv/otp_delivery_method_presenter_spec.rb @@ -1,8 +1,8 @@ require 'rails_helper' describe Idv::OtpDeliveryMethodPresenter do - let(:phone) { '555-555-0000' } - let(:formatted_phone) { '+1 (555) 555-0000' } + let(:phone) { '(703) 555-0000' } + let(:formatted_phone) { '+1 703-555-0000' } let(:phone_number_capabilities) { PhoneNumberCapabilities.new(formatted_phone) } subject { Idv::OtpDeliveryMethodPresenter.new(phone) } diff --git a/spec/presenters/openid_connect_user_info_presenter_spec.rb b/spec/presenters/openid_connect_user_info_presenter_spec.rb index 9b722fc34d4..7a011cabcb9 100644 --- a/spec/presenters/openid_connect_user_info_presenter_spec.rb +++ b/spec/presenters/openid_connect_user_info_presenter_spec.rb @@ -84,7 +84,7 @@ city: 'Washington', state: 'DC', zipcode: '12345', - phone: '+1 (555) 555-5555', + phone: '+1 (703) 555-5555', ssn: '666661234', } end @@ -102,7 +102,7 @@ expect(user_info[:given_name]).to eq('John') expect(user_info[:family_name]).to eq('Smith') expect(user_info[:birthdate]).to eq('1970-01-01') - expect(user_info[:phone]).to eq('+1 (555) 555-5555') + expect(user_info[:phone]).to eq('+1 (703) 555-5555') expect(user_info[:phone_verified]).to eq(true) expect(user_info[:address]).to eq( formatted: "123 Fake St Apt 456\nWashington, DC 12345", @@ -132,7 +132,7 @@ expect(user_info[:given_name]).to eq(nil) expect(user_info[:family_name]).to eq(nil) expect(user_info[:birthdate]).to eq(nil) - expect(user_info[:phone]).to eq('+1 (555) 555-5555') + expect(user_info[:phone]).to eq('+1 (703) 555-5555') expect(user_info[:phone_verified]).to eq(true) expect(user_info[:address]).to eq(nil) expect(user_info[:social_security_number]).to eq(nil) 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 bc1521f30d8..3fdb8a3c622 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 @@ -53,6 +53,34 @@ 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) @@ -163,4 +191,16 @@ def presenter_with_locale(locale) view: view ) end + + def account_reset_cancel_link(account_reset_token) + I18n.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_delete_account_link + I18n.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/services/account_reset_service_spec.rb b/spec/services/account_reset_service_spec.rb new file mode 100644 index 00000000000..a9c3edca9d8 --- /dev/null +++ b/spec/services/account_reset_service_spec.rb @@ -0,0 +1,169 @@ +require 'rails_helper' + +describe AccountResetService do + include Rails.application.routes.url_helpers + + let(:user) { create(:user) } + let(:subject) { AccountResetService.new(user) } + let(:user2) { create(:user) } + let(:subject2) { AccountResetService.new(user2) } + + before { + allow(Figaro.env).to receive(:account_reset_wait_period_days).and_return('1') + } + + describe '#create_request' do + it 'creates a new account reset request on the user' do + subject.create_request + arr = user.account_reset_request + expect(arr.request_token).to be_present + expect(arr.requested_at).to be_present + expect(arr.cancelled_at).to be_nil + expect(arr.granted_at).to be_nil + expect(arr.granted_token).to be_nil + end + + it 'creates a new account reset request in the db' do + subject.create_request + arr = AccountResetRequest.find_by(user_id: user.id) + expect(arr.request_token).to be_present + expect(arr.requested_at).to be_present + expect(arr.cancelled_at).to be_nil + expect(arr.granted_at).to be_nil + expect(arr.granted_token).to be_nil + end + end + + 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) + 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 + end + + it 'does not raise an error for a cancel request with a blank token' do + AccountResetService.cancel_request('') + end + + it 'does not raise an error for a cancel request with a nil token' do + AccountResetService.cancel_request('') + end + + it 'does not raise an error for a cancel request with a bad token' do + AccountResetService.cancel_request('ABC') + end + end + + describe '#report_fraud' do + it 'removes tokens from the request' do + subject.create_request + AccountResetService.report_fraud(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.reported_fraud_at).to be_present + end + + it 'does not raise an error for a fraud request with a blank token' do + token_found = AccountResetService.report_fraud('') + expect(token_found).to be(false) + end + + it 'does not raise an error for a cancel request with a nil token' do + token_found = AccountResetService.report_fraud('') + expect(token_found).to be(false) + end + + it 'does not raise an error for a cancel request with a bad token' do + token_found = AccountResetService.report_fraud('ABC') + expect(token_found).to be(false) + end + end + + describe '#grant_request' do + it 'adds a notified at timestamp and granted token to the user' do + rd = subject + rd.create_request + rd.grant_request + arr = AccountResetRequest.find_by(user_id: user.id) + expect(arr.granted_at).to be_present + expect(arr.granted_token).to be_present + end + end + + describe '.grant_tokens_and_send_notifications' do + context 'after waiting the full wait period' do + it 'does not send notifications when the notifications were already sent' do + subject.create_request + + after_waiting_the_full_wait_period do + notifications_sent = 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 + AccountResetService.cancel_request(AccountResetRequest.all[0].request_token) + + after_waiting_the_full_wait_period do + notifications_sent = AccountResetService.grant_tokens_and_send_notifications + expect(notifications_sent).to eq(0) + end + end + + it 'sends notifications after a request is granted' do + subject.create_request + + after_waiting_the_full_wait_period do + notifications_sent = AccountResetService.grant_tokens_and_send_notifications + + expect(notifications_sent).to eq(1) + end + end + + it 'sends 2 notifications after 2 requests are granted' do + subject.create_request + subject2.create_request + + after_waiting_the_full_wait_period do + notifications_sent = AccountResetService.grant_tokens_and_send_notifications + + expect(notifications_sent).to eq(2) + end + end + end + + context 'after not waiting the full wait period' do + it 'does not send notifications after a request' do + subject.create_request + + notifications_sent = AccountResetService.grant_tokens_and_send_notifications + expect(notifications_sent).to eq(0) + end + + it 'does not send notifications when the request was cancelled' do + arr = subject.create_request + AccountResetService.cancel_request(AccountResetRequest.all[0].request_token) + + notifications_sent = AccountResetService.grant_tokens_and_send_notifications + expect(notifications_sent).to eq(0) + end + end + end + + def after_waiting_the_full_wait_period + TwilioService.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 diff --git a/spec/services/agency_seeder_spec.rb b/spec/services/agency_seeder_spec.rb index a02cb3798bb..280592200a9 100644 --- a/spec/services/agency_seeder_spec.rb +++ b/spec/services/agency_seeder_spec.rb @@ -36,7 +36,9 @@ context 'when agencies.yml has a remote setting' do before do location = 'https://raw.githubusercontent.com/18F/identity-idp/master/config/agencies.yml' - RemoteSetting.create(name: 'agencies.yml', url:location, contents: "test:\n 1:\n name: 'CBP'") + RemoteSetting.create( + name: 'agencies.yml', url: location, contents: "test:\n 1:\n name: 'CBP'" + ) end it 'updates the attributes based on the current value of the yml file' do diff --git a/spec/services/encrypted_attribute_spec.rb b/spec/services/encrypted_attribute_spec.rb index bb627374916..c3e208640fa 100644 --- a/spec/services/encrypted_attribute_spec.rb +++ b/spec/services/encrypted_attribute_spec.rb @@ -48,12 +48,21 @@ describe '#stale?' do it 'returns true when email was encrypted with old key' do + allow(Figaro.env).to receive(:attribute_encryption_without_kms).and_return('false') encrypted_with_old_key = encrypted_email rotate_attribute_encryption_key expect(EncryptedAttribute.new(encrypted_with_old_key).stale?).to eq true end + it 'returns true with legacy encryption and old key now switched to encryption without kms' do + encrypted_with_old_key = encrypted_email + rotate_attribute_encryption_key + allow(Figaro.env).to receive(:attribute_encryption_without_kms).and_return('true') + + expect(EncryptedAttribute.new(encrypted_with_old_key).stale?).to eq true + end + it 'returns false when email was encrypted with current key' do ee = EncryptedAttribute.new(encrypted_email) diff --git a/spec/services/encryption/encryptors/attribute_encryptor_spec.rb b/spec/services/encryption/encryptors/attribute_encryptor_spec.rb index 6906062a1bb..e7c3ee19275 100644 --- a/spec/services/encryption/encryptors/attribute_encryptor_spec.rb +++ b/spec/services/encryption/encryptors/attribute_encryptor_spec.rb @@ -8,8 +8,6 @@ let(:retired_cost) { '2000$8$1$' } before do - described_class.instance_variable_set(:@_scypt_hashes_by_key, nil) - allow(Figaro.env).to receive(:attribute_encryption_key).and_return(current_key) allow(Figaro.env).to receive(:attribute_cost).and_return(current_cost) allow(Figaro.env).to receive(:attribute_encryption_key_queue).and_return( @@ -18,73 +16,129 @@ end describe '#encrypt' do - it 'returns encrypted text' do - ciphertext = subject.encrypt(plaintext) + context 'with old kms based encryption' do + before do + allow(Figaro.env).to receive(:attribute_encryption_without_kms).and_return('false') + end - expect(ciphertext).to_not eq(plaintext) - end + it 'returns encrypted text' do + ciphertext = subject.encrypt(plaintext) - it 'only computes an scrypt hash the first time a key is used' do - expect(SCrypt::Engine).to receive(:hash_secret).once.and_call_original + expect(ciphertext).to_not eq(plaintext) + end + end - subject.encrypt(plaintext) + context 'with new non kms based encrytpion' do + before do + allow(Figaro.env).to receive(:attribute_encryption_without_kms).and_return('true') + end - expect(SCrypt::Engine).to_not receive(:hash_secret) + it 'returns encrypted text' do + ciphertext = subject.encrypt(plaintext) - subject.encrypt(plaintext) + expect(ciphertext).to_not eq(plaintext) + end end end describe '#decrypt' do - let(:ciphertext) do - result = subject.encrypt(plaintext) - described_class.instance_variable_set(:@_scypt_hashes_by_key, nil) - result - end + context 'with old kms based encryption' do + let(:ciphertext) do + subject.encrypt(plaintext) + end - before do - # Memoize the ciphertext and purge the key pool so that encryption does not - # affect expected call counts - ciphertext - end + before do + allow(Figaro.env).to receive(:attribute_encryption_without_kms).and_return('false') + # Memoize the ciphertext and purge the key pool so that encryption does not + # affect expected call counts + ciphertext + end - context 'with a ciphertext made with the current key' do - it 'decrypts the ciphertext' do - expect(subject.decrypt(ciphertext)).to eq(plaintext) + context 'with a ciphertext made with the current key' do + it 'decrypts the ciphertext' do + expect(subject.decrypt(ciphertext)).to eq(plaintext) + end end - it 'only computes an scrypt hash the first time a keys is used' do - expect(SCrypt::Engine).to receive(:hash_secret).once.and_call_original + context 'after rotating keys' do + before do + rotate_attribute_encryption_key + end - subject.decrypt(ciphertext) + it 'tries to decrypt with successive keys until it is successful' do + expect(subject.decrypt(ciphertext)).to eq(plaintext) + end + end - expect(SCrypt::Engine).to_not receive(:hash_secret) + context 'it migrates legacy encrypted data after rotating keys' do + before do + rotate_attribute_encryption_key + end - subject.decrypt(ciphertext) + it 'tries to decrypt with successive keys until it is successful' do + expect(subject.decrypt(ciphertext)).to eq(plaintext) + end + end + + context 'with a ciphertext made with a key that does not exist' do + before do + rotate_attribute_encryption_key_with_invalid_queue + end + + it 'raises and encryption error' do + expect { subject.decrypt(ciphertext) }.to \ + raise_error(Encryption::EncryptionError, 'unable to decrypt attribute with any key') + end end end - context 'after rotating keys' do + context 'with new new non kms based encryption' do + let(:ciphertext) do + subject.encrypt(plaintext) + end + before do - rotate_attribute_encryption_key + # Memoize the ciphertext and purge the key pool so that encryption does not + # affect expected call counts + allow(Figaro.env).to receive(:attribute_encryption_without_kms).and_return('true') + ciphertext end - it 'tries to decrypt with successive keys until it is successful' do - expect(Encryption::UserAccessKey).to receive(:new).twice.and_call_original + context 'with a ciphertext made with the current key' do + it 'decrypts the ciphertext' do + expect(subject.decrypt(ciphertext)).to eq(plaintext) + end + end + + context 'after rotating keys' do + before do + rotate_attribute_encryption_key + end - expect(subject.decrypt(ciphertext)).to eq(plaintext) + it 'tries to decrypt with successive keys until it is successful' do + expect(subject.decrypt(ciphertext)).to eq(plaintext) + end end - end - context 'with a ciphertext made with a key that does not exist' do - before do - rotate_attribute_encryption_key_with_invalid_queue + context 'it migrates legacy encrypted data after rotating keys' do + before do + rotate_attribute_encryption_key + end + + it 'tries to decrypt with successive keys until it is successful' do + expect(subject.decrypt(ciphertext)).to eq(plaintext) + end end - it 'raises and encryption error' do - expect { subject.decrypt(ciphertext) }.to raise_error( - Encryption::EncryptionError, 'unable to decrypt attribute with any key' - ) + context 'with a ciphertext made with a key that does not exist' do + before do + rotate_attribute_encryption_key_with_invalid_queue + end + + it 'raises and encryption error' do + expect { subject.decrypt(ciphertext) }.to \ + raise_error(Encryption::EncryptionError, 'unable to decrypt attribute with any key') + end end end end @@ -98,23 +152,21 @@ end it 'returns true if an old key was last used to decrypt something' do + allow(Figaro.env).to receive(:attribute_encryption_without_kms).and_return('false') ciphertext = subject.encrypt(plaintext) rotate_attribute_encryption_key subject.decrypt(ciphertext) expect(subject.stale?).to eq(true) end - end - - describe '.load_or_init_user_access_key' do - it 'does not return the same key object for the same salt and cost' do - expect(SCrypt::Engine).to receive(:hash_secret).once.and_call_original - key1 = described_class.load_or_init_user_access_key(key: current_key, cost: current_cost) - key2 = described_class.load_or_init_user_access_key(key: current_key, cost: current_cost) + it 'returns true if old key used to decrypt and we turn on new encryption' do + ciphertext = subject.encrypt(plaintext) + rotate_attribute_encryption_key + allow(Figaro.env).to receive(:attribute_encryption_without_kms).and_return('true') + subject.decrypt(ciphertext) - expect(key1.as_scrypt_hash).to eq(key2.as_scrypt_hash) - expect(key1).to_not eq(key2) + expect(subject.stale?).to eq(true) end end end diff --git a/spec/services/encryption/encryptors/deprecated_attribute_encryptor_spec.rb b/spec/services/encryption/encryptors/deprecated_attribute_encryptor_spec.rb new file mode 100644 index 00000000000..0b4eb8a507d --- /dev/null +++ b/spec/services/encryption/encryptors/deprecated_attribute_encryptor_spec.rb @@ -0,0 +1,120 @@ +require 'rails_helper' + +describe Encryption::Encryptors::DeprecatedAttributeEncryptor do + let(:plaintext) { 'some secret text' } + let(:current_key) { '1' * 32 } + let(:current_cost) { '400$8$1$' } + let(:retired_key) { '2' * 32 } + let(:retired_cost) { '2000$8$1$' } + + before do + described_class.instance_variable_set(:@_scypt_hashes_by_key, nil) + + allow(Figaro.env).to receive(:attribute_encryption_key).and_return(current_key) + allow(Figaro.env).to receive(:attribute_cost).and_return(current_cost) + allow(Figaro.env).to receive(:attribute_encryption_key_queue).and_return( + [{ key: retired_key, cost: retired_cost }].to_json + ) + end + + describe '#encrypt' do + it 'returns encrypted text' do + ciphertext = subject.encrypt(plaintext) + + expect(ciphertext).to_not eq(plaintext) + end + + it 'only computes an scrypt hash the first time a key is used' do + expect(SCrypt::Engine).to receive(:hash_secret).once.and_call_original + + subject.encrypt(plaintext) + + expect(SCrypt::Engine).to_not receive(:hash_secret) + + subject.encrypt(plaintext) + end + end + + describe '#decrypt' do + let(:ciphertext) do + result = subject.encrypt(plaintext) + described_class.instance_variable_set(:@_scypt_hashes_by_key, nil) + result + end + + before do + # Memoize the ciphertext and purge the key pool so that encryption does not + # affect expected call counts + ciphertext + end + + context 'with a ciphertext made with the current key' do + it 'decrypts the ciphertext' do + expect(subject.decrypt(ciphertext)).to eq(plaintext) + end + + it 'only computes an scrypt hash the first time a keys is used' do + expect(SCrypt::Engine).to receive(:hash_secret).once.and_call_original + + subject.decrypt(ciphertext) + + expect(SCrypt::Engine).to_not receive(:hash_secret) + + subject.decrypt(ciphertext) + end + end + + context 'after rotating keys' do + before do + rotate_attribute_encryption_key + end + + it 'tries to decrypt with successive keys until it is successful' do + expect(Encryption::UserAccessKey).to receive(:new).twice.and_call_original + + expect(subject.decrypt(ciphertext)).to eq(plaintext) + end + end + + context 'with a ciphertext made with a key that does not exist' do + before do + rotate_attribute_encryption_key_with_invalid_queue + end + + it 'raises and encryption error' do + expect { subject.decrypt(ciphertext) }.to raise_error( + Encryption::EncryptionError, 'unable to decrypt attribute with any key' + ) + end + end + end + + describe '#stale?' do + it 'returns false if the current key last was used to decrypt something' do + ciphertext = subject.encrypt(plaintext) + subject.decrypt(ciphertext) + + expect(subject.stale?).to eq(false) + end + + it 'returns true if an old key was last used to decrypt something' do + ciphertext = subject.encrypt(plaintext) + rotate_attribute_encryption_key + subject.decrypt(ciphertext) + + expect(subject.stale?).to eq(true) + end + end + + describe '.load_or_init_user_access_key' do + it 'does not return the same key object for the same salt and cost' do + expect(SCrypt::Engine).to receive(:hash_secret).once.and_call_original + + key1 = described_class.load_or_init_user_access_key(key: current_key, cost: current_cost) + key2 = described_class.load_or_init_user_access_key(key: current_key, cost: current_cost) + + expect(key1.as_scrypt_hash).to eq(key2.as_scrypt_hash) + expect(key1).to_not eq(key2) + end + end +end diff --git a/spec/services/encryption/kms_client_spec.rb b/spec/services/encryption/kms_client_spec.rb index 29469b966de..9d52eaa79ea 100644 --- a/spec/services/encryption/kms_client_spec.rb +++ b/spec/services/encryption/kms_client_spec.rb @@ -69,4 +69,14 @@ end end end + + describe '#looks_like_kms?' do + it 'returns true for kms encrypted data' do + expect(subject.class.looks_like_kms?('KMSx' + kms_ciphertext)).to eq(true) + end + + it 'returns false for non kms encrypted data' do + expect(subject.class.looks_like_kms?('abcdef.' + kms_ciphertext)).to eq(false) + end + end end diff --git a/spec/services/encryption/password_verifier_spec.rb b/spec/services/encryption/password_verifier_spec.rb index 6fd9d0e6212..5f045ad2b8b 100644 --- a/spec/services/encryption/password_verifier_spec.rb +++ b/spec/services/encryption/password_verifier_spec.rb @@ -41,4 +41,10 @@ expect(result).to eq(false) end end + + it 'raises an encryption error when the password digest is nil' do + expect do + Encryption::PasswordVerifier::PasswordDigest.parse_from_string(nil) + end.to raise_error(Encryption::EncryptionError) + end end diff --git a/spec/services/file_encryptor_spec.rb b/spec/services/file_encryptor_spec.rb deleted file mode 100644 index aeba9fd5621..00000000000 --- a/spec/services/file_encryptor_spec.rb +++ /dev/null @@ -1,57 +0,0 @@ -require 'rails_helper' - -RSpec.describe FileEncryptor do - let(:email) { Figaro.env.equifax_gpg_email } - - subject(:file_encryptor) do - FileEncryptor.new( - Rails.root.join('keys', 'equifax_gpg.pub.bin'), - email - ) - end - - let(:output_file) { Tempfile.new('temp.encrypted') } - - after do - output_file.close - output_file.unlink - end - - describe '#encrypt' do - let(:plaintext) { 'aaa' } - - subject(:encrypt) { file_encryptor.encrypt(plaintext, output_file.path) } - - it 'writes the encrypted content to a file' do - encrypt - - encrypted_content = File.binread(output_file.path) - - expect(encrypted_content).to be_present - expect(encrypted_content).to_not include(plaintext) - end - - context 'with a bad email' do - let(:email) { 'aaa@aaa.com' } - - it 'raises an error' do - expect { encrypt }.to raise_error(FileEncryptor::EncryptionError) - end - end - end - - describe '#decrypt' do - let(:passphrase) { Figaro.env.equifax_development_example_gpg_passphrase } - let(:plaintext) { 'some super secret content' } - - before do - file_encryptor.encrypt(plaintext, output_file.path) - end - - it 'returns the decrypted content' do - decrypted = file_encryptor.decrypt(passphrase, output_file.path) - - expect(decrypted).to eq(plaintext) - end - end -end diff --git a/spec/services/holiday_service_spec.rb b/spec/services/holiday_service_spec.rb new file mode 100644 index 00000000000..4bd7b6e21c1 --- /dev/null +++ b/spec/services/holiday_service_spec.rb @@ -0,0 +1,155 @@ +require 'rails_helper' + +RSpec.describe HolidayService do + let(:year) { 2018 } + + let(:instance) { described_class.new(year) } + + context 'instance methods' do + describe '#holidays' do + subject { instance.holidays } + + it { is_expected.to eq(holidays) } + end + + describe '#observed_holidays' do + subject { instance.observed_holidays } + + it { is_expected.to eq(observed_holidays) } + + context 'when the next NY is on a Saturday' do + let(:year) { 2010 } + + subject { instance.observed_holidays } + + it 'includes Dec 31st' do + expect(subject).to include(Date.new(2010, 12, 31)) + end + end + end + + describe '#holiday?' do + subject { instance.holiday?(date) } + + context 'when its a holiday' do + let(:date) { Date.new(year, 11, 11) } + + it { is_expected.to eq(true) } + end + + context 'when its not a holiday' do + let(:date) { Date.new(year, 11, 12) } + + it { is_expected.to eq(false) } + end + end + + describe '#observed_holiday?' do + subject { instance.observed_holiday?(date) } + + context 'when its not a holiday' do + let(:date) { Date.new(year, 11, 11) } + + it { is_expected.to eq(false) } + end + + context 'when its a holiday' do + let(:date) { Date.new(year, 11, 12) } + + it { is_expected.to eq(true) } + end + end + + describe '#observed' do + subject { instance.send(:observed, date) } + + context 'when date is a Saturday' do + let(:date) { Date.new(2018, 6, 23) } + + it 'returns the day before' do + expect(subject).to eq(date - 1) + end + end + + context 'when date is a Sunday' do + let(:date) { Date.new(2018, 6, 24) } + + it 'returns the day after' do + expect(subject).to eq(date + 1) + end + end + + context 'when date is a weekday' do + let(:date) { Date.new(2018, 6, 25) } + + it 'returns the day' do + expect(subject).to eq(date) + end + end + end + end + + context 'class methods' do + describe '.holiday?' do + subject { described_class.holiday?(date) } + + context 'when its a holiday' do + let(:date) { Date.new(year, 11, 11) } + + it { is_expected.to eq(true) } + end + + context 'when its not a holiday' do + let(:date) { Date.new(year, 11, 12) } + + it { is_expected.to eq(false) } + end + end + + describe '.observed_holiday?' do + subject { described_class.observed_holiday?(date) } + + context 'when its not a holiday' do + let(:date) { Date.new(year, 11, 11) } + + it { is_expected.to eq(false) } + end + + context 'when its a holiday' do + let(:date) { Date.new(year, 11, 12) } + + it { is_expected.to eq(true) } + end + end + end + + def holidays + [ + Date.new(year, 1, 1), + Date.new(year, 1, 15), + Date.new(year, 2, 19), + Date.new(year, 5, 28), + Date.new(year, 7, 4), + Date.new(year, 9, 3), + Date.new(year, 10, 8), + Date.new(year, 11, 11), + Date.new(year, 11, 22), + Date.new(year, 12, 25), + ] + end + + def observed_holidays + [ + Date.new(year, 1, 1), + Date.new(year, 1, 15), + Date.new(year, 2, 19), + Date.new(year, 5, 28), + Date.new(year, 7, 4), + Date.new(year, 9, 3), + Date.new(year, 10, 8), + Date.new(year, 11, 12), + Date.new(year, 11, 22), + Date.new(year, 12, 25), + ] + end +end diff --git a/spec/services/idv/phone_step_spec.rb b/spec/services/idv/phone_step_spec.rb index 74637a5d93b..093ccc89418 100644 --- a/spec/services/idv/phone_step_spec.rb +++ b/spec/services/idv/phone_step_spec.rb @@ -9,7 +9,7 @@ idvs.applicant = { first_name: 'Some' } idvs end - let(:idv_form_params) { { phone: '555-555-0000', phone_confirmed_at: nil } } + let(:idv_form_params) { { phone: '703-555-0000', phone_confirmed_at: nil } } let(:idv_phone_form) { Idv::PhoneForm.new(idv_session.params, user) } def build_step(vendor_validator_result) @@ -49,7 +49,7 @@ def build_step(vendor_validator_result) end it 'returns false for mock-sad phone' do - idv_form_params[:phone] = '555-555-5555' + idv_form_params[:phone] = '703-555-5555' errors = { phone: ['The phone number could not be verified.'] } step = build_step( diff --git a/spec/services/openid_connect_attribute_scoper_spec.rb b/spec/services/openid_connect_attribute_scoper_spec.rb index e0e9888abb9..5eb7522a644 100644 --- a/spec/services/openid_connect_attribute_scoper_spec.rb +++ b/spec/services/openid_connect_attribute_scoper_spec.rb @@ -36,7 +36,7 @@ given_name: 'John', family_name: 'Jones', birthdate: '1970-01-01', - phone: '+1 (555) 555-5555', + phone: '+1 (703) 555-5555', phone_verified: true, address: { formatted: "123 Fake St\nWashington, DC 12345", diff --git a/spec/services/password_metrics_incrementer_spec.rb b/spec/services/password_metrics_incrementer_spec.rb new file mode 100644 index 00000000000..bfbd18758c1 --- /dev/null +++ b/spec/services/password_metrics_incrementer_spec.rb @@ -0,0 +1,17 @@ +require 'rails_helper' + +describe PasswordMetricsIncrementer do + let(:password) { 'saltypickles' } + let(:guesses_log10) { 7.1 } + + subject { described_class.new(password) } + + describe '#increment_password_metrics' do + it 'increments password metrics for the length and guesses' do + subject.increment_password_metrics + + expect(PasswordMetric.where(metric: 'length', value: 12, count: 1).count).to eq(1) + expect(PasswordMetric.where(metric: 'guesses_log10', value: 7.1, count: 1).count).to eq(1) + end + end +end diff --git a/spec/services/phone_formatter_spec.rb b/spec/services/phone_formatter_spec.rb index 8f32b271a2d..56aa36d77ad 100644 --- a/spec/services/phone_formatter_spec.rb +++ b/spec/services/phone_formatter_spec.rb @@ -3,42 +3,42 @@ describe PhoneFormatter do describe '#format' do it 'formats international numbers correctly' do - phone = '+404004004000' - formatted_phone = PhoneFormatter.new.format(phone) + phone = '+40211234567' + formatted_phone = PhoneFormatter.format(phone) - expect(formatted_phone).to eq('+40 400 400 4000') + expect(formatted_phone).to eq('+40 21 123 4567') end it 'formats U.S. numbers correctly' do phone = '+12025005000' - formatted_phone = PhoneFormatter.new.format(phone) + formatted_phone = PhoneFormatter.format(phone) - expect(formatted_phone).to eq('+1 (202) 500-5000') + expect(formatted_phone).to eq('+1 202-500-5000') end it 'uses +1 as the default international code' do phone = '2025005000' - formatted_phone = PhoneFormatter.new.format(phone) + formatted_phone = PhoneFormatter.format(phone) - expect(formatted_phone).to eq('+1 (202) 500-5000') + expect(formatted_phone).to eq('+1 202-500-5000') end it 'uses the international code for the country specified in the country code option' do - phone = '123123123' - formatted_phone = PhoneFormatter.new.format(phone, country_code: 'MA') + phone = '636023853' + formatted_phone = PhoneFormatter.format(phone, country_code: 'MA') - expect(formatted_phone).to eq('+212 12 3123 123') + expect(formatted_phone).to eq('+212 636-023853') end it 'returns nil for nil' do - formatted_phone = PhoneFormatter.new.format(nil) + formatted_phone = PhoneFormatter.format(nil) expect(formatted_phone).to be_nil end it 'returns nil for nonsense' do phone = '☎️📞📱📳' - formatted_phone = PhoneFormatter.new.format(phone) + formatted_phone = PhoneFormatter.format(phone) expect(formatted_phone).to be_nil end end diff --git a/spec/services/phone_number_capabilities_spec.rb b/spec/services/phone_number_capabilities_spec.rb index fb50c947300..4b9afdc5887 100644 --- a/spec/services/phone_number_capabilities_spec.rb +++ b/spec/services/phone_number_capabilities_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' describe PhoneNumberCapabilities do - let(:phone) { '+1 (555) 555-5000' } + let(:phone) { '+1 (703) 555-5000' } subject { PhoneNumberCapabilities.new(phone) } describe '#sms_only?' do @@ -10,7 +10,7 @@ end context 'voice is not supported for the area code' do - let(:phone) { '+1 (242) 555-5000' } + let(:phone) { '+1 (242) 327-0143' } it { expect(subject.sms_only?).to eq(true) } end @@ -28,7 +28,7 @@ describe '#unsupported_location' do it 'returns the name of the unsupported area code location' do - locality = PhoneNumberCapabilities.new('+1 (242) 555-5000').unsupported_location + locality = PhoneNumberCapabilities.new('+1 (242) 327-0143').unsupported_location expect(locality).to eq('Bahamas') end diff --git a/spec/services/phone_verification_spec.rb b/spec/services/phone_verification_spec.rb new file mode 100644 index 00000000000..dbbc692fd1a --- /dev/null +++ b/spec/services/phone_verification_spec.rb @@ -0,0 +1,49 @@ +require 'rails_helper' + +describe PhoneVerification do + describe '#send_sms' do + it 'makes a POST request to Twilio Verify endpoint' do + PhoneVerification.adapter = FakeAdapter + + phone = '17873270143' + headers = { 'X-Authy-API-Key' => 'secret' } + locale = 'es' + code = '123456' + body = { + code_length: 6, + country_code: '1', + custom_code: code, + locale: locale, + phone_number: '7873270143', + via: 'sms', + } + connecttimeout = PhoneVerification::OPEN_TIMEOUT + timeout = PhoneVerification::READ_TIMEOUT + + expect(FakeAdapter).to receive(:post). + with( + PhoneVerification::AUTHY_START_ENDPOINT, + headers: headers, + body: body, + connecttimeout: connecttimeout, + timeout: timeout + ).and_return(FakeAdapter::SuccessResponse.new) + + PhoneVerification.new(phone: phone, locale: locale, code: code).send_sms + end + + it 'raises VerifyError when response is not successful' do + PhoneVerification.adapter = FakeAdapter + phone = '17035551212' + code = '123456' + + allow(FakeAdapter).to receive(:post).and_return(FakeAdapter::ErrorResponse.new) + + expect { PhoneVerification.new(phone: phone, code: code).send_sms }.to raise_error do |error| + expect(error.code).to eq 60_033 + expect(error.message).to eq 'Invalid number' + expect(error).to be_a(PhoneVerification::VerifyError) + end + end + end +end diff --git a/spec/services/pii/cacher_spec.rb b/spec/services/pii/cacher_spec.rb index 36b0e0d3ca3..bf5c62c1507 100644 --- a/spec/services/pii/cacher_spec.rb +++ b/spec/services/pii/cacher_spec.rb @@ -11,6 +11,7 @@ describe '#save' do before do + allow(Figaro.env).to receive(:attribute_encryption_without_kms).and_return('false') allow(FeatureManagement).to receive(:use_kms?).and_return(false) profile.save! end diff --git a/spec/services/piv_cac_service_spec.rb b/spec/services/piv_cac_service_spec.rb index 78446c738f5..b9127b7599b 100644 --- a/spec/services/piv_cac_service_spec.rb +++ b/spec/services/piv_cac_service_spec.rb @@ -37,10 +37,10 @@ end it 'returns the test data' do - token = 'TEST:{"uuid":"hijackedUUID","dn":"hijackedDN"}' + token = 'TEST:{"uuid":"hijackedUUID","subject":"hijackedDN"}' expect(PivCacService.decode_token(token)).to eq( 'uuid' => 'hijackedUUID', - 'dn' => 'hijackedDN' + 'subject' => 'hijackedDN' ) end end @@ -106,11 +106,11 @@ stub_request(:post, 'localhost:8443'). with( body: 'token=foo', - headers: {'Authentication' => %r<^hmac\s+:.+:.+$>} + headers: { 'Authentication' => /^hmac\s+:.+:.+$/ } ). to_return( status: [200, 'Ok'], - body: '{"dn":"dn","uuid":"uuid"}' + body: '{"subject":"dn","uuid":"uuid"}' ) end @@ -121,14 +121,14 @@ it 'returns the decoded JSON from the target service' do expect(PivCacService.decode_token('foo')).to eq( - 'dn' => 'dn', + 'subject' => 'dn', 'uuid' => 'uuid' ) end describe 'with test data' do it 'returns an error' do - token = 'TEST:{"uuid":"hijackedUUID","dn":"hijackedDN"}' + token = 'TEST:{"uuid":"hijackedUUID","subject":"hijackedDN"}' expect(PivCacService.decode_token(token)).to eq( 'error' => 'token.bad' ) diff --git a/spec/services/remote_settings_service_spec.rb b/spec/services/remote_settings_service_spec.rb index 5b499f88dc6..1bb24b4e293 100644 --- a/spec/services/remote_settings_service_spec.rb +++ b/spec/services/remote_settings_service_spec.rb @@ -2,9 +2,7 @@ describe RemoteSettingsService do subject(:service) { RemoteSettingsService } - before { - WebMock.allow_net_connect! - } + before { WebMock.allow_net_connect! } describe '.load_yml_erb' do it 'loads the remote location' do @@ -34,13 +32,11 @@ end end - describe '.load' do it 'loads the remote location' do expect do service.load('https://github.com/18F/identity-idp/blob/master/public/images/logo.svg') end.to_not raise_error - end it 'raises an error if the location is not https://' do diff --git a/spec/services/service_provider_seeder_spec.rb b/spec/services/service_provider_seeder_spec.rb index a2990dd16b3..215b92be669 100644 --- a/spec/services/service_provider_seeder_spec.rb +++ b/spec/services/service_provider_seeder_spec.rb @@ -116,7 +116,7 @@ context 'when service_providers.yml has a remote setting' do before do location = 'https://raw.githubusercontent.com/18F/identity-idp/master/config/service_providers.yml' - RemoteSetting.create(name: 'service_providers.yml', url:location, + RemoteSetting.create(name: 'service_providers.yml', url: location, contents: "test:\n 'issuer1':\n friendly_name: 'name1'") end diff --git a/spec/services/twilio_service_spec.rb b/spec/services/twilio_service_spec.rb index f1a72e9c66d..6246839f845 100644 --- a/spec/services/twilio_service_spec.rb +++ b/spec/services/twilio_service_spec.rb @@ -24,7 +24,7 @@ SmsOtpSenderJob.perform_now( code: '1234', - phone: '555-5555', + phone: '17035551212', otp_created_at: Time.zone.now.to_s ) diff --git a/spec/services/usps_confirmation_entry_spec.rb b/spec/services/usps_confirmation_entry_spec.rb deleted file mode 100644 index 2b4d7de916b..00000000000 --- a/spec/services/usps_confirmation_entry_spec.rb +++ /dev/null @@ -1,28 +0,0 @@ -require 'rails_helper' - -describe UspsConfirmationEntry do - subject do - described_class.new_from_hash( - first_name: 'Some', - last_name: 'One', - otp: 123 - ) - end - - describe '#encrypted' do - it 'encrypts' do - encrypted_entry = subject.encrypted - - expect(encrypted_entry).to_not match 'Some' - end - end - - describe '#new_from_encrypted' do - it 'round-trips entry' do - encrypted_entry = subject.encrypted - plain_entry = described_class.new_from_encrypted(encrypted_entry) - - expect(plain_entry).to eq subject - end - end -end diff --git a/spec/services/usps_confirmation_maker_spec.rb b/spec/services/usps_confirmation_maker_spec.rb index bcbac173469..b62e2a38957 100644 --- a/spec/services/usps_confirmation_maker_spec.rb +++ b/spec/services/usps_confirmation_maker_spec.rb @@ -32,7 +32,8 @@ expect { subject.perform }.to change { UspsConfirmation.count }.from(0).to(1) usps_confirmation = UspsConfirmation.first - expect(usps_confirmation.decrypted_entry.to_h).to eq decrypted_attributes + entry_hash = usps_confirmation.entry + expect(entry_hash).to eq decrypted_attributes end it 'should create a UspsConfrimationCode with the profile and the encrypted OTP' do diff --git a/spec/services/usps_confirmation_spec.rb b/spec/services/usps_confirmation_spec.rb new file mode 100644 index 00000000000..cf20430d66a --- /dev/null +++ b/spec/services/usps_confirmation_spec.rb @@ -0,0 +1,33 @@ +require 'rails_helper' + +describe UspsConfirmation do + let(:attributes) do + { + first_name: 'Homer', + last_name: 'Simpson', + ssn: '123-456-7890', + } + end + let(:encryptor) { Encryption::Encryptors::SessionEncryptor.new } + + subject { UspsConfirmation.create!(entry: attributes) } + + describe '#entry' do + it 'stores the entry as an encrypted json string' do + # Since the encryption is different every time, we'll just make sure this + # is some non-empty string thats NOT the json version of the attributes. + expect(subject[:entry]).to be_a(String) + expect(subject[:entry]).not_to be_empty + expect(subject[:entry]).not_to eq(attributes.to_json) + expect(parse(subject[:entry])).to eq(attributes) + end + + it 'retrieves the entry as an unencrypted hash with symbolized keys' do + expect(subject.entry).to eq(attributes) + end + end + + def parse(json) + JSON.parse(encryptor.decrypt(json), symbolize_names: true) + end +end diff --git a/spec/services/usps_exporter_spec.rb b/spec/services/usps_exporter_spec.rb index de21baf1148..9596adc1450 100644 --- a/spec/services/usps_exporter_spec.rb +++ b/spec/services/usps_exporter_spec.rb @@ -36,12 +36,6 @@ ] values.join('|') end - let(:file_encryptor) do - FileEncryptor.new( - Rails.root.join('keys', 'equifax_gpg.pub.bin'), - Figaro.env.equifax_gpg_email - ) - end subject { described_class.new(export_file.path) } @@ -61,19 +55,12 @@ confirmation_maker.perform end - it 'creates encrypted file' do + it 'creates plain text file' do subject.run psv_contents = export_file.read - expect(psv_contents).to_not eq("01|1\r\n#{psv_row_contents}\r\n") - - decrypted_contents = file_encryptor.decrypt( - Figaro.env.equifax_development_example_gpg_passphrase, - export_file.path - ) - - expect(decrypted_contents).to eq("01|1\r\n#{psv_row_contents}\r\n") + expect(psv_contents).to eq("01|1\r\n#{psv_row_contents}\r\n") end it 'clears entries after creating file' do @@ -83,15 +70,5 @@ expect(UspsConfirmation.count).to eq 0 end - - it 'does not clear entries when GPG encrypting fails for some reason' do - expect(Figaro.env).to receive(:equifax_gpg_email).and_return('wrong@email.com') - - original_count = UspsConfirmation.count - - expect { subject.run }.to raise_error(FileEncryptor::EncryptionError) - - expect(UspsConfirmation.count).to eq(original_count) - end end end diff --git a/spec/services/usps_uploader_spec.rb b/spec/services/usps_uploader_spec.rb index b5f48ef530c..22c68e9f5e1 100644 --- a/spec/services/usps_uploader_spec.rb +++ b/spec/services/usps_uploader_spec.rb @@ -10,17 +10,17 @@ before do sftp_options = [ - Figaro.env.equifax_sftp_host, - Figaro.env.equifax_sftp_username, - { key_data: [RequestKeyManager.equifax_ssh_key.to_pem] }, + Figaro.env.usps_upload_sftp_host, + Figaro.env.usps_upload_sftp_username, + { password: Figaro.env.usps_upload_sftp_password }, ] expect(Net::SFTP).to receive(:start). with(*sftp_options).and_yield(sftp_connection) end - it 'creates a PGP-encrypted file and uploads it via SFTP and deletes it after' do + it 'creates a file, uploads it via SFTP, and deletes it after' do expect(sftp_connection).to receive(:upload!). - with(uploader.local_path.to_s, File.join(Figaro.env.equifax_sftp_directory, 'batch.pgp')) + with(uploader.local_path.to_s, upload_folder) run @@ -36,4 +36,8 @@ expect(File.exist?(uploader.local_path)).to eq(true) end end + + def upload_folder + File.join(Figaro.env.usps_upload_sftp_directory, 'batch.psv') + end end diff --git a/spec/support/fake_adapter.rb b/spec/support/fake_adapter.rb new file mode 100644 index 00000000000..f721126e560 --- /dev/null +++ b/spec/support/fake_adapter.rb @@ -0,0 +1,24 @@ +module FakeAdapter + def self.post(_endpoint, _params) + SuccessResponse.new + end + + class SuccessResponse + def success? + true + end + end + + class ErrorResponse + def success? + false + end + + def response_body + { + error_code: '60033', + message: 'Invalid number', + }.to_json + end + end +end diff --git a/spec/support/features/idv_helper.rb b/spec/support/features/idv_helper.rb index 74c5a877e9e..12c28fd8517 100644 --- a/spec/support/features/idv_helper.rb +++ b/spec/support/features/idv_helper.rb @@ -57,7 +57,7 @@ def fill_out_phone_form_ok(phone = '415-555-0199') end def fill_out_phone_form_fail - fill_in :idv_phone_form_phone, with: '(555) 555-5555' + fill_in :idv_phone_form_phone, with: '(703) 555-5555' end def click_idv_continue diff --git a/spec/support/features/idv_step_helper.rb b/spec/support/features/idv_step_helper.rb index fb6484a75a1..6ddb4c0880f 100644 --- a/spec/support/features/idv_step_helper.rb +++ b/spec/support/features/idv_step_helper.rb @@ -48,7 +48,7 @@ def complete_idv_steps_before_usps_step(user = user_with_2fa) def complete_idv_steps_before_phone_otp_delivery_selection_step(user = user_with_2fa) complete_idv_steps_before_phone_step(user) - fill_out_phone_form_ok('2341230638') + fill_out_phone_form_ok('2342255432') click_idv_continue end diff --git a/spec/support/features/session_helper.rb b/spec/support/features/session_helper.rb index 96672d210e2..94e7c921ebf 100644 --- a/spec/support/features/session_helper.rb +++ b/spec/support/features/session_helper.rb @@ -102,12 +102,12 @@ def sign_in_and_2fa_user(user = user_with_2fa) end def user_with_2fa - create(:user, :signed_up, phone: '+1 (555) 555-0000', password: VALID_PASSWORD) + create(:user, :signed_up, phone: '+1 202-555-1212', password: VALID_PASSWORD) end def user_with_piv_cac create(:user, :signed_up, :with_piv_or_cac, - phone: '+1 (555) 555-0000', + phone: '+1 (703) 555-0000', password: VALID_PASSWORD) end diff --git a/spec/support/idv_examples/failed_idv_job.rb b/spec/support/idv_examples/failed_idv_job.rb index e9efa5bb055..8319acd2101 100644 --- a/spec/support/idv_examples/failed_idv_job.rb +++ b/spec/support/idv_examples/failed_idv_job.rb @@ -82,7 +82,7 @@ end end - # rubocop:disable Lint/HandleExceptions + # rubocop:disable Lint/HandleExceptions, Style/RedundantBegin def stub_idv_job_to_raise_error_in_background(idv_job_class) allow(Idv::Agent).to receive(:new).and_raise('this is a test error') allow(idv_job_class).to receive(:perform_now).and_wrap_original do |perform_now, *args| @@ -93,7 +93,7 @@ def stub_idv_job_to_raise_error_in_background(idv_job_class) end end end - # rubocop:enable Lint/HandleExceptions + # rubocop:enable Lint/HandleExceptions, Style/RedundantBegin def stub_idv_job_to_timeout_in_background(idv_job_class) allow(idv_job_class).to receive(:perform_now) diff --git a/spec/support/key_rotation_helper.rb b/spec/support/key_rotation_helper.rb index 8d66cc4c675..93240346b2e 100644 --- a/spec/support/key_rotation_helper.rb +++ b/spec/support/key_rotation_helper.rb @@ -5,10 +5,10 @@ def rotate_hmac_key allow(env).to receive(:hmac_fingerprinter_key_queue).and_return( "[\"#{old_hmac_key}\"]" ) - allow(env).to receive(:hmac_fingerprinter_key).and_return('a-new-key') + allow(env).to receive(:hmac_fingerprinter_key).and_return('4' * 32) end - def rotate_attribute_encryption_key(new_key = 'a-new-key', new_cost = '4000$8$2$') + def rotate_attribute_encryption_key(new_key = '4' * 32, new_cost = '4000$8$2$') env = Figaro.env old_key = env.attribute_encryption_key old_cost = env.attribute_cost @@ -33,6 +33,6 @@ def rotate_attribute_encryption_key_with_invalid_queue allow(env).to receive(:attribute_encryption_key_queue).and_return( [{ key: 'key-that-was-never-used-in-the-past', cost: '4000$8$2$' }].to_json ) - allow(env).to receive(:attribute_encryption_key).and_return('a-new-key') + allow(env).to receive(:attribute_encryption_key).and_return('4' * 32) end end diff --git a/spec/support/saml_response_doc.rb b/spec/support/saml_response_doc.rb index 12fb881d149..74067f5cbf4 100644 --- a/spec/support/saml_response_doc.rb +++ b/spec/support/saml_response_doc.rb @@ -34,7 +34,7 @@ def input_id def raw_xml_response if @test_type == 'feature' xml_response - elsif @response.body =~ // + elsif @response.body.match?(//) html_response else @response.body @@ -53,7 +53,7 @@ def saml_document end def response_doc - if raw_xml_response =~ /EncryptedData/ + if raw_xml_response.match?(/EncryptedData/) @original_encrypted = true Nokogiri::XML( OneLogin::RubySaml::Response.new( diff --git a/spec/support/shared_examples/remember_device.rb b/spec/support/shared_examples/remember_device.rb index 25f1d717911..24c78a0b7c1 100644 --- a/spec/support/shared_examples/remember_device.rb +++ b/spec/support/shared_examples/remember_device.rb @@ -25,7 +25,7 @@ sign_in_user(user) visit manage_phone_path - fill_in 'user_phone_form_phone', with: '5551230000' + fill_in 'user_phone_form_phone', with: '7032231000' click_button t('forms.buttons.submit.confirm_change') click_submit_default first(:link, t('links.sign_out')).click diff --git a/spec/support/shared_examples_for_otp_delivery_preference_validation.rb b/spec/support/shared_examples_for_otp_delivery_preference_validation.rb index ad2964464f6..42d041000ce 100644 --- a/spec/support/shared_examples_for_otp_delivery_preference_validation.rb +++ b/spec/support/shared_examples_for_otp_delivery_preference_validation.rb @@ -1,5 +1,5 @@ shared_examples 'an otp delivery preference form' do - let(:phone) { '+1 (555) 555-5000' } + let(:phone) { '+1 (703) 555-5000' } let(:params) do { phone: phone, diff --git a/spec/support/shared_examples_for_phone_validation.rb b/spec/support/shared_examples_for_phone_validation.rb index a8c3ffc386b..f77c04d4402 100644 --- a/spec/support/shared_examples_for_phone_validation.rb +++ b/spec/support/shared_examples_for_phone_validation.rb @@ -1,8 +1,6 @@ require 'shoulda/matchers' shared_examples 'a phone form' do - include Shoulda::Matchers::ActiveModel - describe 'phone presence validation' do it 'is invalid when phone is blank' do params[:phone] = '' @@ -12,32 +10,6 @@ end end - describe 'phone validation' do - it 'uses the phony_rails gem' do - phone_validator = subject._validators.values.flatten. - detect { |v| v.class == PhonyPlausibleValidator } - - expect(phone_validator.options[:presence]).to eq(true) - expect(phone_validator.options[:message]).to eq(:improbable_phone) - expect(phone_validator.options).to include(:international_code) - end - - it do - should validate_inclusion_of(:international_code). - in_array(PhoneNumberCapabilities::INTERNATIONAL_CODES.keys) - end - - it 'validates that the number matches the requested international code' do - params[:phone] = '123 123 1234' - params[:international_code] = 'MA' - result = subject.submit(params) - - expect(result).to be_kind_of(FormResponse) - expect(result.success?).to eq(false) - expect(result.errors).to include(:phone) - end - end - describe 'phone uniqueness' do context 'when phone is already taken' do it 'is valid' do @@ -63,7 +35,7 @@ context 'when phone is same as current user' do it 'is valid' do - user.phone = '+1 (555) 500-5000' + user.phone = '+1 (703) 500-5000' params[:phone] = user.phone result = subject.submit(params) @@ -75,10 +47,10 @@ describe '#submit' do it 'formats the phone before assigning it' do - params[:phone] = '703-555-1212' + params[:phone] = '(703) 555-1212' subject.submit(params) - expect(subject.phone).to eq '+1 (703) 555-1212' + expect(subject.phone).to eq '+1 703-555-1212' end end end 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 new file mode 100644 index 00000000000..f7f48780e14 --- /dev/null +++ b/spec/views/account_reset/confirm_delete_account/show.html.slim_spec.rb @@ -0,0 +1,32 @@ +require 'rails_helper' + +describe 'account_reset/confirm_delete_account/show.html.slim' do + before do + allow(view).to receive(:email).and_return('foo@bar.com') + end + + it 'has a localized title' do + expect(view).to receive(:title).with(t('account_reset.confirm_delete_account.title')) + + render + end + + it 'contains the user email' do + email = 'foo@bar.com' + session[:email] = email + + render + + expect(rendered).to have_content(email) + end + + 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 + ) + end +end diff --git a/spec/views/account_reset/confirm_request/show.html.slim_spec.rb b/spec/views/account_reset/confirm_request/show.html.slim_spec.rb new file mode 100644 index 00000000000..471a851cd0d --- /dev/null +++ b/spec/views/account_reset/confirm_request/show.html.slim_spec.rb @@ -0,0 +1,22 @@ +require 'rails_helper' + +describe 'account_reset/confirm_request/show.html.slim' do + before do + allow(view).to receive(:email).and_return('foo@bar.com') + end + + it 'has a localized title' do + expect(view).to receive(:title).with(t('account_reset.confirm_request.check_your_email')) + + render + end + + it 'contains the user email' do + email = 'foo@bar.com' + session[:email] = email + + render + + expect(rendered).to have_content(email) + 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 new file mode 100644 index 00000000000..6fe0957933e --- /dev/null +++ b/spec/views/account_reset/delete_account/show.html.slim_spec.rb @@ -0,0 +1,19 @@ +require 'rails_helper' + +describe 'account_reset/delete_account/show.html.slim' do + before do + allow(view).to receive(:email).and_return('foo@bar.com') + end + + it 'has a localized title' do + expect(view).to receive(:title).with(t('account_reset.delete_account.title')) + + render + end + + it 'has button to delete' do + + render + expect(rendered).to have_button t('account_reset.delete_account.delete_button') + end +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 new file mode 100644 index 00000000000..d68284b8311 --- /dev/null +++ b/spec/views/account_reset/request/show.html.slim_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +describe 'account_reset/request/show.html.slim' do + it 'has a localized title' do + expect(view).to receive(:title).with(t('account_reset.request.title')) + + render + end + + it 'has button to delete' do + + render + expect(rendered).to have_button t('account_reset.request.yes_continue') + end +end diff --git a/spec/views/idv/review/new.html.slim_spec.rb b/spec/views/idv/review/new.html.slim_spec.rb index e61667b654c..52fa0b5e440 100644 --- a/spec/views/idv/review/new.html.slim_spec.rb +++ b/spec/views/idv/review/new.html.slim_spec.rb @@ -29,7 +29,7 @@ expect(rendered).to have_content('MO') expect(rendered).to have_content('12345') expect(rendered).to have_content('666-66-1234') - expect(rendered).to have_content('+1 (213) 555-0000') + expect(rendered).to have_content('+1 213-555-0000') expect(rendered).to have_content('March 29, 1972') end