diff --git a/Gemfile b/Gemfile index d8611d53d61..c8d331ba2a9 100644 --- a/Gemfile +++ b/Gemfile @@ -33,7 +33,7 @@ gem 'jsbundling-rails', '~> 1.0.0' gem 'jwe' gem 'jwt' gem 'lograge', '>= 0.11.2' -gem 'lookbook', '~> 1.4.5', require: false +gem 'lookbook', '~> 1.5.3', require: false gem 'lru_redux' gem 'msgpack', '~> 1.6' gem 'maxminddb' @@ -68,7 +68,7 @@ gem 'strong_migrations', '>= 0.4.2' gem 'subprocess', require: false gem 'uglifier', '~> 4.2' gem 'valid_email', '>= 0.1.3' -gem 'view_component', '~> 2.51.0' +gem 'view_component', '~> 2.82.0' gem 'webauthn', '~> 2.5.2' gem 'xmldsig', '~> 0.6' gem 'xmlenc', '~> 0.7', '>= 0.7.1' diff --git a/Gemfile.lock b/Gemfile.lock index 7d456fc816e..27123c1d906 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -366,7 +366,7 @@ GEM loofah (2.19.1) crass (~> 1.0.2) nokogiri (>= 1.5.9) - lookbook (1.4.5) + lookbook (1.5.3) actioncable activemodel css_parser @@ -376,7 +376,7 @@ GEM railties (>= 5.0) redcarpet (~> 3.5) rouge (>= 3.26, < 5.0) - view_component (~> 2.0) + view_component (> 2.0, < 4) yard (~> 0.9.25) zeitwerk (~> 2.5) lru_redux (1.1.0) @@ -520,7 +520,7 @@ GEM rb-inotify (0.10.1) ffi (~> 1.0) redacted_struct (1.1.0) - redcarpet (3.5.1) + redcarpet (3.6.0) redis (5.0.5) redis-client (>= 0.9.0) redis-client (0.12.0) @@ -541,7 +541,7 @@ GEM retries (0.0.5) rexml (3.2.5) rotp (6.2.0) - rouge (4.0.1) + rouge (4.1.0) rqrcode (2.1.0) chunky_png (~> 1.0) rqrcode_core (~> 1.0) @@ -665,8 +665,9 @@ GEM activemodel mail (>= 2.6.1) simpleidn - view_component (2.51.0) - activesupport (>= 5.0.0, < 8.0) + view_component (2.82.0) + activesupport (>= 5.2.0, < 8.0) + concurrent-ruby (~> 1.0) method_source (~> 1.0) virtus (2.0.0) axiom-types (~> 0.1) @@ -762,7 +763,7 @@ DEPENDENCIES knapsack letter_opener (~> 1.8) lograge (>= 0.11.2) - lookbook (~> 1.4.5) + lookbook (~> 1.5.3) lru_redux maxminddb msgpack (~> 1.6) @@ -822,7 +823,7 @@ DEPENDENCIES subprocess uglifier (~> 4.2) valid_email (>= 0.1.3) - view_component (~> 2.51.0) + view_component (~> 2.82.0) webauthn (~> 2.5.2) webdrivers (~> 5.2.0) webmock diff --git a/app/assets/stylesheets/components/_phone-input.scss b/app/assets/stylesheets/components/_phone-input.scss index b0c76de41c8..6cea19c5b13 100644 --- a/app/assets/stylesheets/components/_phone-input.scss +++ b/app/assets/stylesheets/components/_phone-input.scss @@ -14,6 +14,10 @@ lg-phone-input { } } + .iti__dial-code { + color: color('ink'); + } + .iti:not(.iti--allow-dropdown) input { padding-left: 36px; padding-right: 6px; diff --git a/app/components/base_component.rb b/app/components/base_component.rb index abbacf2f842..eb7a225d087 100644 --- a/app/components/base_component.rb +++ b/app/components/base_component.rb @@ -9,7 +9,7 @@ def before_render def self.scripts @scripts ||= begin - scripts = _sidecar_files(['js', 'ts']).map { |file| File.basename(file, '.*') } + scripts = sidecar_files(['js', 'ts']).map { |file| File.basename(file, '.*') } scripts.concat superclass.scripts if superclass.respond_to?(:scripts) scripts end diff --git a/app/controllers/concerns/idv/step_utilities_concern.rb b/app/controllers/concerns/idv/step_utilities_concern.rb new file mode 100644 index 00000000000..d69709d1719 --- /dev/null +++ b/app/controllers/concerns/idv/step_utilities_concern.rb @@ -0,0 +1,41 @@ +module Idv + module StepUtilitiesConcern + extend ActiveSupport::Concern + + def flow_session + user_session['idv/doc_auth'] + end + + # copied from doc_auth_controller + def flow_path + flow_session[:flow_path] + end + + def confirm_pii_from_doc + @pii = flow_session['pii_from_doc'] # hash with indifferent access + return if @pii.present? + flow_session.delete('Idv::Steps::DocumentCaptureStep') + redirect_to idv_doc_auth_url + end + + # Copied from capture_doc_flow.rb + # and from doc_auth_flow.rb + def acuant_sdk_ab_test_analytics_args + capture_session_uuid = flow_session[:document_capture_session_uuid] + if capture_session_uuid + { + acuant_sdk_upgrade_ab_test_bucket: + AbTests::ACUANT_SDK.bucket(capture_session_uuid), + } + else + {} + end + end + + def irs_reproofing? + effective_user&.decorate&.reproof_for_irs?( + service_provider: current_sp, + ).present? + end + end +end diff --git a/app/controllers/concerns/idv_session.rb b/app/controllers/concerns/idv_session.rb index 496d81687b0..4f7c25c255e 100644 --- a/app/controllers/concerns/idv_session.rb +++ b/app/controllers/concerns/idv_session.rb @@ -7,7 +7,7 @@ module IdvSession before_action :redirect_if_sp_context_needed end - def confirm_idv_session_started + def confirm_idv_applicant_created redirect_to idv_verify_info_url if idv_session.applicant.blank? end diff --git a/app/controllers/concerns/idv_step_concern.rb b/app/controllers/concerns/idv_step_concern.rb index d19e58ded78..8568a2138cb 100644 --- a/app/controllers/concerns/idv_step_concern.rb +++ b/app/controllers/concerns/idv_step_concern.rb @@ -6,6 +6,5 @@ module IdvStepConcern included do before_action :confirm_two_factor_authenticated before_action :confirm_idv_needed - before_action :confirm_idv_session_started end end diff --git a/app/controllers/idv/personal_key_controller.rb b/app/controllers/idv/personal_key_controller.rb index 57c1d3f2c13..e482f5de564 100644 --- a/app/controllers/idv/personal_key_controller.rb +++ b/app/controllers/idv/personal_key_controller.rb @@ -56,6 +56,8 @@ def add_proofing_component def finish_idv_session @code = personal_key + @personal_key_generated_at = current_user.personal_key_generated_at + user_session[:personal_key] = @code idv_session.personal_key = nil diff --git a/app/controllers/idv/phone_controller.rb b/app/controllers/idv/phone_controller.rb index 8844497508b..c8354d18c04 100644 --- a/app/controllers/idv/phone_controller.rb +++ b/app/controllers/idv/phone_controller.rb @@ -7,6 +7,7 @@ class PhoneController < ApplicationController attr_reader :idv_form + before_action :confirm_idv_applicant_created before_action :confirm_step_needed before_action :set_idv_form diff --git a/app/controllers/idv/review_controller.rb b/app/controllers/idv/review_controller.rb index 632da1949bc..d8c0646bbf2 100644 --- a/app/controllers/idv/review_controller.rb +++ b/app/controllers/idv/review_controller.rb @@ -6,6 +6,7 @@ class ReviewController < ApplicationController include StepIndicatorConcern include PhoneConfirmation + before_action :confirm_idv_applicant_created before_action :confirm_idv_steps_complete before_action :confirm_idv_phone_confirmed before_action :confirm_current_password, only: [:create] diff --git a/app/controllers/idv/ssn_controller.rb b/app/controllers/idv/ssn_controller.rb new file mode 100644 index 00000000000..ff287e7cd1e --- /dev/null +++ b/app/controllers/idv/ssn_controller.rb @@ -0,0 +1,88 @@ +module Idv + class SsnController < ApplicationController + include IdvSession + include StepIndicatorConcern + include StepUtilitiesConcern + include Steps::ThreatMetrixStepHelper + + before_action :render_404_if_ssn_controller_disabled + before_action :confirm_two_factor_authenticated + before_action :confirm_pii_from_doc + + attr_accessor :error_message + + def show + increment_step_counts + + analytics.idv_doc_auth_redo_ssn_submitted(**analytics_arguments) if updating_ssn + + analytics.idv_doc_auth_ssn_visited(**analytics_arguments) + + render :show, locals: extra_view_variables + end + + def update + @error_message = nil + form_response = form_submit + + unless form_response.success? + @error_message = form_response.first_error_message + redirect_to idv_ssn_url + end + + flow_session['pii_from_doc'][:ssn] = params[:doc_auth][:ssn] + + analytics.idv_doc_auth_ssn_submitted(**analytics_arguments) + + irs_attempts_api_tracker.idv_ssn_submitted( + ssn: params[:doc_auth][:ssn], + ) + + idv_session.invalidate_steps_after_ssn! + + redirect_to idv_verify_info_url + end + + def extra_view_variables + { + updating_ssn: updating_ssn, + success_alert_enabled: !updating_ssn, + **threatmetrix_view_variables, + } + end + + private + + def render_404_if_ssn_controller_disabled + render_not_found unless IdentityConfig.store.doc_auth_ssn_controller_enabled + end + + def analytics_arguments + { + flow_path: flow_path, + step: 'ssn', + step_count: current_flow_step_counts['Idv::Steps::SsnStep'], + analytics_id: 'Doc Auth', + irs_reproofing: irs_reproofing?, + }.merge(**acuant_sdk_ab_test_analytics_args) + end + + def current_flow_step_counts + user_session['idv/doc_auth_flow_step_counts'] ||= {} + user_session['idv/doc_auth_flow_step_counts'].default = 0 + user_session['idv/doc_auth_flow_step_counts'] + end + + def increment_step_counts + current_flow_step_counts['Idv::Steps::SsnStep'] += 1 + end + + def form_submit + Idv::SsnFormatForm.new(current_user).submit(params.require(:doc_auth).permit(:ssn)) + end + + def updating_ssn + flow_session.dig('pii_from_doc', :ssn).present? + end + end +end diff --git a/app/controllers/idv/verify_info_controller.rb b/app/controllers/idv/verify_info_controller.rb index 10e5e6063f4..8de40d8bd79 100644 --- a/app/controllers/idv/verify_info_controller.rb +++ b/app/controllers/idv/verify_info_controller.rb @@ -1,6 +1,7 @@ module Idv class VerifyInfoController < ApplicationController - include IdvSession + include IdvStepConcern + include StepUtilitiesConcern before_action :confirm_two_factor_authenticated before_action :confirm_ssn_step_complete @@ -73,21 +74,6 @@ def update private - # copied from doc_auth_controller - def flow_session - user_session['idv/doc_auth'] - end - - def flow_path - flow_session[:flow_path] - end - - def irs_reproofing? - effective_user&.decorate&.reproof_for_irs?( - service_provider: current_sp, - ).present? - end - def analytics_arguments { flow_path: flow_path, @@ -98,20 +84,6 @@ def analytics_arguments }.merge(**acuant_sdk_ab_test_analytics_args) end - # Copied from capture_doc_flow.rb - # and from doc_auth_flow.rb - def acuant_sdk_ab_test_analytics_args - capture_session_uuid = flow_session[:document_capture_session_uuid] - if capture_session_uuid - { - acuant_sdk_upgrade_ab_test_bucket: - AbTests::ACUANT_SDK.bucket(capture_session_uuid), - } - else - {} - end - end - # copied from verify_step def pii @pii = flow_session[:pii_from_doc] if flow_session @@ -125,7 +97,11 @@ def delete_pii # copied from address_controller def confirm_ssn_step_complete return if pii.present? && pii[:ssn].present? - redirect_to idv_doc_auth_url + if IdentityConfig.store.doc_auth_ssn_controller_enabled + redirect_to idv_ssn_url + else + redirect_to idv_doc_auth_url + end end def confirm_profile_not_already_confirmed diff --git a/app/controllers/reactivate_account_controller.rb b/app/controllers/reactivate_account_controller.rb index dbf6a427010..4ffe8267361 100644 --- a/app/controllers/reactivate_account_controller.rb +++ b/app/controllers/reactivate_account_controller.rb @@ -4,7 +4,9 @@ class ReactivateAccountController < ApplicationController before_action :confirm_two_factor_authenticated before_action :confirm_password_reset_profile - def index; end + def index + @personal_key_generated_at = current_user.personal_key_generated_at + end def update reactivate_account_session.suspend diff --git a/app/controllers/users/personal_keys_controller.rb b/app/controllers/users/personal_keys_controller.rb index 46b2aaaccbe..5e6e818cf09 100644 --- a/app/controllers/users/personal_keys_controller.rb +++ b/app/controllers/users/personal_keys_controller.rb @@ -17,6 +17,7 @@ def show return redirect_to account_url if personal_key.blank? @code = personal_key + @personal_key_generated_at = current_user.personal_key_generated_at end def update diff --git a/app/forms/openid_connect_authorize_form.rb b/app/forms/openid_connect_authorize_form.rb index 0b3d7b3aa65..b9340a5c530 100644 --- a/app/forms/openid_connect_authorize_form.rb +++ b/app/forms/openid_connect_authorize_form.rb @@ -110,7 +110,8 @@ def aal_values end def aal - Saml::Idp::Constants::AUTHN_CONTEXT_CLASSREF_TO_AAL[aal_values.sort.max] + Saml::Idp::Constants::AUTHN_CONTEXT_CLASSREF_TO_AAL[aal_values.sort.max] || + Saml::Idp::Constants::DEFAULT_AAL_AUTHN_CONTEXT_CLASSREF end def_delegators :ial_context, diff --git a/app/javascript/packages/phone-input/index.ts b/app/javascript/packages/phone-input/index.ts index 429c4506fd1..6f6ada9906b 100644 --- a/app/javascript/packages/phone-input/index.ts +++ b/app/javascript/packages/phone-input/index.ts @@ -137,6 +137,7 @@ export class PhoneInputElement extends HTMLElement { const iti = intlTelInput(this.textInput, { preferredCountries: ['US', 'CA'], + initialCountry: this.codeInput.value, localizedCountries: countryCodePairs, onlyCountries: supportedCountryCodes, autoPlaceholder: 'off', diff --git a/app/javascript/packages/phone-input/package.json b/app/javascript/packages/phone-input/package.json index 8e5469c4434..538375d3bd3 100644 --- a/app/javascript/packages/phone-input/package.json +++ b/app/javascript/packages/phone-input/package.json @@ -3,7 +3,7 @@ "private": true, "version": "1.0.0", "dependencies": { - "intl-tel-input": "^17.0.8", + "intl-tel-input": "^17.0.19", "libphonenumber-js": "^1.10.11" } } diff --git a/app/models/user.rb b/app/models/user.rb index 668157dbc23..e5968b458ba 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -101,6 +101,12 @@ def pending_profile profiles.gpo_verification_pending.order(created_at: :desc).first end + def personal_key_generated_at + encrypted_recovery_code_digest_generated_at || + active_profile&.verified_at || + profiles.verified.order(activated_at: :desc).first&.verified_at + end + def default_phone_configuration phone_configurations.order('made_default_at DESC NULLS LAST, created_at').first end diff --git a/app/presenters/account_show_presenter.rb b/app/presenters/account_show_presenter.rb index 4ff05e61097..7258048ed24 100644 --- a/app/presenters/account_show_presenter.rb +++ b/app/presenters/account_show_presenter.rb @@ -62,8 +62,7 @@ def backup_codes_generated_at end def personal_key_generated_at - decorated_user.user.encrypted_recovery_code_digest_generated_at || - decorated_user.user.active_profile&.verified_at + decorated_user.user.personal_key_generated_at end def header_personalization diff --git a/app/presenters/openid_connect_user_info_presenter.rb b/app/presenters/openid_connect_user_info_presenter.rb index 7f52a4aebc1..2b18fef1d25 100644 --- a/app/presenters/openid_connect_user_info_presenter.rb +++ b/app/presenters/openid_connect_user_info_presenter.rb @@ -21,6 +21,8 @@ def user_info info.merge!(ial2_attributes) if scoper.ial2_scopes_requested? info.merge!(x509_attributes) if scoper.x509_scopes_requested? info[:verified_at] = verified_at if scoper.verified_at_requested? + info[:ial] = Saml::Idp::Constants::AUTHN_CONTEXT_IAL_TO_CLASSREF[identity.ial] + info[:aal] = Saml::Idp::Constants::AUTHN_CONTEXT_AAL_TO_CLASSREF[identity.aal] scoper.filter(info) end diff --git a/app/services/idv/session.rb b/app/services/idv/session.rb index 3f7234bf895..563fd064b0d 100644 --- a/app/services/idv/session.rb +++ b/app/services/idv/session.rb @@ -136,6 +136,24 @@ def user_phone_confirmation_session=(new_user_phone_confirmation_session) session[:user_phone_confirmation_session] = new_user_phone_confirmation_session.to_h end + def invalidate_steps_after_ssn! + # Guard against unvalidated attributes from in-person flow in review controller + session[:applicant] = nil + + invalidate_verify_info_step! + invalidate_phone_step! + end + + def invalidate_verify_info_step! + session[:resolution_successful] = nil + session[:profile_confirmation] = nil + end + + def invalidate_phone_step! + session[:vendor_phone_confirmation] = nil + session[:user_phone_confirmation] = nil + end + private attr_accessor :user_session diff --git a/app/services/idv/steps/document_capture_step.rb b/app/services/idv/steps/document_capture_step.rb index e2df7019cdd..9f3408b2f5a 100644 --- a/app/services/idv/steps/document_capture_step.rb +++ b/app/services/idv/steps/document_capture_step.rb @@ -15,6 +15,8 @@ def self.analytics_submitted_event def call handle_stored_result if !FeatureManagement.document_capture_async_uploads_enabled? + + exit_flow_state_machine if IdentityConfig.store.doc_auth_ssn_controller_enabled end def extra_view_variables @@ -38,6 +40,11 @@ def extra_view_variables private + def exit_flow_state_machine + flow_session[:flow_path] = @flow.flow_path + redirect_to idv_ssn_url + end + def native_camera_ab_testing_variables { acuant_sdk_upgrade_ab_test_bucket: diff --git a/app/views/idv/come_back_later/show.html.erb b/app/views/idv/come_back_later/show.html.erb index 4f3436972c4..9294f8ae88a 100644 --- a/app/views/idv/come_back_later/show.html.erb +++ b/app/views/idv/come_back_later/show.html.erb @@ -12,7 +12,7 @@ <%= image_tag( asset_url('come-back.svg'), size: '140', class: 'display-block margin-x-auto margin-bottom-2', - alt: '' + alt: t('idv.images.come_back_later') ) %> <%= render PageHeadingComponent.new(class: 'text-center').with_content(t('idv.titles.come_back_later')) %> diff --git a/app/views/idv/in_person/state_id.html.erb b/app/views/idv/in_person/state_id.html.erb index a9f41f73acd..3ba30bd9127 100644 --- a/app/views/idv/in_person/state_id.html.erb +++ b/app/views/idv/in_person/state_id.html.erb @@ -10,6 +10,21 @@ <%= t('in_person_proofing.body.state_id.info_html') %>

+<%= render AlertComponent.new( + type: :info, + class: 'margin-bottom-4', + text_tag: 'div', + ) do %> + <%= t('in_person_proofing.body.state_id.alert_message') %> + +<% end %> + <%= simple_form_for :doc_auth, url: url_for, method: 'put', diff --git a/app/views/idv/personal_key/show.html.erb b/app/views/idv/personal_key/show.html.erb index d68ae996442..26ecfd4c694 100644 --- a/app/views/idv/personal_key/show.html.erb +++ b/app/views/idv/personal_key/show.html.erb @@ -9,4 +9,8 @@ <% title t('titles.idv.personal_key') %> -<%= render 'shared/personal_key', code: @code, update_path: idv_personal_key_path %> +<%= render 'shared/personal_key', + code: @code, + personal_key_generated_at: @personal_key_generated_at, + update_path: idv_personal_key_path +%> diff --git a/app/views/idv/ssn/show.html.erb b/app/views/idv/ssn/show.html.erb new file mode 100644 index 00000000000..f744c8ccedb --- /dev/null +++ b/app/views/idv/ssn/show.html.erb @@ -0,0 +1,91 @@ +<%# +Renders a page asking the user to enter their SSN or update their SSN if they had previously entered it. + +locals: +* success_alert_enabled: whether or not to display a "We've successfully verified your ID" success alert +* updating_ssn: true if the user is updating their SSN instead of providing it for the first time. This + will render a different page heading and different navigation buttons in the page footer +%> +<% content_for(:pre_flash_content) do %> + <%= render StepIndicatorComponent.new( + steps: Idv::Flows::DocAuthFlow::STEP_INDICATOR_STEPS, + current_step: :verify_info, + locale_scope: 'idv', + class: 'margin-x-neg-2 margin-top-neg-4 tablet:margin-x-neg-6 tablet:margin-top-neg-4', + ) %> +<% end %> + +<% title t('titles.doc_auth.ssn') %> + +<% if success_alert_enabled %> + <%= render AlertComponent.new( + type: :success, + class: 'margin-bottom-4', + ) do %> + <%= t('doc_auth.headings.capture_complete') %> + <% end %> +<% end %> + +<% if updating_ssn %> + <%= render PageHeadingComponent.new.with_content(t('doc_auth.headings.ssn_update')) %> +<% else %> + <%= render PageHeadingComponent.new.with_content(t('doc_auth.headings.ssn')) %> +<% end %> + +

+ <%= t('doc_auth.info.ssn') %> + <%= new_window_link_to(t('doc_auth.instructions.learn_more'), MarketingSite.security_and_privacy_practices_url) %> +

+ +<% if FeatureManagement.proofing_device_profiling_collecting_enabled? %> + <% if threatmetrix_session_id.present? %> + <% threatmetrix_javascript_urls.each do |threatmetrix_javascript_url| %> + <%= javascript_include_tag threatmetrix_javascript_url, nonce: true %> + <% end %> + + <% end %> +<% end %> + +<% if IdentityConfig.store.proofer_mock_fallback %> +
+
+

+ <%= t('doc_auth.instructions.test_ssn') %> +

+
+
+<% end %> + +<%= simple_form_for( + :doc_auth, + url: idv_ssn_url, + method: :put, + html: { autocomplete: 'off' }, + ) do |f| %> +
+ <%= render 'shared/ssn_field', f: f %> +
+ +

<%= @error_message %>

+ + <%= f.submit class: 'display-block margin-y-5' do %> + <% if updating_ssn %> + <%= t('forms.buttons.submit.update') %> + <% else %> + <%= t('forms.buttons.continue') %> + <% end %> + <% end %> +<% end %> + +<% if updating_ssn %> + <%= render 'idv/shared/back', action: 'cancel_update_ssn' %> +<% else %> + <%= render 'idv/doc_auth/cancel', step: 'ssn' %> +<% end %> diff --git a/app/views/idv/verify_info/show.html.erb b/app/views/idv/verify_info/show.html.erb index 26c528d38ed..bf768760504 100644 --- a/app/views/idv/verify_info/show.html.erb +++ b/app/views/idv/verify_info/show.html.erb @@ -102,14 +102,24 @@ locals: toggle_label: t('forms.ssn.show'), ) %> -
- <%= button_to( - idv_doc_auth_step_url(step: :redo_ssn), - method: :put, - class: 'usa-button usa-button--unstyled', - 'aria-label': t('idv.buttons.change_ssn_label'), - ) { t('idv.buttons.change_label') } %> -
+ <% if IdentityConfig.store.doc_auth_ssn_controller_enabled %> +
+ <%= link_to( + t('idv.buttons.change_label'), + idv_ssn_url, + 'aria-label': t('idv.buttons.change_ssn_label'), + ) %> +
+ <% else %> +
+ <%= button_to( + idv_doc_auth_step_url(step: :redo_ssn), + method: :put, + class: 'usa-button usa-button--unstyled', + 'aria-label': t('idv.buttons.change_ssn_label'), + ) { t('idv.buttons.change_label') } %> +
+ <% end %>
<%= render SpinnerButtonComponent.new( diff --git a/app/views/layouts/component_preview.html.erb b/app/views/layouts/component_preview.html.erb index 1e027b3c237..846d8e03f93 100644 --- a/app/views/layouts/component_preview.html.erb +++ b/app/views/layouts/component_preview.html.erb @@ -4,7 +4,7 @@ Component Preview <%= stylesheet_link_tag 'application', media: 'all' %> - + <% if params.dig(:lookbook, :display, :form) == true %>
<%= yield %> diff --git a/app/views/partials/personal_key/_key.html.erb b/app/views/partials/personal_key/_key.html.erb index a9669920fa5..116e4198038 100644 --- a/app/views/partials/personal_key/_key.html.erb +++ b/app/views/partials/personal_key/_key.html.erb @@ -7,10 +7,17 @@

- <%= t( - 'users.personal_key.generated_on_html', - date: content_tag(:strong, I18n.l(Time.zone.today, format: '%B %d, %Y')), - ) %> + <% if local_assigns[:personal_key_generated_at].present? %> + <%= t( + 'users.personal_key.generated_on_html', + date: content_tag(:strong, render(TimeComponent.new(time: personal_key_generated_at))), + ) %> + <% else %> + <%= t( + 'users.personal_key.generated_on_html', + date: content_tag(:strong, render(TimeComponent.new(time: Time.zone.today))), + ) %> + <% end %>

<% if show_save_buttons %> <%= render ClipboardButtonComponent.new(clipboard_text: code, unstyled: true) %> diff --git a/app/views/reactivate_account/index.html.erb b/app/views/reactivate_account/index.html.erb index d078682c6aa..dedbd93e630 100644 --- a/app/views/reactivate_account/index.html.erb +++ b/app/views/reactivate_account/index.html.erb @@ -30,7 +30,11 @@

- <%= render 'partials/personal_key/key', code: 'XXXX-XXXX-XXXX-XXXX', show_save_buttons: false %> + <%= render 'partials/personal_key/key', + code: 'XXXX-XXXX-XXXX-XXXX', + personal_key_generated_at: @personal_key_generated_at, + show_save_buttons: false + %>
diff --git a/app/views/shared/_personal_key.html.erb b/app/views/shared/_personal_key.html.erb index 61f59ef53d2..4461cdece24 100644 --- a/app/views/shared/_personal_key.html.erb +++ b/app/views/shared/_personal_key.html.erb @@ -1,6 +1,10 @@ <%= render PageHeadingComponent.new.with_content(t('forms.personal_key_partial.header')) %>
- <%= render 'partials/personal_key/key', code: code, show_save_buttons: true %> + <%= render 'partials/personal_key/key', + code: code, + personal_key_generated_at: personal_key_generated_at, + show_save_buttons: true + %>
<%= render AccordionComponent.new do |c| %> diff --git a/app/views/users/personal_keys/show.html.erb b/app/views/users/personal_keys/show.html.erb index f443fd7b135..3e5e066920f 100644 --- a/app/views/users/personal_keys/show.html.erb +++ b/app/views/users/personal_keys/show.html.erb @@ -1,3 +1,9 @@ <% title t('titles.personal_key') %> -<%= render('shared/personal_key', code: @code, update_path: manage_personal_key_path) %> +<%= render( + 'shared/personal_key', + code: @code, + personal_key_generated_at: @personal_key_generated_at, + update_path: manage_personal_key_path, + ) +%> diff --git a/config/application.yml.default b/config/application.yml.default index 39df9509e4d..90352964ee5 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -70,6 +70,7 @@ country_phone_number_overrides: '{}' doc_auth_error_dpi_threshold: 290 doc_auth_error_sharpness_threshold: 40 doc_auth_error_glare_threshold: 40 +doc_auth_ssn_controller_enabled: false database_pool_extra_connections_for_worker: 4 database_pool_idp: 5 database_statement_timeout: 2_500 diff --git a/config/locales/idv/en.yml b/config/locales/idv/en.yml index 66358817487..e26ef019972 100644 --- a/config/locales/idv/en.yml +++ b/config/locales/idv/en.yml @@ -121,6 +121,8 @@ en: ssn_label_html: Social Security number state: State zipcode: ZIP Code + images: + come_back_later: Letter with a check mark index: id: need_html: If you’re creating an account, you’ll need a current diff --git a/config/locales/idv/es.yml b/config/locales/idv/es.yml index bce914c90df..f551a873284 100644 --- a/config/locales/idv/es.yml +++ b/config/locales/idv/es.yml @@ -130,6 +130,8 @@ es: ssn_label_html: Número de Seguro Social state: Estado zipcode: Código postal + images: + come_back_later: Carta con una marca de verificación index: id: need_html: Si está creando una cuenta, necesitará una identificación diff --git a/config/locales/idv/fr.yml b/config/locales/idv/fr.yml index e07e6ccaaf1..603f3a911c0 100644 --- a/config/locales/idv/fr.yml +++ b/config/locales/idv/fr.yml @@ -135,6 +135,8 @@ fr: ssn_label_html: Numéro de sécurité sociale state: État zipcode: Code postal + images: + come_back_later: Lettre avec un crochet index: id: need_html: Si vous créez un compte, vous aurez besoin d’un identifiant diff --git a/config/locales/in_person_proofing/en.yml b/config/locales/in_person_proofing/en.yml index 38298e735c1..9342ce86453 100644 --- a/config/locales/in_person_proofing/en.yml +++ b/config/locales/in_person_proofing/en.yml @@ -80,6 +80,10 @@ en: verify_step_enter_pii: Enter your name, date of birth, state-issued ID number, address and Social Security number. state_id: + alert_message: 'Your state-issued ID must not be expired. Accepted forms of ID are:' + id_types: + - State Driver’s License + - State Non-Driver’s Identification Card info_html: Enter information exactly as it appears on your state-issued ID. We will use this information to confirm it matches your ID in person. diff --git a/config/locales/in_person_proofing/es.yml b/config/locales/in_person_proofing/es.yml index 628ae3d5c99..21f038496cb 100644 --- a/config/locales/in_person_proofing/es.yml +++ b/config/locales/in_person_proofing/es.yml @@ -91,6 +91,11 @@ es: identificación emitido por el estado, dirección y número de la Seguridad Social. state_id: + alert_message: 'Su identificación emitida por el estado no debe estar vencida. + Se aceptan las siguientes formas de identificación:' + id_types: + - Licencia para conducir estatal + - Identificación estatal que no sea la licencia para conducir info_html: Ingrese la información exactamente como aparece en su cédula de identidad emitida por el estado. Utilizaremos esta información para confirmar que coincide con su cédula en persona. diff --git a/config/locales/in_person_proofing/fr.yml b/config/locales/in_person_proofing/fr.yml index a7c7094671e..4a6b33fc8a3 100644 --- a/config/locales/in_person_proofing/fr.yml +++ b/config/locales/in_person_proofing/fr.yml @@ -92,6 +92,11 @@ fr: document d’identité délivré par l’État, votre adresse et votre numéro de sécurité sociale. state_id: + alert_message: 'Votre carte d’identité délivrée par l’État ne doit pas être + périmée. Les pièces d’identité acceptées sont:' + id_types: + - Permis de conduire national + - Carte d’identité nationale de non-conducteur info_html: Saisissez les informations exactement comme elles figurent sur votre document d’identité nationale. Nous utiliserons ces informations pour confirmer qu’elles correspondent à votre pièce diff --git a/config/routes.rb b/config/routes.rb index 497774d2ffb..8f31eb5ac83 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -308,6 +308,8 @@ post '/forgot_password' => 'forgot_password#update' get '/otp_delivery_method' => 'otp_delivery_method#new' put '/otp_delivery_method' => 'otp_delivery_method#create' + get '/ssn' => 'ssn#show' + put '/ssn' => 'ssn#update' get '/verify_info' => 'verify_info#show' put '/verify_info' => 'verify_info#update' get '/phone' => 'phone#new' diff --git a/lib/identity_config.rb b/lib/identity_config.rb index b206c48da18..ebd4f974ec1 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -166,6 +166,7 @@ def self.build_store(config_map) config.add(:doc_auth_max_submission_attempts_before_native_camera, type: :integer) config.add(:doc_auth_max_capture_attempts_before_tips, type: :integer) config.add(:doc_auth_s3_request_timeout, type: :integer) + config.add(:doc_auth_ssn_controller_enabled, type: :boolean) config.add(:doc_auth_vendor, type: :string) config.add(:doc_auth_vendor_randomize, type: :boolean) config.add(:doc_auth_vendor_randomize_percent, type: :integer) diff --git a/package.json b/package.json index 4fe4fd52077..e2df37ddbaa 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "fast-glob": "^3.2.7", "foundation-emails": "^2.3.1", "identity-style-guide": "^6.7.0", - "intl-tel-input": "^17.0.8", + "intl-tel-input": "^17.0.19", "react": "^17.0.2", "react-dom": "^17.0.2", "source-map-loader": "^4.0.0", diff --git a/spec/components/base_component_spec.rb b/spec/components/base_component_spec.rb index 32e9ddfc70d..052bac13ead 100644 --- a/spec/components/base_component_spec.rb +++ b/spec/components/base_component_spec.rb @@ -26,7 +26,7 @@ def call '' end - def self._sidecar_files(extensions) + def self.sidecar_files(extensions) files = [] files << '/components/example_component_with_script_js.js' if extensions.include?('js') files << '/components/example_component_with_script_ts.ts' if extensions.include?('ts') @@ -39,7 +39,7 @@ def call render(ExampleComponentWithScript.new) end - def self._sidecar_files(extensions) + def self.sidecar_files(extensions) if extensions.include?('js') ['/components/example_component_with_script_rendering_other_component_with_script.js'] else @@ -49,7 +49,7 @@ def self._sidecar_files(extensions) end class NestedExampleComponentWithScript < ExampleComponentWithScript - def self._sidecar_files(extensions) + def self.sidecar_files(extensions) if extensions.include?('js') ['/components/nested_example_component_with_script.js'] else diff --git a/spec/components/previews/base_component_preview.rb b/spec/components/previews/base_component_preview.rb index ac576c699ed..69b5465dd0c 100644 --- a/spec/components/previews/base_component_preview.rb +++ b/spec/components/previews/base_component_preview.rb @@ -1,3 +1,4 @@ +# @hidden class BaseComponentPreview < ViewComponent::Preview private diff --git a/spec/components/previews/step_indicator_component_preview.rb b/spec/components/previews/step_indicator_component_preview.rb index 6064e079bca..9d3f9611509 100644 --- a/spec/components/previews/step_indicator_component_preview.rb +++ b/spec/components/previews/step_indicator_component_preview.rb @@ -8,7 +8,7 @@ def default { name: :third_step, title: 'Third Step' }, { name: :fourth_step, title: 'Fourth Step' }, ], - current_step: :second, + current_step: :second_step, ) end # @!endgroup diff --git a/spec/components/previews/validated_field_component_preview.rb b/spec/components/previews/validated_field_component_preview.rb index 83fc3721d5c..7d154e3d6c9 100644 --- a/spec/components/previews/validated_field_component_preview.rb +++ b/spec/components/previews/validated_field_component_preview.rb @@ -50,8 +50,8 @@ def required_checkbox # @display form true # @param label text # @param required toggle - # @param input_type select [~,Text,Email Address,Boolean] - def workbench(label: 'Input', required: true, input_type: 'Text') + # @param input_type select [~,String,Email,Boolean] + def workbench(label: 'Input', required: true, input_type: 'String') render( ValidatedFieldComponent.new( form: form_builder, diff --git a/spec/controllers/concerns/idv_step_concern_spec.rb b/spec/controllers/concerns/idv_step_concern_spec.rb index da0675f12a4..1ddf2d87459 100644 --- a/spec/controllers/concerns/idv_step_concern_spec.rb +++ b/spec/controllers/concerns/idv_step_concern_spec.rb @@ -12,44 +12,6 @@ class StepController < ApplicationController end end - describe '#confirm_idv_session_started' do - controller Idv::StepController do - before_action :confirm_idv_session_started - - def show - render plain: 'Hello' - end - end - - before(:each) do - stub_sign_in(user) - routes.draw do - get 'show' => 'idv/step#show' - end - end - - context 'user has not started IdV session' do - it 'redirects to a previous step url' do - get :show - - expect(response).to redirect_to(idv_verify_info_url) - end - end - - context 'user has started IdV session' do - before do - idv_session.applicant = { first_name: 'Jane' } - allow(subject).to receive(:idv_session).and_return(idv_session) - end - - it 'allows request' do - get :show - - expect(response.body).to eq 'Hello' - end - end - end - describe '#confirm_idv_needed' do controller Idv::StepController do before_action :confirm_idv_needed @@ -70,7 +32,6 @@ def show before do allow(user).to receive(:active_profile).and_return(Profile.new) allow(subject).to receive(:current_user).and_return(user) - allow(subject).to receive(:confirm_idv_session_started).and_return(true) end it 'redirects to activated page' do @@ -83,7 +44,6 @@ def show context 'user does not have active profile' do before do allow(subject).to receive(:current_user).and_return(user) - allow(subject).to receive(:confirm_idv_session_started).and_return(true) end it 'does not redirect to activated page' do diff --git a/spec/controllers/idv/phone_controller_spec.rb b/spec/controllers/idv/phone_controller_spec.rb index d13815ae777..12659a8d800 100644 --- a/spec/controllers/idv/phone_controller_spec.rb +++ b/spec/controllers/idv/phone_controller_spec.rb @@ -17,7 +17,7 @@ expect(subject).to have_actions( :before, :confirm_two_factor_authenticated, - :confirm_idv_session_started, + :confirm_idv_applicant_created, ) end end @@ -66,6 +66,22 @@ end end + context 'when the user has not finished the verify step' do + before do + subject.idv_session.applicant = nil + subject.idv_session.profile_confirmation = nil + subject.idv_session.resolution_successful = nil + + allow(controller).to receive(:confirm_idv_applicant_created).and_call_original + end + + it 'redirects to the verify step' do + get :new + + expect(response).to redirect_to idv_verify_info_url + end + end + context 'when the user is throttled' do before do Throttle.new(throttle_type: :proof_address, user: user).increment_to_throttled! diff --git a/spec/controllers/idv/review_controller_spec.rb b/spec/controllers/idv/review_controller_spec.rb index 1c325cb56f5..a0b99b9c079 100644 --- a/spec/controllers/idv/review_controller_spec.rb +++ b/spec/controllers/idv/review_controller_spec.rb @@ -34,7 +34,7 @@ expect(subject).to have_actions( :before, :confirm_two_factor_authenticated, - :confirm_idv_session_started, + :confirm_idv_applicant_created, :confirm_idv_steps_complete, ) end @@ -192,7 +192,7 @@ def show describe '#new' do before do stub_sign_in(user) - allow(subject).to receive(:confirm_idv_session_started).and_return(true) + allow(subject).to receive(:confirm_idv_applicant_created).and_return(true) end context 'user has completed all steps' do @@ -264,7 +264,7 @@ def show describe '#create' do before do stub_sign_in(user) - allow(subject).to receive(:confirm_idv_session_started).and_return(true) + allow(subject).to receive(:confirm_idv_applicant_created).and_return(true) end context 'user fails to supply correct password' do diff --git a/spec/controllers/idv/ssn_controller_spec.rb b/spec/controllers/idv/ssn_controller_spec.rb new file mode 100644 index 00000000000..b12d9dc9c73 --- /dev/null +++ b/spec/controllers/idv/ssn_controller_spec.rb @@ -0,0 +1,175 @@ +require 'rails_helper' + +describe Idv::SsnController do + include IdvHelper + + let(:flow_session) do + { 'document_capture_session_uuid' => 'fd14e181-6fb1-4cdc-92e0-ef66dad0df4e', + 'pii_from_doc' => Idp::Constants::MOCK_IDV_APPLICANT.dup, + :threatmetrix_session_id => 'c90ae7a5-6629-4e77-b97c-f1987c2df7d0', + :flow_path => 'standard' } + end + + let(:user) { build(:user, :with_phone, with: { phone: '+1 (415) 555-0130' }) } + + before do + allow(subject).to receive(:flow_session).and_return(flow_session) + stub_sign_in(user) + end + + describe 'before_actions' do + it 'checks that feature flag is enabled' do + expect(subject).to have_actions( + :before, + :render_404_if_ssn_controller_disabled, + ) + end + + it 'includes authentication before_action' do + expect(subject).to have_actions( + :before, + :confirm_two_factor_authenticated, + ) + end + + it 'checks that the previous step is complete' do + expect(subject).to have_actions( + :before, + :confirm_pii_from_doc, + ) + end + end + + context 'when doc_auth_ssn_controller_enabled' do + before do + allow(IdentityConfig.store).to receive(:doc_auth_ssn_controller_enabled). + and_return(true) + stub_analytics + stub_attempts_tracker + allow(@analytics).to receive(:track_event) + end + + describe '#show' do + let(:analytics_name) { 'IdV: doc auth ssn visited' } + let(:analytics_args) do + { + analytics_id: 'Doc Auth', + flow_path: 'standard', + irs_reproofing: false, + step: 'ssn', + step_count: 1, + } + end + + context 'when doc_auth_ssn_controller_enabled' do + before do + allow(IdentityConfig.store).to receive(:doc_auth_ssn_controller_enabled). + and_return(true) + end + + it 'renders the show template' do + get :show + + expect(response).to render_template :show + end + + it 'sends analytics_visited event' do + get :show + + expect(@analytics).to have_received(:track_event).with(analytics_name, analytics_args) + end + + it 'sends correct step count to analytics' do + get :show + get :show + analytics_args[:step_count] = 2 + + expect(@analytics).to have_received(:track_event).with(analytics_name, analytics_args) + end + end + end + + describe '#update' do + context 'with valid ssn' do + let(:ssn) { Idp::Constants::MOCK_IDV_APPLICANT_WITH_SSN[:ssn] } + let(:params) { { doc_auth: { ssn: ssn } } } + let(:analytics_name) { 'IdV: doc auth ssn submitted' } + let(:analytics_args) do + { + analytics_id: 'Doc Auth', + flow_path: 'standard', + irs_reproofing: false, + step: 'ssn', + step_count: 1, + } + end + + it 'merges ssn into pii session value' do + put :update, params: params + + expect(flow_session['pii_from_doc'][:ssn]).to eq(ssn) + end + + it 'sends analytics_submitted event with correct step count' do + get :show + put :update, params: params + + expect(@analytics).to have_received(:track_event).with(analytics_name, analytics_args) + end + + it 'logs attempts api event' do + expect(@irs_attempts_api_tracker).to receive(:idv_ssn_submitted).with( + ssn: ssn, + ) + put :update, params: params + end + + context 'with existing session applicant' do + it 'clears applicant' do + subject.idv_session.applicant = Idp::Constants::MOCK_IDV_APPLICANT + + put :update, params: params + + expect(subject.idv_session.applicant).to be_blank + end + end + + it 'adds a threatmetrix session id to flow session' do + subject.extra_view_variables + expect(flow_session[:threatmetrix_session_id]).to_not eq(nil) + end + + it 'does not change threatmetrix_session_id when updating ssn' do + flow_session['pii_from_doc'][:ssn] = ssn + put :update, params: params + session_id = flow_session[:threatmetrix_session_id] + subject.extra_view_variables + expect(flow_session[:threatmetrix_session_id]).to eq(session_id) + end + end + + context 'when pii_from_doc is not present' do + it 'marks previous step as incomplete' do + flow_session.delete('pii_from_doc') + flow_session['Idv::Steps::DocumentCaptureStep'] = true + put :update + expect(flow_session['Idv::Steps::DocumentCaptureStep']).to eq nil + expect(response.status).to eq 302 + end + end + end + end + + context 'when doc_auth_ssn_controller_enabled is false' do + before do + allow(IdentityConfig.store).to receive(:doc_auth_ssn_controller_enabled). + and_return(false) + end + + it 'returns 404' do + get :show + + expect(response.status).to eq(404) + end + end +end diff --git a/spec/controllers/idv/verify_info_controller_spec.rb b/spec/controllers/idv/verify_info_controller_spec.rb index bea98420782..ac56fda9afd 100644 --- a/spec/controllers/idv/verify_info_controller_spec.rb +++ b/spec/controllers/idv/verify_info_controller_spec.rb @@ -100,10 +100,36 @@ ).increment_to_throttled! end - it 'redirects to ssn failure url' do - get :show + context 'when using new ssn controller' do + before do + allow(IdentityConfig.store).to receive(:doc_auth_ssn_controller_enabled). + and_return(true) + end - expect(response).to redirect_to idv_session_errors_ssn_failure_url + it 'redirects to ssn controller when ssn info is missing' do + flow_session[:pii_from_doc][:ssn] = nil + + get :show + + expect(response).to redirect_to(idv_ssn_url) + end + end + + context 'when the user is ssn throttled' do + before do + Throttle.new( + target: Pii::Fingerprinter.fingerprint( + Idp::Constants::MOCK_IDV_APPLICANT_WITH_SSN[:ssn], + ), + throttle_type: :proof_ssn, + ).increment_to_throttled! + end + + it 'redirects to ssn failure url' do + get :show + + expect(response).to redirect_to idv_session_errors_ssn_failure_url + end end end diff --git a/spec/controllers/users/backup_code_setup_controller_spec.rb b/spec/controllers/users/backup_code_setup_controller_spec.rb index 3c5a8a4a16e..e51d17b457d 100644 --- a/spec/controllers/users/backup_code_setup_controller_spec.rb +++ b/spec/controllers/users/backup_code_setup_controller_spec.rb @@ -32,6 +32,17 @@ expect(user.backup_code_configurations.length).to eq BackupCodeGenerator::NUMBER_OF_CODES end + it 'creating backup codes revokes remember device cookies' do + user = create(:user, :signed_up) + stub_sign_in(user) + expect(user.remember_device_revoked_at).to eq nil + + freeze_time do + post :create + expect(user.reload.remember_device_revoked_at).to eq Time.zone.now + end + end + it 'deletes backup codes' do user = build(:user, :signed_up, :with_authentication_app, :with_backup_code) stub_sign_in(user) @@ -43,6 +54,17 @@ expect(user.backup_code_configurations.length).to eq 0 end + it 'deleting backup codes revokes remember device cookies' do + user = build(:user, :signed_up, :with_authentication_app, :with_backup_code) + stub_sign_in(user) + expect(user.remember_device_revoked_at).to eq nil + + freeze_time do + post :delete + expect(user.reload.remember_device_revoked_at).to eq Time.zone.now + end + end + it 'does not deletes backup codes if they are the only mfa' do user = build(:user, :with_backup_code) stub_sign_in(user) diff --git a/spec/controllers/users/edit_phone_controller_spec.rb b/spec/controllers/users/edit_phone_controller_spec.rb index e2f2296de56..c253f7efa8e 100644 --- a/spec/controllers/users/edit_phone_controller_spec.rb +++ b/spec/controllers/users/edit_phone_controller_spec.rb @@ -81,6 +81,15 @@ expect(PhoneConfiguration.find_by(id: phone_configuration.id)).to eq(nil) end + it 'revokes remember device cookies' do + stub_sign_in(user.reload) + expect(user.remember_device_revoked_at).to eq nil + freeze_time do + delete :destroy, params: { id: phone_configuration.id } + expect(user.reload.remember_device_revoked_at).to eq Time.zone.now + end + end + context 'when the user will not have enough phone configurations after deleting' do let(:user) { create(:user, :with_phone) } let(:phone_configuration) { user.phone_configurations.first } 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 ff067b336a0..4b39f1596b1 100644 --- a/spec/controllers/users/piv_cac_authentication_setup_controller_spec.rb +++ b/spec/controllers/users/piv_cac_authentication_setup_controller_spec.rb @@ -242,9 +242,11 @@ end it 'resets the remember device revocation date/time' do - delete :delete, params: { id: piv_cac_configuration_id } - expect(subject.current_user.reload.remember_device_revoked_at.to_i).to \ - be_within(1).of(Time.zone.now.to_i) + expect(user.remember_device_revoked_at).to eq nil + freeze_time do + delete :delete, params: { id: piv_cac_configuration_id } + expect(user.reload.remember_device_revoked_at).to eq Time.zone.now + end end it 'removes the piv/cac information from the user session' do diff --git a/spec/controllers/users/totp_setup_controller_spec.rb b/spec/controllers/users/totp_setup_controller_spec.rb index cfcf61a74c6..7428ca25156 100644 --- a/spec/controllers/users/totp_setup_controller_spec.rb +++ b/spec/controllers/users/totp_setup_controller_spec.rb @@ -398,6 +398,18 @@ expect(@analytics).to have_received(:track_event).with('TOTP: User Disabled') expect(subject).to have_received(:create_user_event).with(:authenticator_disabled) end + + it 'revokes remember device cookies' do + user = create(:user, :signed_up, :with_phone) + totp_app = user.auth_app_configurations.create(otp_secret_key: 'foo', name: 'My Auth App') + user.save + stub_sign_in(user) + expect(user.remember_device_revoked_at).to eq nil + freeze_time do + delete :disable, params: { id: totp_app.id } + expect(user.reload.remember_device_revoked_at).to eq Time.zone.now + end + end end context 'when totp is the last mfa method' do diff --git a/spec/controllers/users/webauthn_setup_controller_spec.rb b/spec/controllers/users/webauthn_setup_controller_spec.rb index aa570edd5e8..f36a3475f82 100644 --- a/spec/controllers/users/webauthn_setup_controller_spec.rb +++ b/spec/controllers/users/webauthn_setup_controller_spec.rb @@ -119,6 +119,14 @@ ).to eq 1 end + it 'revokes remember device cookies' do + expect(user.remember_device_revoked_at).to eq nil + freeze_time do + delete :delete, params: { id: webauthn_configuration.id } + expect(user.reload.remember_device_revoked_at).to eq Time.zone.now + end + end + it 'tracks the delete in analytics' do result = { success: true, diff --git a/spec/features/idv/doc_auth/document_capture_step_spec.rb b/spec/features/idv/doc_auth/document_capture_step_spec.rb index bce2bbdefa5..a630d6094c0 100644 --- a/spec/features/idv/doc_auth/document_capture_step_spec.rb +++ b/spec/features/idv/doc_auth/document_capture_step_spec.rb @@ -207,6 +207,20 @@ end end + context 'when new ssn controller is enabled' do + before do + allow(IdentityConfig.store).to receive(:doc_auth_ssn_controller_enabled). + and_return(true) + end + it 'redirects to ssn controller' do + expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) + + attach_and_submit_images + + expect(page).to have_current_path(idv_ssn_url) + end + end + def next_step idv_doc_auth_ssn_step end diff --git a/spec/features/idv/doc_auth/ssn_spec.rb b/spec/features/idv/doc_auth/ssn_spec.rb new file mode 100644 index 00000000000..d1d7c6b373d --- /dev/null +++ b/spec/features/idv/doc_auth/ssn_spec.rb @@ -0,0 +1,45 @@ +require 'rails_helper' + +feature 'doc auth ssn step', :js do + include IdvStepHelper + include DocAuthHelper + include DocCaptureHelper + + before do + allow(IdentityConfig.store).to receive(:proofing_device_profiling).and_return(:enabled) + allow(IdentityConfig.store).to receive(:lexisnexis_threatmetrix_org_id).and_return('test_org') + allow(IdentityConfig.store).to receive(:doc_auth_ssn_controller_enabled).and_return(true) + + sign_in_and_2fa_user + complete_doc_auth_steps_before_ssn_step + end + + it 'proceeds to the next page with valid info' do + expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_info')) + + fill_out_ssn_form_ok + + match = page.body.match(/session_id=(?[^"&]+)/) + session_id = match && match[:session_id] + expect(session_id).to be_present + + select 'Review', from: 'mock_profiling_result' + + expect(page.find_field(t('idv.form.ssn_label_html'))['aria-invalid']).to eq('false') + click_idv_continue + + expect(page).to have_current_path(idv_verify_info_url) + + profiling_result = Proofing::Mock::DeviceProfilingBackend.new.profiling_result(session_id) + expect(profiling_result).to eq('review') + end + + it 'does not proceed to the next page with invalid info' do + fill_out_ssn_form_fail + click_idv_continue + + expect(page.find_field(t('idv.form.ssn_label_html'))['aria-invalid']).to eq('true') + + expect(page).to have_current_path(idv_ssn_url) + end +end diff --git a/spec/features/idv/doc_auth/verify_info_step_spec.rb b/spec/features/idv/doc_auth/verify_info_step_spec.rb index 64cd6c07a85..078cb82e81f 100644 --- a/spec/features/idv/doc_auth/verify_info_step_spec.rb +++ b/spec/features/idv/doc_auth/verify_info_step_spec.rb @@ -374,4 +374,32 @@ expect(page).to have_current_path(idv_phone_path) end end + + context 'with ssn_controller enabled' do + before do + allow(IdentityConfig.store).to receive(:doc_auth_ssn_controller_enabled). + and_return(true) + allow_any_instance_of(ApplicationController).to receive(:analytics).and_return(fake_analytics) + sign_in_and_2fa_user + complete_doc_auth_steps_before_verify_step + end + + it 'uses ssn controller to enter a new ssn and displays updated info' do + click_link t('idv.buttons.change_ssn_label') + expect(page).to have_current_path(idv_ssn_path) + + fill_in t('idv.form.ssn_label_html'), with: '900456789' + click_button t('forms.buttons.submit.update') + + expect(fake_analytics).to have_logged_event( + 'IdV: doc auth redo_ssn submitted', + ) + + expect(page).to have_current_path(idv_verify_info_path) + + expect(page).to have_text('9**-**-***9') + check t('forms.ssn.show') + expect(page).to have_text('900-45-6789') + end + end end diff --git a/spec/features/phone/add_phone_spec.rb b/spec/features/phone/add_phone_spec.rb index 554f9dd24fc..ae08ae2058d 100644 --- a/spec/features/phone/add_phone_spec.rb +++ b/spec/features/phone/add_phone_spec.rb @@ -139,10 +139,15 @@ ) end - scenario 'adding a phone that is already on the user account shows error message' do + scenario 'adding a phone that is already on the user account shows error message', js: true do user = create(:user, :signed_up) phone = user.phone_configurations.first.phone + # Regression handling: The fake phone number generator uses well-formatted numbers, which isn't + # how a user would likely enter their number, and would give detail to the phone initialization + # which wouldn't exist for typical user input. Emulate the user input by removing format hints. + phone = phone.sub(/^\+1\s*/, '').gsub(/\D/, '') + sign_in_and_2fa_user(user) within('.sidenav') do click_on t('account.navigation.add_phone_number') @@ -151,6 +156,7 @@ click_continue expect(page).to have_content(I18n.t('errors.messages.phone_duplicate')) + expect(page).to have_css('.iti__selected-flag .iti__flag.iti__us', visible: :all) end let(:telephony_gem_voip_number) { '+12255551000' } diff --git a/spec/features/remember_device/revocation_spec.rb b/spec/features/remember_device/revocation_spec.rb index a05b53fb282..d55c8921211 100644 --- a/spec/features/remember_device/revocation_spec.rb +++ b/spec/features/remember_device/revocation_spec.rb @@ -7,133 +7,6 @@ allow(IdentityConfig.store).to receive(:otp_delivery_blocklist_maxretry).and_return(1000) end - context 'phone' do - let(:user) { create(:user, :signed_up) } - - it 'revokes remember device when removed' do - create(:webauthn_configuration, user: user) # The user needs multiple methods to delete phone - - sign_in_with_remember_device_and_sign_out - - sign_in_user(user) - click_link( - t('forms.buttons.manage'), - href: manage_phone_path(id: user.phone_configurations.first.id), - ) - click_on t('forms.phone.buttons.delete') - first(:link, t('links.sign_out')).click - - expect_mfa_to_be_required_for_user(user) - end - end - - context 'webauthn' do - let(:user) { create(:user, :signed_up, :with_webauthn) } - - it 'revokes remember device when removed' do - sign_in_with_remember_device_and_sign_out - - sign_in_user(user) - visit account_two_factor_authentication_path - click_on t('account.index.webauthn_delete') - click_on t('account.index.webauthn_confirm_delete') - first(:link, t('links.sign_out')).click - - expect_mfa_to_be_required_for_user(user) - end - end - - context 'webauthn platform' do - let(:user) { create(:user, :signed_up, :with_webauthn_platform) } - - it 'revokes remember device when removed' do - sign_in_with_remember_device_and_sign_out - - sign_in_user(user) - visit account_two_factor_authentication_path - click_on t('account.index.webauthn_platform_delete') - click_on t('account.index.webauthn_platform_confirm_delete') - first(:link, t('links.sign_out')).click - - expect_mfa_to_be_required_for_user(user) - end - end - - context 'piv/cac' do - let(:user) { create(:user, :signed_up, :with_piv_or_cac) } - - it 'revokes remember device when removed' do - sign_in_with_remember_device_and_sign_out - - sign_in_user(user) - visit account_two_factor_authentication_path - page.find('.remove-piv').click - click_on t('account.index.piv_cac_confirm_delete') - first(:link, t('links.sign_out')).click - - expect_mfa_to_be_required_for_user(user) - end - end - - context 'totp' do - let(:user) { create(:user, :signed_up, :with_authentication_app) } - - it 'revokes remember device when removed' do - sign_in_with_remember_device_and_sign_out - - sign_in_user(user) - visit account_two_factor_authentication_path - page.find('.remove-auth-app').click # Delete - click_on t('account.index.totp_confirm_delete') - first(:link, t('links.sign_out')).click - - expect_mfa_to_be_required_for_user(user) - end - end - - context 'backup codes' do - let(:user) { create(:user, :signed_up, :with_authentication_app, :with_backup_code) } - - it 'revokes remember device when regenerated' do - sign_in_with_remember_device_and_sign_out - - sign_in_user(user) - visit account_two_factor_authentication_path - click_on t('forms.backup_code.regenerate') - click_on t('account.index.backup_code_confirm_regenerate') - expect(page).to have_content(t('forms.backup_code.subtitle')) - click_continue - first(:link, t('links.sign_out')).click - - expect_mfa_to_be_required_for_user(user) - end - - it 'revokes remember device when removed' do - user.backup_code_configurations.destroy_all - sign_in_with_remember_device_and_sign_out - - sign_in_user(user) - visit account_two_factor_authentication_path - click_on t('forms.backup_code.generate') - click_continue - click_continue - - expect(user.reload.backup_code_configurations).to_not be_empty - - click_link( - t('forms.buttons.delete'), - href: backup_code_delete_path, - ) - click_on t('account.index.backup_code_confirm_delete') - - expect(user.reload.backup_code_configurations).to be_empty - - first(:link, t('links.sign_out')).click - - expect_mfa_to_be_required_for_user(user) - end - end - context 'clicking forget browsers' do let(:user) { create(:user, :signed_up) } diff --git a/spec/forms/openid_connect_authorize_form_spec.rb b/spec/forms/openid_connect_authorize_form_spec.rb index bb76a2dd5fd..05f45dfd74b 100644 --- a/spec/forms/openid_connect_authorize_form_spec.rb +++ b/spec/forms/openid_connect_authorize_form_spec.rb @@ -356,6 +356,98 @@ end end + describe '#aal' do + context 'when DEFAULT_AAL passed' do + before do + default = Saml::Idp::Constants::DEFAULT_AAL_AUTHN_CONTEXT_CLASSREF + IdentityConfig.store.valid_authn_contexts.push(default) + end + + after do + IdentityConfig.store.valid_authn_contexts.pop + end + + let(:acr_values) { Saml::Idp::Constants::DEFAULT_AAL_AUTHN_CONTEXT_CLASSREF } + + it 'returns 0' do + expect(form.aal).to eq(Saml::Idp::Constants::DEFAULT_AAL_AUTHN_CONTEXT_CLASSREF) + end + end + + context 'when AAL1 passed' do + before do + aal1 = Saml::Idp::Constants::AAL1_AUTHN_CONTEXT_CLASSREF + IdentityConfig.store.valid_authn_contexts.push(aal1) + end + + after do + IdentityConfig.store.valid_authn_contexts.pop + end + + let(:acr_values) { Saml::Idp::Constants::AAL1_AUTHN_CONTEXT_CLASSREF } + + it 'returns 1' do + expect(form.aal).to eq(1) + end + end + + context 'when AAL2 passed' do + let(:acr_values) { Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF } + + it 'returns 2' do + expect(form.aal).to eq(2) + end + end + + context 'when AAL2_PHISHING_RESISTANT passed' do + let(:acr_values) { Saml::Idp::Constants::AAL2_PHISHING_RESISTANT_AUTHN_CONTEXT_CLASSREF } + + it 'returns 2' do + expect(form.aal).to eq(2) + end + end + + context 'when AAL2_HSPD12 passed' do + let(:acr_values) { Saml::Idp::Constants::AAL2_HSPD12_AUTHN_CONTEXT_CLASSREF } + + it 'returns 2' do + expect(form.aal).to eq(2) + end + end + + context 'when AAL3 passed' do + let(:acr_values) { Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF } + + it 'returns 3' do + expect(form.aal).to eq(3) + end + end + + context 'when AAL3_HSPD12 passed' do + let(:acr_values) { Saml::Idp::Constants::AAL3_HSPD12_AUTHN_CONTEXT_CLASSREF } + + it 'returns 3' do + expect(form.aal).to eq(3) + end + end + end + + describe '#aal' do + context 'when IAL and AAL passed' do + aal2 = Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF + ial2 = Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF + + let(:acr_values) do + "#{aal2} #{ial2}" + end + + it 'returns ial and aal' do + expect(form.aal).to eq(2) + expect(form.ial).to eq(2) + end + end + end + describe '#verified_within' do context 'without a verified_within' do let(:verified_within) { nil } diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 2ccfdf73bf5..71e197a8f67 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -510,6 +510,70 @@ end end + describe '#personal_key_generated_at' do + let(:user) do + build(:user, encrypted_recovery_code_digest_generated_at: digest_generated_at) + end + let(:digest_generated_at) { nil } + + context 'the user has a encrypted_recovery_code_digest_generated_at date' do + let(:digest_generated_at) { 1.day.ago } + + it 'returns the date in the digest' do + expect( + user.personal_key_generated_at, + ).to be_within(1.second).of(digest_generated_at) + end + end + + context 'the user does not have a encrypted_recovery_code_digest_generated_at but is proofed' do + let!(:profile) do + create( + :profile, + :active, + :verified, + user: user, + ) + end + + it 'returns the date the user was proofed' do + expect( + user.personal_key_generated_at, + ).to be_within(1.second).of(profile.verified_at) + end + end + + context 'the user has no encrypted_recovery_code_digest_generated_at and is not proofed' do + it 'returns nil' do + expect(user.personal_key_generated_at).to be_nil + end + end + + context 'the user has no active profile but has a previously verified profile' do + let!(:password_reset_profile) do + create( + :profile, + :password_reset, + user: user, + ) + end + + let!(:verification_cancelled_profile) do + create( + :profile, + :verification_cancelled, + user: user, + ) + end + + it 'returns the date of the previously verified profile' do + expect( + user.personal_key_generated_at, + ).to be_within(1.second).of(password_reset_profile.verified_at) + end + end + end + describe '#should_receive_in_person_completion_survey?' do let!(:user) { create(:user) } let(:service_provider) { create(:service_provider) } diff --git a/spec/presenters/openid_connect_user_info_presenter_spec.rb b/spec/presenters/openid_connect_user_info_presenter_spec.rb index 4411d4a0422..c380b2875da 100644 --- a/spec/presenters/openid_connect_user_info_presenter_spec.rb +++ b/spec/presenters/openid_connect_user_info_presenter_spec.rb @@ -17,6 +17,7 @@ user: create(:user, profiles: [profile]), service_provider: service_provider.issuer, scope: scope, + aal: 2, ) end @@ -26,12 +27,17 @@ subject(:user_info) { presenter.user_info } it 'has basic attributes' do + ial = Saml::Idp::Constants::AUTHN_CONTEXT_IAL_TO_CLASSREF[identity.ial] + aal = Saml::Idp::Constants::AUTHN_CONTEXT_AAL_TO_CLASSREF[identity.aal] + aggregate_failures do expect(user_info[:sub]).to eq(identity.uuid) expect(user_info[:iss]).to eq(root_url) expect(user_info[:email]).to eq(identity.user.email_addresses.first.email) expect(user_info[:email_verified]).to eq(true) expect(user_info[:all_emails]).to eq([identity.user.email_addresses.first.email]) + expect(user_info[:ial]).to eq(ial) + expect(user_info[:aal]).to eq(aal) end end diff --git a/spec/support/controller_helper.rb b/spec/support/controller_helper.rb index 70a2ea6b009..ac1eba140fd 100644 --- a/spec/support/controller_helper.rb +++ b/spec/support/controller_helper.rb @@ -47,7 +47,7 @@ def stub_idv_steps_before_verify_step(user) dob: 50.years.ago.to_date.to_s, ssn: '666-12-1234', }.with_indifferent_access - allow(subject).to receive(:confirm_idv_session_started).and_return(true) + allow(subject).to receive(:confirm_idv_applicant_created).and_return(true) allow(subject).to receive(:idv_session).and_return(idv_session) allow(subject).to receive(:user_session).and_return(user_session) end @@ -67,7 +67,7 @@ def stub_verify_steps_one_and_two(user) ssn: '666-12-1234', }.with_indifferent_access idv_session.profile_confirmation = true - allow(subject).to receive(:confirm_idv_session_started).and_return(true) + allow(subject).to receive(:confirm_idv_applicant_created).and_return(true) allow(subject).to receive(:idv_session).and_return(idv_session) allow(subject).to receive(:user_session).and_return(user_session) end @@ -81,7 +81,7 @@ def stub_user_with_applicant_data(user, applicant) ) idv_session.applicant = applicant.with_indifferent_access idv_session.profile_confirmation = true - allow(subject).to receive(:confirm_idv_session_started).and_return(true) + allow(subject).to receive(:confirm_idv_applicant_created).and_return(true) allow(subject).to receive(:idv_session).and_return(idv_session) allow(subject).to receive(:user_session).and_return(user_session) end diff --git a/spec/views/partials/personal_key/_key.html.erb_spec.rb b/spec/views/partials/personal_key/_key.html.erb_spec.rb new file mode 100644 index 00000000000..3f697be8584 --- /dev/null +++ b/spec/views/partials/personal_key/_key.html.erb_spec.rb @@ -0,0 +1,44 @@ +require 'rails_helper' + +describe 'partials/personal_key/_key.html.erb' do + subject(:rendered) do + render partial: 'key', locals: locals + end + + let(:personal_key) { 'XXXX-XXXX-XXXX-XXXX' } + + context 'with local personal_key_generated_at' do + let(:personal_key_generated_at) { Time.zone.parse('2020-04-09T14:03:00Z').utc } + let(:locals) do + { + code: personal_key, + personal_key_generated_at: personal_key_generated_at, + show_save_buttons: false, + } + end + + it 'displays the specified date' do + expect(rendered).to have_css( + 'lg-time[data-timestamp="2020-04-09T14:03:00Z"][data-format]', + text: 'April 9, 2020 at 2:03 PM', + ) + end + + it 'displays personal key block' do + expect(rendered).to have_css('.personal-key-block__code') + end + end + + context 'without local personal_key_generated_at' do + let(:locals) do + { + code: personal_key, + show_save_buttons: false, + } + end + + it 'displays personal key block' do + expect(rendered).to have_css('.personal-key-block__code') + end + end +end diff --git a/spec/views/reactivate_account/index.html.erb_spec.rb b/spec/views/reactivate_account/index.html.erb_spec.rb index 819028de174..3aca5ed3a7a 100644 --- a/spec/views/reactivate_account/index.html.erb_spec.rb +++ b/spec/views/reactivate_account/index.html.erb_spec.rb @@ -1,9 +1,24 @@ require 'rails_helper' describe 'reactivate_account/index.html.erb' do - it 'displays a fallback warning alert when js is off' do + subject(:rendered) do render + end + + let(:personal_key_generated_at) { Time.zone.parse('2020-04-09T14:03:00Z').utc } + + it 'displays a fallback warning alert when js is off' do + assign(:personal_key_generated_at, personal_key_generated_at) expect(rendered).to have_content(t('instructions.account.reactivate.modal.copy')) end + + it 'displays the date the personal key was generated' do + assign(:personal_key_generated_at, personal_key_generated_at) + + expect(rendered).to have_css( + 'lg-time[data-timestamp="2020-04-09T14:03:00Z"][data-format]', + text: 'April 9, 2020 at 2:03 PM', + ) + end end diff --git a/spec/views/shared/_personal_key.html.erb_spec.rb b/spec/views/shared/_personal_key.html.erb_spec.rb index e99b12bfd1a..4aaeb30300a 100644 --- a/spec/views/shared/_personal_key.html.erb_spec.rb +++ b/spec/views/shared/_personal_key.html.erb_spec.rb @@ -2,8 +2,14 @@ RSpec.describe 'shared/_personal_key.html.erb' do let(:personal_key) { RandomPhrase.new(num_words: 4).to_s } + let(:personal_key_generated_at) { Time.zone.today } - subject(:rendered) { render 'shared/personal_key', code: personal_key, update_path: '/test' } + subject(:rendered) do + render 'shared/personal_key', + code: personal_key, + personal_key_generated_at: personal_key_generated_at, + update_path: '/test' + end describe 'download link' do it 'has the download attribute and a data: url for the personal key' do diff --git a/yarn.lock b/yarn.lock index d5c140ab952..fc21062e5bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4026,10 +4026,10 @@ interpret@^2.2.0: resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9" integrity sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw== -intl-tel-input@^17.0.8: - version "17.0.12" - resolved "https://registry.yarnpkg.com/intl-tel-input/-/intl-tel-input-17.0.12.tgz#057c35b57871bd6d6932ac28428d086e63d6cc89" - integrity sha512-jl3DkDQg/aaIPK2hrvtgX2eYgtkz5LxCQW57Ru1Hpdt9MA9VE8PnGUllaMsAm6SeJODHBdMok7XFZR8/M1yytg== +intl-tel-input@^17.0.19: + version "17.0.19" + resolved "https://registry.yarnpkg.com/intl-tel-input/-/intl-tel-input-17.0.19.tgz#4c277e3bf02069fac2ef3821a62a3d7e8b55740a" + integrity sha512-GBNoUT4JVgm2e1N+yFMaBQ24g5EQfZhDznGneCM9IEZwfKsMUAUa1dS+v0wOiKpRAZ5IPNLJMIEEFGgqlCI22A== ipaddr.js@1.9.1: version "1.9.1"