From f5c2cf6fcb215cff050c6bbe814a3ff002648a28 Mon Sep 17 00:00:00 2001 From: Vraj Mohan Date: Tue, 14 Jan 2025 07:16:10 -0800 Subject: [PATCH 1/8] Update translations for PIV/CAC mismatch (#11735) changelog: User-Facing Improvements, Translations, Update translations for PIV/CAC mismatch --- config/locales/es.yml | 14 +++++++------- config/locales/zh.yml | 10 +++++----- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/config/locales/es.yml b/config/locales/es.yml index 00686cb34f0..6c3a7ea4a9d 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -906,12 +906,12 @@ forms.personal_key.instructions: Confirme que tiene una copia de su clave person forms.personal_key.required_checkbox: Guardé mi clave personal en un lugar seguro. forms.personal_key.title: Ingrese su clave personal forms.phone.buttons.delete: Eliminar teléfono -forms.piv_cac_login.submit: Inserte su tarjeta PIV o CAC. -forms.piv_cac_mfa.submit: Inserte su tarjeta PIV o CAC. +forms.piv_cac_login.submit: Inserte su tarjeta PIV o CAC +forms.piv_cac_mfa.submit: Inserte su tarjeta PIV o CAC forms.piv_cac_setup.nickname: Alias de la tarjeta PIV o CAC forms.piv_cac_setup.no_thanks: No, gracias forms.piv_cac_setup.piv_cac_intro_html: Le pediremos que presente su tarjeta PIV o CAC cada vez que inicie sesión como parte de la autenticación de dos factores.

Después de hacer clic en “Agregar tarjeta PIV o CAC”, su navegador le pedirá el PIN de su tarjeta PIV o CAC y que seleccione un certificado. -forms.piv_cac_setup.submit: Inserte su tarjeta PIV o CAC. +forms.piv_cac_setup.submit: Inserte su tarjeta PIV o CAC forms.piv_cac_setup.try_again: Vuelva a intentarlo forms.registration.labels.email: Ingrese su dirección de correo electrónico forms.registration.labels.email_language: Seleccione su preferencia de idioma del correo electrónico @@ -1389,7 +1389,7 @@ instructions.go_back_to_mobile_app: Para continuar, vuelva a la aplicación %{fr instructions.mfa.authenticator.confirm_code_html: Ingrese el código de su aplicación de autenticación. Si tiene varias cuentas configuradas en su aplicación, ingrese el código correspondiente a %{app_name_html}. instructions.mfa.authenticator.manual_entry: O ingrese este código manualmente en su aplicación de autenticación instructions.mfa.piv_cac.account_not_found_html: '

%{sign_in} con su dirección de correo electrónico y su contraseña. Luego, agregue su tarjeta PIV o CAC a su cuenta.

¿No tiene una cuenta de %{app_name}? %{create_account}

' -instructions.mfa.piv_cac.add_from_sign_in_html: ' Instrucciones: Inserte su tarjeta PIV o CAC en “AGREGAR TARJETA PIV O CAC” . Tendrá que elegir un certificado (es probable que el correcto tenga su nombre) e ingrese su PIN (su PIN se creó cuando configuró su tarjeta PIV o CAC).' +instructions.mfa.piv_cac.add_from_sign_in_html: ' Instrucciones: Inserte su tarjeta PIV o CAC en “AGREGAR TARJETA PIV O CAC” . Tendrá que elegir un certificado (es probable que el correcto tenga su nombre) e ingresar su PIN (su PIN se creó cuando configuró su tarjeta PIV o CAC).' instructions.mfa.piv_cac.already_associated_html: Elija un certificado de una tarjeta PIV o CAC diferente y contacte con el administrador para confirmar que su tarjeta PIV o CAC está al día. Si cree que se trata de un error, %{try_again_html}. instructions.mfa.piv_cac.back_to_sign_in: Vuelva a iniciar sesión instructions.mfa.piv_cac.confirm_piv_cac: Inserte la tarjeta PIV o CAC asociada con su cuenta en un lector de tarjetas inteligente. @@ -1403,7 +1403,7 @@ instructions.mfa.piv_cac.step_1: Asígnele un alias. instructions.mfa.piv_cac.step_1_info: Si agrega más de una tarjeta PIV o CAC, podrá distinguirlas. instructions.mfa.piv_cac.step_2: Inserte su tarjeta PIV o CAC en su lector de tarjetas. instructions.mfa.piv_cac.step_3: Agregue su tarjeta PIV o CAC. -instructions.mfa.piv_cac.step_3_info_html: Tendrá que elegir un certificado (es probable que el correcto tenga su nombre) e ingrese su PIN (su PIN se creó cuando configuró su tarjeta PIV o CAC) +instructions.mfa.piv_cac.step_3_info_html: Tendrá que elegir un certificado (es probable que el correcto tenga su nombre) e ingresar su PIN (su PIN se creó cuando configuró su tarjeta PIV o CAC) instructions.mfa.piv_cac.try_again: vuelva a intentarlo instructions.mfa.sms.number_message_html: Enviamos un mensaje de texto (SMS) con un código de un solo uso al %{number_html}. Este código vence en %{expiration} minutos. instructions.mfa.voice.number_message_html: Hicimos una llamada con un código de un solo uso al %{number_html}. Este código vence en %{expiration} minutos. @@ -1685,7 +1685,7 @@ two_factor_authentication.account_reset.cancel_link: Cancelar su solicitud two_factor_authentication.account_reset.link: eliminando su cuenta two_factor_authentication.account_reset.pending: Actualmente tiene una solicitud pendiente para eliminar su cuenta. Se necesitan %{interval} desde el momento en que realizó la solicitud para completar el proceso. Por favor, vuelva más tarde. two_factor_authentication.account_reset.successful_cancel: Gracias. Su solicitud para eliminar su cuenta de %{app_name} ha sido cancelada. -two_factor_authentication.account_reset.text_html: Si no puede usar ninguno de los métodos de autenticación anteriores, puede restablecer sus preferencias en %{link_html}. +two_factor_authentication.account_reset.text_html: Si no puede usar ninguno de los métodos de autenticación anteriores, puede restablecer sus preferencias %{link_html}. two_factor_authentication.attempt_remaining_warning_html.one: Le queda %{count} intento. two_factor_authentication.attempt_remaining_warning_html.other: Le quedan %{count} intentos. two_factor_authentication.auth_app.change_nickname: Cambiar apodo @@ -1774,7 +1774,7 @@ two_factor_authentication.piv_cac_mismatch.cta: Autenticar y añadir tarjeta PIV two_factor_authentication.piv_cac_mismatch.delete_account: Eliminar su cuenta two_factor_authentication.piv_cac_mismatch.instructions: Haga clic en “Autenticar y añadir tarjeta PIV o CAC” más abajo para autenticar con otro método antes de añadir esta tarjeta PIV o CAC a su cuenta. two_factor_authentication.piv_cac_mismatch.instructions_no_other_method: Si se le emitió una nueva tarjeta PIV o CAC, para poder usarla, deberá eliminar su cuenta de %{app_name} y crear una cuenta nueva. -two_factor_authentication.piv_cac_mismatch.skip: Saltar añadir tarjeta PIV o CAC +two_factor_authentication.piv_cac_mismatch.skip: Omitir añadir tarjeta PIV o CAC two_factor_authentication.piv_cac_mismatch.title: Esta tarjeta de identificación de empleado del gobierno no está conectada a su cuenta two_factor_authentication.piv_cac_upsell.add_piv: Agregar tarjeta PIV/CAC two_factor_authentication.piv_cac_upsell.choose_other_method: Elegir otros métodos diff --git a/config/locales/zh.yml b/config/locales/zh.yml index 00a41aa73e8..20b499180b1 100644 --- a/config/locales/zh.yml +++ b/config/locales/zh.yml @@ -969,7 +969,7 @@ headings.passwords.confirm: 确认你目前密码以继续 headings.passwords.confirm_for_personal_key: 输入密码并获得一个新个人密钥 headings.passwords.forgot: 忘了你的密码? headings.piv_cac_login.account_not_found: 您的政府雇员ID未与帐户连接 -headings.piv_cac_login.add: 使用智能卡读卡器设置您的个人身份验证 (PIV) 或通用访问卡 (CAC)。您可以使用其中任何一种作为双因素身份证实方法来登录。 +headings.piv_cac_login.add: 使用智能卡读卡器设置你的个人身份验证 (PIV) 或通用访问卡 (CAC)。你可以使用其中任何一种作为双因素身份证实方法来登录。 headings.piv_cac_login.new: 插入您的政府雇员ID headings.piv_cac_login.success: 你已成功把 PIV/CAC 设为一个身份证实方法 headings.piv_cac_setup.already_associated: 你提供的 PIV/CAC 与另外一个用户相关。 @@ -1391,7 +1391,7 @@ instructions.go_back_to_mobile_app: 要继续的话,请回到 %{friendly_name} instructions.mfa.authenticator.confirm_code_html: 输入来自你身份证实应用程序的代码。如果你在应用程序中设了几个账户,请输入与在 %{app_name_html}对应的代码。 instructions.mfa.authenticator.manual_entry: 或者动手将这个密码输入你的身份证实应用程序。 instructions.mfa.piv_cac.account_not_found_html: '

使用您的电子邮件地址和密码 %{sign_in}。然后将您的 PIV/CAC 插入智能卡读卡器以添加到您的帐户。

没有 %{app_name} 帐户?%{create_account}

' -instructions.mfa.piv_cac.add_from_sign_in_html: '说明: 看到“添加 PIV/CAC”时插入你的 PIV or CAC 。你将需要选择一个证书 (恰当的证书可能会有你的名字)而且输入你的个人识别号码(PIN) (你的个人识别号码(PIN)是在设置 PIV/CAC 时设立的)。' +instructions.mfa.piv_cac.add_from_sign_in_html: '说明: 看到“添加 PIV/CAC”时插入你的 PIV or CAC 。你将需要选择一个证书 (恰当的证书可能会有你的名字)而且输入你的 PIN (你的 PIN 是在设置 PIV/CAC 时设立的)。' instructions.mfa.piv_cac.already_associated_html: 请从另一个 PIV/CAC 选择证书,联系管理员以保证你的 PIV/CAC 是最新的。如果你认为这是一个错误, %{try_again_html}。 instructions.mfa.piv_cac.back_to_sign_in: 返回去登录 instructions.mfa.piv_cac.confirm_piv_cac: 将与您的帐户关联的 PIV/CAC 插入智能卡读卡器。 @@ -1405,7 +1405,7 @@ instructions.mfa.piv_cac.step_1: 给它一个昵称 instructions.mfa.piv_cac.step_1_info: 这样如果你添加了一个以上 PIV/CAC 话,你就能把它们分辨开来。 instructions.mfa.piv_cac.step_2: 把 PIV/CAC 插入读卡器 instructions.mfa.piv_cac.step_3: 添加 PIV/CAC -instructions.mfa.piv_cac.step_3_info_html: 你将需要选择一个证书 (恰当的证书可能会有你的名字)而且输入你的个人识别号码(PIN) (你的个人识别号码(PIN)是在设置 PIV/CAC 时设立的)。 +instructions.mfa.piv_cac.step_3_info_html: 你将需要选择一个证书 (恰当的证书可能会有你的名字)而且输入你的 PIN (你的 PIN 是在设置 PIV/CAC 时设立的)。 instructions.mfa.piv_cac.try_again: 再试一次 instructions.mfa.sms.number_message_html: 我们把带有一次性代码的短信发到了 %{number_html}。这一代码 %{expiration} 分钟后会作废。 instructions.mfa.voice.number_message_html: 我们给 %{number_html}打了电话告知一次性代码。这一代码 %{expiration} 分钟后会作废。 @@ -1656,7 +1656,7 @@ titles.openid_connect.logout: OpenID Connect 登出 titles.passwords.change: 更改你账户密码 titles.passwords.forgot: 重设密码 titles.personal_key: 万一 -titles.piv_cac_login.add: 添加您的政府雇员ID +titles.piv_cac_login.add: 添加你的政府雇员ID titles.piv_cac_login.new: 用 PIV/CAC 登入你的账户 titles.piv_cac_setup.new: 用 PIV/CAC 卡来保护你账户安全 titles.piv_cac_setup.upsell: 添加你的政府雇员身份证件更快、更安全地登录 @@ -1713,7 +1713,7 @@ two_factor_authentication.invalid_otp: 那个一次性代码有误。再试一 two_factor_authentication.invalid_personal_key: 那个个人密钥有误 two_factor_authentication.invalid_piv_cac: 那个 PIV/CAC 没起作用。确保 PIV/CAC 是这个账户的。如果的确是,那你的PIV/CAC、个人识别号码(PIN)有问题,或者我们这边出了问题。再试一次或选择另一个身份证实方法。 two_factor_authentication.learn_more: 了解身份证实选项的更多信息 -two_factor_authentication.login_intro: 你设立账户时设了它们。 +two_factor_authentication.login_intro: 你设立账户时设了以下方法。 two_factor_authentication.login_intro_reauthentication: 在你能对账户做出任何更改前,我们需要你使用你一种身份证实方法来确定的确是你。 two_factor_authentication.login_options_link_text: 选择另一个身份证实方法 two_factor_authentication.login_options_reauthentication_title: 须重新进行身份证实 From 7c1af71d604f410fc7af9f1b0cc813b36597f409 Mon Sep 17 00:00:00 2001 From: Andrew Duthie <1779930+aduth@users.noreply.github.com> Date: Tue, 14 Jan 2025 11:44:08 -0500 Subject: [PATCH 2/8] Update Rubocop to latest version (1.70.0) (#11743) * Update Rubocop to latest version (1.70.0) changelog: Internal, Dependencies, Update dependency to latest version * Fix lint error in backup code reminder view specs --- .rubocop.yml | 5 ++++- Gemfile | 2 +- Gemfile.lock | 12 ++++++------ .../options/index.html.erb_spec.rb | 2 +- .../backup_code_setup/reminder.html.erb_spec.rb | 4 +--- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index aac7ab70285..0d369481f1b 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -29,7 +29,7 @@ AllCops: - 'tmp/**/*' - 'vendor/**/*' - 'public/**/*' - TargetRubyVersion: 3.2.0 + TargetRubyVersion: 3.3.0 TargetRailsVersion: 7.2 UseCache: true DisabledByDefault: true @@ -391,6 +391,9 @@ Lint/BooleanSymbol: Lint/CircularArgumentReference: Enabled: true +Lint/ConstantReassignment: + Enabled: true + Lint/Debugger: Enabled: true diff --git a/Gemfile b/Gemfile index bea078bd2b9..392aa957920 100644 --- a/Gemfile +++ b/Gemfile @@ -118,7 +118,7 @@ group :development, :test do gem 'psych' gem 'rspec', '~> 3.13.0' gem 'rspec-rails', '~> 7.0' - gem 'rubocop', '~> 1.69.1', require: false + gem 'rubocop', '~> 1.70.0', require: false gem 'rubocop-performance', '~> 1.23.0', require: false gem 'rubocop-rails', '~> 2.27.0', require: false gem 'rubocop-rspec', '~> 3.2.0', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 917879269d1..aa5b10f1a52 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -390,7 +390,7 @@ GEM jmespath (1.6.2) jsbundling-rails (1.1.2) railties (>= 6.0.0) - json (2.9.0) + json (2.9.1) jwe (0.4.0) jwt (2.7.1) knapsack (4.0.0) @@ -582,7 +582,7 @@ GEM redis-client (>= 0.22.0) redis-client (0.23.0) connection_pool - regexp_parser (2.9.3) + regexp_parser (2.10.0) reline (0.5.12) io-console (~> 0.5) request_store (1.5.1) @@ -623,7 +623,7 @@ GEM rspec-support (3.13.2) rspec_junit_formatter (0.6.0) rspec-core (>= 2, < 4, != 2.12.0) - rubocop (1.69.1) + rubocop (1.70.0) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) @@ -633,7 +633,7 @@ GEM rubocop-ast (>= 1.36.2, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.36.2) + rubocop-ast (1.37.0) parser (>= 3.3.1.0) rubocop-capybara (2.21.0) rubocop (~> 1.41) @@ -706,7 +706,7 @@ GEM openssl-signature_algorithm (~> 1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - unicode-display_width (2.5.0) + unicode-display_width (2.6.0) uniform_notifier (1.16.0) uri (0.13.0) useragent (0.16.11) @@ -854,7 +854,7 @@ DEPENDENCIES rspec-rails (~> 7.0) rspec-retry rspec_junit_formatter - rubocop (~> 1.69.1) + rubocop (~> 1.70.0) rubocop-capybara rubocop-performance (~> 1.23.0) rubocop-rails (~> 2.27.0) diff --git a/spec/views/two_factor_authentication/options/index.html.erb_spec.rb b/spec/views/two_factor_authentication/options/index.html.erb_spec.rb index 97e379c4024..3a194e716e2 100644 --- a/spec/views/two_factor_authentication/options/index.html.erb_spec.rb +++ b/spec/views/two_factor_authentication/options/index.html.erb_spec.rb @@ -26,7 +26,7 @@ end it 'has a localized title' do - expect(view).to receive(:title=).with( \ + expect(view).to receive(:title=).with( t('two_factor_authentication.login_options_title'), ) diff --git a/spec/views/users/backup_code_setup/reminder.html.erb_spec.rb b/spec/views/users/backup_code_setup/reminder.html.erb_spec.rb index 0ae32f5e231..78882c82b66 100644 --- a/spec/views/users/backup_code_setup/reminder.html.erb_spec.rb +++ b/spec/views/users/backup_code_setup/reminder.html.erb_spec.rb @@ -2,9 +2,7 @@ RSpec.describe 'users/backup_code_setup/reminder.html.erb' do it 'has a localized title' do - expect(view).to receive(:title=).with( \ - t('forms.backup_code.title'), - ) + expect(view).to receive(:title=).with(t('forms.backup_code.title')) render end From 61614f9c616c9ca40565d1f9d9edb280d2ec7209 Mon Sep 17 00:00:00 2001 From: Andrew Duthie <1779930+aduth@users.noreply.github.com> Date: Tue, 14 Jan 2025 11:49:23 -0500 Subject: [PATCH 3/8] Remove unused file input styles (#11745) changelog: Internal, Performance, Reduce size of application stylesheet --- .../components/_file-input.scss | 20 ++----------------- 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/app/javascript/packages/document-capture/components/_file-input.scss b/app/javascript/packages/document-capture/components/_file-input.scss index e1e6e31784e..d7e22048390 100644 --- a/app/javascript/packages/document-capture/components/_file-input.scss +++ b/app/javascript/packages/document-capture/components/_file-input.scss @@ -17,18 +17,6 @@ border-width: 3px; } -usa-file-input:not( - .usa-file-input--has-value, - .usa-file-input--value-pending, - .usa-file-input--is-id-capture - ) - .usa-form-group--success - .usa-file-input - .usa-file-input__target { - height: 21rem; - width: 12rem; -} - .usa-file-input:not(.usa-file-input--has-value, .usa-file-input--value-pending) { .usa-file-input__target { border-color: color('primary'); @@ -88,12 +76,8 @@ usa-file-input:not( } } .usa-file-input.usa-file-input--single-value:not(.usa-file-input--is-id-capture) { - .usa-file-input__preview { - width: 12rem; - } - .usa-file-input__target { - width: 12rem; - } + .usa-file-input__preview, + .usa-file-input__target, .usa-file-input__preview-image { width: 12rem; } From 61a1077dfd7816f68591fa207f8feafa4737173e Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Tue, 14 Jan 2025 08:56:23 -0800 Subject: [PATCH 4/8] Remove preview_methods_that_can_be_missing (#11749) This was added to support changing the name of a template in #11604 and is no longer needed. [skip changelog] --- spec/support/shared_examples/mailer_preview.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/support/shared_examples/mailer_preview.rb b/spec/support/shared_examples/mailer_preview.rb index 4f359c1f43b..ea6eea84e19 100644 --- a/spec/support/shared_examples/mailer_preview.rb +++ b/spec/support/shared_examples/mailer_preview.rb @@ -1,10 +1,10 @@ -RSpec.shared_examples 'a mailer preview' do |preview_methods_that_can_be_missing: []| +RSpec.shared_examples 'a mailer preview' do let(:mailer_class) { described_class.class_name.gsub(/Preview$/, '').constantize } it 'has a preview method for each mailer method' do mailer_methods = mailer_class.instance_methods(false) preview_methods = described_class.instance_methods(false) - expect(mailer_methods - preview_methods).to eql(preview_methods_that_can_be_missing) + expect(mailer_methods - preview_methods).to eql([]) end described_class.instance_methods(false).each do |mailer_method| From d6fb2bcfbd55c9b4c8530e75ff651ed9b2a76de1 Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Tue, 14 Jan 2025 12:49:02 -0500 Subject: [PATCH 5/8] Fix the TN AAMVA maintenance window scheulde (#11751) The AAMVA maintenance window for TN had a typo where `duration_minutes` was named `duration`. This resulted in a `NoMethodError` when we tried to call `minutes` on `nil`. [skip changelog] --- app/services/idv/aamva_state_maintenance_window.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/idv/aamva_state_maintenance_window.rb b/app/services/idv/aamva_state_maintenance_window.rb index 357d5beca98..e1024de8c2f 100644 --- a/app/services/idv/aamva_state_maintenance_window.rb +++ b/app/services/idv/aamva_state_maintenance_window.rb @@ -153,7 +153,7 @@ class AamvaStateMaintenanceWindow ], 'TN' => [ # Last Sunday of every month from 11:00 pm Sunday to 2:00 am. Monday ET - { cron: '0 23 * * Sun#last', duration: 3 * 60 }, + { cron: '0 23 * * Sun#last', duration_minutes: 3 * 60 }, ], 'TX' => [ # Saturday 9:00 pm. to Sunday 7:00 am. ET. From ad4b9457cbb392228a9c8912e13e6840c0e1d424 Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Tue, 14 Jan 2025 13:13:48 -0500 Subject: [PATCH 6/8] Revert "Merge pull request #11726 from 18F/stages/rc-2025-01-08" This reverts commit 6ece32f6fba11722a8550be7c3940dd5da311400, reversing changes made to a082cb33088e1ba529e7eeaa24411c659497e996. --- .gitlab-ci.yml | 2 +- Gemfile.lock | 2 +- .../idv/forgot_password_controller.rb | 2 +- app/jobs/resolution_proofing_job.rb | 2 +- app/models/user.rb | 2 +- app/services/attempts_api/attempt_event.rb | 86 +++++++++++++++++ app/services/attempts_api/redis_client.rb | 29 ++++++ app/views/users/webauthn_setup/new.html.erb | 2 +- config/application.yml.default | 3 + config/initializers/01_redis.rb | 5 + lib/identity_config.rb | 3 + .../idv/by_mail/enter_code_controller_spec.rb | 4 +- .../idv/enter_password_controller_spec.rb | 2 +- .../multiple_emails/email_management_spec.rb | 6 +- spec/features/sp_cost_tracking_spec.rb | 2 +- .../two_factor_authentication/sign_in_spec.rb | 2 +- spec/features/users/sign_in_spec.rb | 8 +- .../get_usps_proofing_results_job_spec.rb | 4 +- spec/rails_helper.rb | 1 + .../attempts_api/attempt_event_spec.rb | 70 ++++++++++++++ .../attempts_api/redis_client_spec.rb | 92 +++++++++++++++++++ .../alert_user_about_account_verified_spec.rb | 4 +- spec/support/features/session_helper.rb | 2 +- spec/support/idv_examples/sp_handoff.rb | 4 +- .../shared_examples/phone/rate_limiting.rb | 4 +- spec/support/shared_examples/sign_in.rb | 6 +- 26 files changed, 319 insertions(+), 30 deletions(-) create mode 100644 app/services/attempts_api/attempt_event.rb create mode 100644 app/services/attempts_api/redis_client.rb create mode 100644 spec/services/attempts_api/attempt_event_spec.rb create mode 100644 spec/services/attempts_api/redis_client_spec.rb diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4dcf6078669..fa9120acf94 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -293,7 +293,7 @@ specs: - cp -a keys.example keys - cp -a certs.example certs - cp pwned_passwords/pwned_passwords.txt.sample pwned_passwords/pwned_passwords.txt - - "echo -e \"test:\n redis_url: 'redis://db-redis:6379/0'\n redis_throttle_url: 'redis://db-redis:6379/1'\" > config/application.yml" + - "echo -e \"test:\n redis_url: 'redis://db-redis:6379/0'\n redis_throttle_url: 'redis://db-redis:6379/1'\n redis_attempts_api_url: 'redis://db-redis:6379/2'\" > config/application.yml" - bundle exec rake db:create db:migrate --trace - bundle exec rake db:seed - bundle exec rake knapsack:rspec["--format documentation --format RspecJunitFormatter --out rspec.xml --format json --out rspec_json/${CI_NODE_INDEX}.json"] diff --git a/Gemfile.lock b/Gemfile.lock index e152e8df347..fa23eee29b2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -458,7 +458,7 @@ GEM net-smtp (0.5.0) net-protocol net-ssh (6.1.0) - newrelic_rpm (9.7.0) + newrelic_rpm (9.16.1) nio4r (2.7.4) nokogiri (1.16.8) mini_portile2 (~> 2.8.2) diff --git a/app/controllers/idv/forgot_password_controller.rb b/app/controllers/idv/forgot_password_controller.rb index aa3041b13ff..af5fce773fa 100644 --- a/app/controllers/idv/forgot_password_controller.rb +++ b/app/controllers/idv/forgot_password_controller.rb @@ -15,7 +15,7 @@ def new def update analytics.idv_forgot_password_confirmed request_id = sp_session[:request_id] - email = current_user.confirmed_email_addresses.first.email + email = current_user.last_sign_in_email_address.email reset_password(email, request_id) end diff --git a/app/jobs/resolution_proofing_job.rb b/app/jobs/resolution_proofing_job.rb index 66c9d635feb..23e63b75e6e 100644 --- a/app/jobs/resolution_proofing_job.rb +++ b/app/jobs/resolution_proofing_job.rb @@ -131,7 +131,7 @@ def make_vendor_proofing_requests( end def user_email_for_proofing(user) - user.confirmed_email_addresses.first.email + user.last_sign_in_email_address.email end def log_threatmetrix_info(threatmetrix_result, user) diff --git a/app/models/user.rb b/app/models/user.rb index 44270a01e15..2eca557512a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -65,7 +65,7 @@ class User < ApplicationRecord attr_accessor :asserted_attributes, :email def confirmed_email_addresses - email_addresses.confirmed.order('last_sign_in_at DESC NULLS LAST') + email_addresses.confirmed end def fully_registered? diff --git a/app/services/attempts_api/attempt_event.rb b/app/services/attempts_api/attempt_event.rb new file mode 100644 index 00000000000..bd5821d5721 --- /dev/null +++ b/app/services/attempts_api/attempt_event.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module AttemptsApi + class AttemptEvent + attr_reader :jti, :iat, :event_type, :session_id, :occurred_at, :event_metadata + + def initialize( + event_type:, + session_id:, + occurred_at:, + event_metadata:, + jti: SecureRandom.uuid, + iat: Time.zone.now.to_i + ) + @jti = jti + @iat = iat + @event_type = event_type + @session_id = session_id + @occurred_at = occurred_at + @event_metadata = event_metadata + end + + def to_jwe(public_key:, issuer:) + jwk = JWT::JWK.new(public_key) + + JWE.encrypt( + payload_json(issuer: issuer), + public_key, + typ: 'secevent+jwe', + zip: 'DEF', + alg: 'RSA-OAEP', + enc: 'A256GCM', + kid: jwk.kid, + ) + end + + def self.from_jwe(jwe, private_key) + decrypted_event = JWE.decrypt(jwe, private_key) + parsed_event = JSON.parse(decrypted_event) + event_type = parsed_event['events'].keys.first.split('/').last + event_data = parsed_event['events'].values.first + jti = parsed_event['jti'].split(':').last + AttemptEvent.new( + jti: jti, + iat: parsed_event['iat'], + event_type: event_type, + session_id: event_data['subject']['session_id'], + occurred_at: Time.zone.at(event_data['occurred_at']), + event_metadata: event_data.symbolize_keys.except(:subject, :occurred_at), + ) + end + + def payload(issuer:) + { + jti: jti, + iat: iat, + iss: Rails.application.routes.url_helpers.root_url, + aud: issuer, + events: { + long_event_type => event_data, + }, + } + end + + def payload_json(issuer:) + @payload_json ||= payload(issuer:).to_json + end + + private + + def event_data + { + 'subject' => { + 'subject_type' => 'session', + 'session_id' => session_id, + }, + 'occurred_at' => occurred_at.to_f, + }.merge(event_metadata || {}) + end + + def long_event_type + dasherized_name = event_type.to_s.dasherize + "https://schemas.login.gov/secevent/attempts-api/event-type/#{dasherized_name}" + end + end +end diff --git a/app/services/attempts_api/redis_client.rb b/app/services/attempts_api/redis_client.rb new file mode 100644 index 00000000000..2f9fd809382 --- /dev/null +++ b/app/services/attempts_api/redis_client.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module AttemptsApi + class RedisClient + def write_event(event_key:, jwe:, timestamp:, issuer:) + key = key(timestamp, issuer) + REDIS_ATTEMPTS_API_POOL.with do |client| + client.hset(key, event_key, jwe) + client.expire(key, IdentityConfig.store.attempts_api_event_ttl_seconds) + end + end + + def read_events(timestamp:, issuer:, batch_size: 5000) + key = key(timestamp, issuer) + events = {} + REDIS_ATTEMPTS_API_POOL.with do |client| + client.hscan_each(key, count: batch_size) do |k, v| + events[k] = v + end + end + events + end + + def key(timestamp, issuer) + formatted_time = timestamp.in_time_zone('UTC').change(min: 0, sec: 0).iso8601 + "attempts-api-events:#{issuer}:#{formatted_time}" + end + end +end diff --git a/app/views/users/webauthn_setup/new.html.erb b/app/views/users/webauthn_setup/new.html.erb index 1323e2fe05d..a0ad8a34ce6 100644 --- a/app/views/users/webauthn_setup/new.html.erb +++ b/app/views/users/webauthn_setup/new.html.erb @@ -27,7 +27,7 @@ }, ) do |f| %> <%= hidden_field_tag :user_id, current_user.id, id: 'user_id' %> - <%= hidden_field_tag :user_email, current_user.confirmed_email_addresses.first.email, id: 'user_email' %> + <%= hidden_field_tag :user_email, current_user.last_sign_in_email_address.email, id: 'user_email' %> <%= hidden_field_tag :user_challenge, user_session[:webauthn_challenge].to_json, id: 'user_challenge' %> <%= hidden_field_tag :exclude_credentials, @exclude_credentials&.join(','), id: 'exclude_credentials' %> <%= hidden_field_tag :webauthn_id, '', id: 'webauthn_id' %> diff --git a/config/application.yml.default b/config/application.yml.default index 3aeaffe4dcc..0c72eaaba56 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -40,6 +40,7 @@ allowed_verified_within_providers: '[]' asset_host: '' async_stale_job_timeout_seconds: 300 async_wait_timeout_seconds: 60 +attempts_api_event_ttl_seconds: 3_600 attribute_encryption_key: attribute_encryption_key_queue: '[]' available_locales: 'en,es,fr,zh' @@ -322,6 +323,8 @@ recaptcha_site_key: '' recommend_webauthn_platform_for_sms_ab_test_account_creation_percent: 0 recommend_webauthn_platform_for_sms_ab_test_authentication_percent: 0 recovery_code_length: 4 +redis_attempts_api_pool_size: 1 +redis_attempts_api_url: redis://localhost:6379/2 redis_pool_size: 10 redis_throttle_pool_size: 5 redis_throttle_url: redis://localhost:6379/1 diff --git a/config/initializers/01_redis.rb b/config/initializers/01_redis.rb index 39851d84299..3a88b1c43f9 100644 --- a/config/initializers/01_redis.rb +++ b/config/initializers/01_redis.rb @@ -9,3 +9,8 @@ REDIS_THROTTLE_POOL = ConnectionPool.new(size: IdentityConfig.store.redis_throttle_pool_size) do Redis.new(url: IdentityConfig.store.redis_throttle_url) end.freeze + +REDIS_ATTEMPTS_API_POOL = + ConnectionPool.new(size: IdentityConfig.store.redis_attempts_api_pool_size) do + Redis.new(url: IdentityConfig.store.redis_attempts_api_url) + end.freeze diff --git a/lib/identity_config.rb b/lib/identity_config.rb index b2dc14f6aab..9ea782ad0d8 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -58,6 +58,7 @@ def self.store config.add(:asset_host, type: :string) config.add(:async_stale_job_timeout_seconds, type: :integer) config.add(:async_wait_timeout_seconds, type: :integer) + config.add(:attempts_api_event_ttl_seconds, type: :integer) config.add(:attribute_encryption_key, type: :string) config.add(:attribute_encryption_key_queue, type: :json) config.add(:available_locales, type: :comma_separated_string_list) @@ -356,6 +357,8 @@ def self.store config.add(:recaptcha_secret_key, type: :string) config.add(:recaptcha_site_key, type: :string) config.add(:recovery_code_length, type: :integer) + config.add(:redis_attempts_api_pool_size, type: :integer) + config.add(:redis_attempts_api_url, type: :string) config.add(:redis_pool_size, type: :integer) config.add(:redis_throttle_pool_size, type: :integer) config.add(:redis_throttle_url, type: :string) diff --git a/spec/controllers/idv/by_mail/enter_code_controller_spec.rb b/spec/controllers/idv/by_mail/enter_code_controller_spec.rb index e9f5ef0f835..277187e96cc 100644 --- a/spec/controllers/idv/by_mail/enter_code_controller_spec.rb +++ b/spec/controllers/idv/by_mail/enter_code_controller_spec.rb @@ -289,7 +289,7 @@ it 'does not send the "Please Call" email' do action expect_email_not_delivered( - to: user.confirmed_email_addresses.first.email, + to: user.last_sign_in_email_address.email, subject: t('user_mailer.idv_please_call.subject', app_name: APP_NAME), ) end @@ -335,7 +335,7 @@ it 'sends the "Please Call" email' do action expect_delivered_email( - to: user.confirmed_email_addresses.first.email, + to: user.last_sign_in_email_address.email, subject: t('user_mailer.idv_please_call.subject', app_name: APP_NAME), ) end diff --git a/spec/controllers/idv/enter_password_controller_spec.rb b/spec/controllers/idv/enter_password_controller_spec.rb index a96a19998d4..7f88fcc31d0 100644 --- a/spec/controllers/idv/enter_password_controller_spec.rb +++ b/spec/controllers/idv/enter_password_controller_spec.rb @@ -416,7 +416,7 @@ def show it 'sends the idv_please_call email' do put :create, params: { user: { password: ControllerHelper::VALID_PASSWORD } } expect_delivered_email( - to: user.confirmed_email_addresses.first.email, + to: user.last_sign_in_email_address.email, subject: t('user_mailer.idv_please_call.subject', app_name: APP_NAME), ) end diff --git a/spec/features/multiple_emails/email_management_spec.rb b/spec/features/multiple_emails/email_management_spec.rb index 4018460e401..78deed13679 100644 --- a/spec/features/multiple_emails/email_management_spec.rb +++ b/spec/features/multiple_emails/email_management_spec.rb @@ -40,7 +40,7 @@ context 'allows deletion of email address' do it 'does not allow last confirmed email to be deleted' do user = create(:user, :fully_registered, email: 'test@example.com ') - confirmed_email = user.confirmed_email_addresses.first + confirmed_email = user.last_sign_in_email_address unconfirmed_email = create(:email_address, user: user, confirmed_at: nil) user.email_addresses.reload @@ -56,7 +56,7 @@ it 'Allows delete when more than one confirmed email exists' do user = create(:user, :fully_registered, email: 'test@example.com ') - confirmed_email1 = user.confirmed_email_addresses.first + confirmed_email1 = user.last_sign_in_email_address confirmed_email2 = create( :email_address, user: user, confirmed_at: Time.zone.now @@ -74,7 +74,7 @@ it 'sends notification to all confirmed emails when email address is deleted' do user = create(:user, :fully_registered, email: 'test@example.com ') - confirmed_email1 = user.confirmed_email_addresses.first + confirmed_email1 = user.last_sign_in_email_address confirmed_email2 = create(:email_address, user: user, confirmed_at: Time.zone.now) sign_in_and_2fa_user(user) diff --git a/spec/features/sp_cost_tracking_spec.rb b/spec/features/sp_cost_tracking_spec.rb index feb8f84eac2..2bf4a64a083 100644 --- a/spec/features/sp_cost_tracking_spec.rb +++ b/spec/features/sp_cost_tracking_spec.rb @@ -45,7 +45,7 @@ user.active_profile.update!(verified_at: 60.days.ago) visit_idp_from_sp_with_ial2(:oidc, verified_within: '45d') - fill_in_credentials_and_submit(user.confirmed_email_addresses.first.email, password) + fill_in_credentials_and_submit(user.last_sign_in_email_address.email, password) fill_in_code_with_last_totp(user) click_submit_default complete_all_doc_auth_steps_before_password_step diff --git a/spec/features/two_factor_authentication/sign_in_spec.rb b/spec/features/two_factor_authentication/sign_in_spec.rb index 63ff2dc5a12..075dc608440 100644 --- a/spec/features/two_factor_authentication/sign_in_spec.rb +++ b/spec/features/two_factor_authentication/sign_in_spec.rb @@ -92,7 +92,7 @@ scenario 'allows a user to recreate their account after account reset' do sign_in_before_2fa(user) - email = user.confirmed_email_addresses.first.email + email = user.last_sign_in_email_address.email expect(page).to have_content(t('two_factor_authentication.opt_in.title')) diff --git a/spec/features/users/sign_in_spec.rb b/spec/features/users/sign_in_spec.rb index 982fbc6a6f8..15d4e3abe6d 100644 --- a/spec/features/users/sign_in_spec.rb +++ b/spec/features/users/sign_in_spec.rb @@ -406,7 +406,7 @@ create(:user, :fully_registered, email: email, password: password) user = User.find_with_email(email) - encrypted_email = user.confirmed_email_addresses.first.encrypted_email + encrypted_email = user.last_sign_in_email_address.encrypted_email rotate_attribute_encryption_key_with_invalid_queue @@ -414,7 +414,7 @@ .to raise_error Encryption::EncryptionError, 'unable to decrypt attribute with any key' user = user.reload - expect(user.confirmed_email_addresses.first.encrypted_email).to eq encrypted_email + expect(user.last_sign_in_email_address.encrypted_email).to eq encrypted_email end end @@ -426,14 +426,14 @@ create(:user, :fully_registered, email: email, password: password) user = User.find_with_email(email) - encrypted_email = user.confirmed_email_addresses.first.encrypted_email + encrypted_email = user.last_sign_in_email_address.encrypted_email rotate_attribute_encryption_key_with_invalid_queue sign_in_user_with_piv(user) user = user.reload - expect(user.confirmed_email_addresses.first.encrypted_email).to eq encrypted_email + expect(user.last_sign_in_email_address.encrypted_email).to eq encrypted_email end end end diff --git a/spec/jobs/get_usps_proofing_results_job_spec.rb b/spec/jobs/get_usps_proofing_results_job_spec.rb index 5ea9d8b9131..b22d3ee27e5 100644 --- a/spec/jobs/get_usps_proofing_results_job_spec.rb +++ b/spec/jobs/get_usps_proofing_results_job_spec.rb @@ -130,7 +130,7 @@ ) allow(UserMailer).to receive(:with).with( user: enrollment.user, - email_address: enrollment.user.confirmed_email_addresses.first, + email_address: enrollment.user.last_sign_in_email_address, ).and_return(user_mailer) allow(mail_deliverer).to receive(:deliver_later) allow(InPerson::SendProofingNotificationJob).to receive(:set).and_return( @@ -366,7 +366,7 @@ ) allow(UserMailer).to receive(:with).with( user: enrollment.user, - email_address: enrollment.user.confirmed_email_addresses.first, + email_address: enrollment.user.last_sign_in_email_address, ).and_raise(StandardError) subject.perform(current_time) end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 0beffa39153..6c27735fd6d 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -112,6 +112,7 @@ class Analytics Telephony::Test::Call.clear_calls PushNotification::LocalEventQueue.clear! REDIS_THROTTLE_POOL.with { |client| client.flushdb } if Identity::Hostdata.config + REDIS_ATTEMPTS_API_POOL.with { |client| client.flushdb } if Identity::Hostdata.config end config.before(:each) do diff --git a/spec/services/attempts_api/attempt_event_spec.rb b/spec/services/attempts_api/attempt_event_spec.rb new file mode 100644 index 00000000000..3ed0446151e --- /dev/null +++ b/spec/services/attempts_api/attempt_event_spec.rb @@ -0,0 +1,70 @@ +require 'rails_helper' + +RSpec.describe AttemptsApi::AttemptEvent do + let(:attempts_api_private_key) { OpenSSL::PKey::RSA.new(2048) } + let(:attempts_api_public_key) { attempts_api_private_key.public_key } + + let(:jti) { 'test-unique-id' } + let(:iat) { Time.zone.now.to_i } + let(:event_type) { 'test-event' } + let(:session_id) { 'test-session-id' } + let(:occurred_at) { Time.zone.now.round } + let(:event_metadata) { { 'foo' => 'bar' } } + let(:service_provider) { build(:service_provider) } + + subject do + described_class.new( + jti: jti, + iat: iat, + event_type: event_type, + session_id: session_id, + occurred_at: occurred_at, + event_metadata: event_metadata, + ) + end + + describe '#to_jwe' do + it 'returns a JWE for the event' do + jwe = subject.to_jwe(issuer: service_provider.issuer, public_key: attempts_api_public_key) + + header_str, *_rest = JWE::Serialization::Compact.decode(jwe) + headers = JSON.parse(header_str) + + expect(headers['alg']).to eq('RSA-OAEP') + expect(headers['kid']).to eq(JWT::JWK.new(attempts_api_public_key).kid) + + decrypted_jwe_payload = JWE.decrypt(jwe, attempts_api_private_key) + + token = JSON.parse(decrypted_jwe_payload) + + expect(token['iss']).to eq(Rails.application.routes.url_helpers.root_url) + expect(token['jti']).to eq(jti) + expect(token['iat']).to eq(iat) + expect(token['aud']).to eq(service_provider.issuer) + + event_key = 'https://schemas.login.gov/secevent/attempts-api/event-type/test-event' + event_data = token['events'][event_key] + + expect(event_data['subject']).to eq( + 'subject_type' => 'session', 'session_id' => 'test-session-id', + ) + expect(event_data['foo']).to eq('bar') + expect(event_data['occurred_at']).to eq(occurred_at.to_f) + end + end + + describe '.from_jwe' do + it 'returns an event decrypted from the JWE' do + jwe = subject.to_jwe(issuer: service_provider.issuer, public_key: attempts_api_public_key) + + decrypted_event = described_class.from_jwe(jwe, attempts_api_private_key) + + expect(decrypted_event.jti).to eq(subject.jti) + expect(decrypted_event.iat).to eq(subject.iat) + expect(decrypted_event.event_type).to eq(subject.event_type) + expect(decrypted_event.session_id).to eq(subject.session_id) + expect(decrypted_event.occurred_at).to eq(subject.occurred_at) + expect(decrypted_event.event_metadata).to eq(subject.event_metadata.symbolize_keys) + end + end +end diff --git a/spec/services/attempts_api/redis_client_spec.rb b/spec/services/attempts_api/redis_client_spec.rb new file mode 100644 index 00000000000..d768f01eeca --- /dev/null +++ b/spec/services/attempts_api/redis_client_spec.rb @@ -0,0 +1,92 @@ +require 'rails_helper' + +RSpec.describe AttemptsApi::RedisClient do + let(:attempts_api_private_key) { OpenSSL::PKey::RSA.new(2048) } + let(:attempts_api_public_key) { attempts_api_private_key.public_key } + let(:issuer) { 'test' } + + describe '#write_event' do + it 'writes the attempt data to redis with the event key as the key' do + freeze_time do + now = Time.zone.now + event = AttemptsApi::AttemptEvent.new( + event_type: 'test_event', + session_id: 'test-session-id', + occurred_at: Time.zone.now, + event_metadata: { + first_name: Idp::Constants::MOCK_IDV_APPLICANT[:first_name], + }, + ) + event_key = event.jti + jwe = event.to_jwe(issuer: issuer, public_key: attempts_api_public_key) + + subject.write_event(event_key: event_key, jwe: jwe, timestamp: now, issuer: issuer) + + result = subject.read_events(timestamp: now, issuer: issuer) + expect(result[event_key]).to eq(jwe) + end + end + end + + describe '#read_events' do + it 'reads the event events from redis' do + freeze_time do + now = Time.zone.now + events = {} + 3.times do + event = AttemptsApi::AttemptEvent.new( + event_type: 'test_event', + session_id: 'test-session-id', + occurred_at: now, + event_metadata: { + first_name: Idp::Constants::MOCK_IDV_APPLICANT[:first_name], + }, + ) + event_key = event.jti + jwe = event.to_jwe(issuer: issuer, public_key: attempts_api_public_key) + events[event_key] = jwe + end + events.each do |event_key, jwe| + subject.write_event(event_key: event_key, jwe: jwe, timestamp: now, issuer: issuer) + end + + result = subject.read_events(timestamp: now, issuer: issuer) + + expect(result).to eq(events) + end + end + + it 'stores events in hourly buckets' do + time1 = Time.new(2022, 1, 1, 1, 0, 0, 'Z') + time2 = Time.new(2022, 1, 1, 2, 0, 0, 'Z') + event1 = AttemptsApi::AttemptEvent.new( + event_type: 'test_event', + session_id: 'test-session-id', + occurred_at: time1, + event_metadata: { + first_name: Idp::Constants::MOCK_IDV_APPLICANT[:first_name], + }, + ) + event2 = AttemptsApi::AttemptEvent.new( + event_type: 'test_event', + session_id: 'test-session-id', + occurred_at: time2, + event_metadata: { + first_name: Idp::Constants::MOCK_IDV_APPLICANT[:first_name], + }, + ) + jwe1 = event1.to_jwe(issuer: issuer, public_key: attempts_api_public_key) + jwe2 = event2.to_jwe(issuer: issuer, public_key: attempts_api_public_key) + + subject.write_event( + event_key: event1.jti, jwe: jwe1, timestamp: event1.occurred_at, issuer: issuer, + ) + subject.write_event( + event_key: event2.jti, jwe: jwe2, timestamp: event2.occurred_at, issuer: issuer, + ) + + expect(subject.read_events(timestamp: time1, issuer: issuer)).to eq({ event1.jti => jwe1 }) + expect(subject.read_events(timestamp: time2, issuer: issuer)).to eq({ event2.jti => jwe2 }) + end + end +end diff --git a/spec/services/user_alerts/alert_user_about_account_verified_spec.rb b/spec/services/user_alerts/alert_user_about_account_verified_spec.rb index c956abd7ba1..be082872867 100644 --- a/spec/services/user_alerts/alert_user_about_account_verified_spec.rb +++ b/spec/services/user_alerts/alert_user_about_account_verified_spec.rb @@ -36,7 +36,7 @@ described_class.call(profile: profile) expect_delivered_email( - to: [user.confirmed_email_addresses.first.email], + to: [user.last_sign_in_email_address.email], subject: t('user_mailer.account_verified.subject', app_name: APP_NAME), body: [ 'http://www.example.com/redirect/return_to_sp/account_verified_cta', @@ -71,7 +71,7 @@ described_class.call(profile: profile) expect_delivered_email( - to: [user.confirmed_email_addresses.first.email], + to: [user.last_sign_in_email_address.email], subject: t('user_mailer.account_verified.subject', app_name: APP_NAME), body: ['http://example.com'], ) diff --git a/spec/support/features/session_helper.rb b/spec/support/features/session_helper.rb index 123748bd7b9..60b5ecdacfe 100644 --- a/spec/support/features/session_helper.rb +++ b/spec/support/features/session_helper.rb @@ -537,7 +537,7 @@ def skip_second_mfa_prompt end def sign_in_via_branded_page(user) - fill_in_credentials_and_submit(user.confirmed_email_addresses.first.email, user.password) + fill_in_credentials_and_submit(user.last_sign_in_email_address.email, user.password) fill_in_code_with_last_phone_otp click_submit_default end diff --git a/spec/support/idv_examples/sp_handoff.rb b/spec/support/idv_examples/sp_handoff.rb index 71786093558..ee43a76e175 100644 --- a/spec/support/idv_examples/sp_handoff.rb +++ b/spec/support/idv_examples/sp_handoff.rb @@ -145,7 +145,7 @@ def expect_successful_oidc_handoff expect(decoded_id_token[:aud]).to eq(@client_id) expect(decoded_id_token[:acr]).to eq(Saml::Idp::Constants::IAL_VERIFIED_ACR) expect(decoded_id_token[:iss]).to eq(root_url) - expect(decoded_id_token[:email]).to eq(user.confirmed_email_addresses.first.email) + expect(decoded_id_token[:email]).to eq(user.last_sign_in_email_address.email) expect(decoded_id_token[:given_name]).to eq('FAKEY') expect(decoded_id_token[:social_security_number]).to eq(DocAuthHelper::GOOD_SSN) @@ -159,7 +159,7 @@ def expect_successful_oidc_handoff userinfo_response = JSON.parse(page.body).with_indifferent_access expect(userinfo_response[:sub]).to eq(sub) expect(AgencyIdentity.where(user_id: user.id, agency_id: 2).first.uuid).to eq(sub) - expect(userinfo_response[:email]).to eq(user.confirmed_email_addresses.first.email) + expect(userinfo_response[:email]).to eq(user.last_sign_in_email_address.email) expect(userinfo_response[:given_name]).to eq('FAKEY') expect(userinfo_response[:social_security_number]).to eq(DocAuthHelper::GOOD_SSN) end diff --git a/spec/support/shared_examples/phone/rate_limiting.rb b/spec/support/shared_examples/phone/rate_limiting.rb index 0d6b2d0c2a4..f0154c9f94b 100644 --- a/spec/support/shared_examples/phone/rate_limiting.rb +++ b/spec/support/shared_examples/phone/rate_limiting.rb @@ -89,7 +89,7 @@ def expect_user_to_be_rate_limitted visit root_path signin( - user.confirmed_email_addresses.first.email, + user.last_sign_in_email_address.email, user.password || Features::SessionHelper::VALID_PASSWORD, ) @@ -101,7 +101,7 @@ def expect_rate_limiting_to_expire visit root_path signin( - user.confirmed_email_addresses.first.email, + user.last_sign_in_email_address.email, user.password || Features::SessionHelper::VALID_PASSWORD, ) diff --git a/spec/support/shared_examples/sign_in.rb b/spec/support/shared_examples/sign_in.rb index 013e9ad5172..07c1c258f91 100644 --- a/spec/support/shared_examples/sign_in.rb +++ b/spec/support/shared_examples/sign_in.rb @@ -130,12 +130,12 @@ old_personal_key = PersonalKeyGenerator.new(user).generate! visit_idp_from_sp_with_ial1(sp) - trigger_reset_password_and_click_email_link(user.confirmed_email_addresses.first.email) + trigger_reset_password_and_click_email_link(user.last_sign_in_email_address.email) fill_in t('forms.passwords.edit.labels.password'), with: new_password fill_in t('components.password_confirmation.confirm_label'), with: new_password click_button t('forms.passwords.edit.buttons.submit') - fill_in_credentials_and_submit(user.confirmed_email_addresses.first.email, new_password) + fill_in_credentials_and_submit(user.last_sign_in_email_address.email, new_password) choose_another_security_option('personal_key') enter_personal_key(personal_key: old_personal_key) click_submit_default @@ -392,7 +392,7 @@ def ial1_sign_in_with_personal_key_goes_to_sp(sp) Capybara.reset_sessions! visit_idp_from_sp_with_ial1(sp) - fill_in_credentials_and_submit(user.confirmed_email_addresses.first.email, 'Val!d Pass w0rd') + fill_in_credentials_and_submit(user.last_sign_in_email_address.email, 'Val!d Pass w0rd') choose_another_security_option('personal_key') enter_personal_key(personal_key: old_personal_key) click_submit_default From 37e9baab1bf45f2053c096297a3fef41b0476377 Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Tue, 14 Jan 2025 13:15:04 -0500 Subject: [PATCH 7/8] Revert "Revert "Merge pull request #11748 from 18F/stages/rc-2025-01-14"" This reverts commit 7ea655cd6f60200b8b37f95cb807b5aa522e4fae. --- Brewfile | 1 - Gemfile | 2 +- Gemfile.lock | 8 +- Makefile | 1 + .../concerns/idv/verify_info_concern.rb | 7 + app/controllers/saml_idp_controller.rb | 22 - app/controllers/users/sessions_controller.rb | 40 +- app/forms/gpo_verify_form.rb | 1 + .../components/acuant-capture.tsx | 17 +- .../packages/phone-input/package.json | 2 +- app/jobs/resolution_proofing_job.rb | 39 +- app/models/document_capture_session.rb | 1 + app/services/analytics_events.rb | 47 +- .../doc_auth/lexis_nexis/doc_pii_reader.rb | 2 +- .../encrypted_doc_storage/doc_writer.rb | 35 ++ .../encrypted_doc_storage/local_storage.rb | 19 + .../encrypted_doc_storage/s3_storage.rb | 27 + .../idv/aamva_state_maintenance_window.rb | 138 +++-- app/services/idv/agent.rb | 6 +- app/services/idv/proofing_components.rb | 5 +- app/services/proofing/aamva/applicant.rb | 9 +- .../in_person/ready_to_verify/show.html.erb | 2 +- .../_in_person_ready_to_verify.html.erb | 2 +- bin/summarize-user-events | 286 ++++++++++ config/application.yml.default | 7 +- config/initializers/ab_tests.rb | 4 +- config/locales/en.yml | 12 +- config/locales/es.yml | 14 +- config/locales/fr.yml | 16 +- config/locales/zh.yml | 16 +- ...06232958_drop_proofing_components_table.rb | 23 + db/schema.rb | 22 +- dockerfiles/nginx.Dockerfile | 2 +- lib/analytics_events_documenter.rb | 18 +- .../account_deletion_matcher.rb | 74 +++ lib/event_summarizer/example_matcher.rb | 28 + lib/event_summarizer/idv_matcher.rb | 538 ++++++++++++++++++ .../vendor_result_evaluators/aamva.rb | 129 +++++ .../instant_verify.rb | 79 +++ .../vendor_result_evaluators/true_id.rb | 29 + lib/identity_config.rb | 6 +- package.json | 4 - spec/bin/summarize-user-events_spec.rb | 187 ++++++ spec/config/initializers/ab_tests_spec.rb | 13 +- .../idv/by_mail/enter_code_controller_spec.rb | 8 + .../in_person/verify_info_controller_spec.rb | 3 + spec/controllers/saml_idp_controller_spec.rb | 86 +-- .../users/sessions_controller_spec.rb | 69 ++- spec/features/visitors/bad_password_spec.rb | 12 +- .../components/acuant-capture-spec.jsx | 40 ++ spec/jobs/resolution_proofing_job_spec.rb | 188 ++++-- spec/lib/analytics_events_documenter_spec.rb | 43 +- spec/lib/event_summarizer/idv_matcher_spec.rb | 56 ++ .../instant_verify_spec.rb | 91 +++ .../responses/true_id_response_spec.rb | 19 +- .../encrypted_doc_storage/doc_writer_spec.rb | 52 ++ .../local_storage_spec.rb | 30 + .../encrypted_doc_storage/s3_storage_spec.rb | 31 + spec/services/idv/agent_spec.rb | 32 ++ spec/services/idv/proofing_components_spec.rb | 21 +- .../services/proofing/aamva/applicant_spec.rb | 21 +- spec/support/shared_examples/sign_in.rb | 4 +- yarn.lock | 42 +- 63 files changed, 2392 insertions(+), 396 deletions(-) create mode 100644 app/services/encrypted_doc_storage/doc_writer.rb create mode 100644 app/services/encrypted_doc_storage/local_storage.rb create mode 100644 app/services/encrypted_doc_storage/s3_storage.rb create mode 100755 bin/summarize-user-events create mode 100644 db/primary_migrate/20250106232958_drop_proofing_components_table.rb create mode 100644 lib/event_summarizer/account_deletion_matcher.rb create mode 100644 lib/event_summarizer/example_matcher.rb create mode 100644 lib/event_summarizer/idv_matcher.rb create mode 100644 lib/event_summarizer/vendor_result_evaluators/aamva.rb create mode 100644 lib/event_summarizer/vendor_result_evaluators/instant_verify.rb create mode 100644 lib/event_summarizer/vendor_result_evaluators/true_id.rb create mode 100644 spec/bin/summarize-user-events_spec.rb create mode 100644 spec/lib/event_summarizer/idv_matcher_spec.rb create mode 100644 spec/lib/event_summarizer/vendor_result_evaluators/instant_verify_spec.rb create mode 100644 spec/services/encrypted_doc_storage/doc_writer_spec.rb create mode 100644 spec/services/encrypted_doc_storage/local_storage_spec.rb create mode 100644 spec/services/encrypted_doc_storage/s3_storage_spec.rb diff --git a/Brewfile b/Brewfile index f087878fea3..e62947e5bfc 100644 --- a/Brewfile +++ b/Brewfile @@ -2,6 +2,5 @@ brew 'postgresql@14' brew 'redis' brew 'node@22' brew 'yarn' -brew 'openssl@1.1' brew 'jq' cask 'chromedriver' diff --git a/Gemfile b/Gemfile index 04e1f823c9d..bea078bd2b9 100644 --- a/Gemfile +++ b/Gemfile @@ -74,7 +74,7 @@ gem 'rqrcode' gem 'ruby-progressbar' gem 'ruby-saml' gem 'safe_target_blank', '>= 1.0.2' -gem 'saml_idp', github: '18F/saml_idp', tag: '0.23.4-18f' +gem 'saml_idp', github: '18F/saml_idp', tag: '0.23.5-18f' gem 'scrypt' gem 'simple_form', '>= 5.0.2' gem 'stringex', require: false diff --git a/Gemfile.lock b/Gemfile.lock index fa23eee29b2..04544525ea2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -36,10 +36,10 @@ GIT GIT remote: https://github.com/18F/saml_idp.git - revision: e5d876cf10ce9b39bba0cc523d06c4dda1af5124 - tag: 0.23.4-18f + revision: bdf8e1f93707e413ecbd0f48d803e18812e19f90 + tag: 0.23.5-18f specs: - saml_idp (0.23.4.pre.18f) + saml_idp (0.23.5.pre.18f) activesupport builder faraday @@ -477,7 +477,7 @@ GEM pg (1.5.9) pg_query (5.1.0) google-protobuf (>= 3.22.3) - phonelib (0.9.1) + phonelib (0.10.3) pkcs11 (0.3.4) premailer (1.27.0) addressable diff --git a/Makefile b/Makefile index a2aba78f2db..4ee599d6467 100644 --- a/Makefile +++ b/Makefile @@ -309,6 +309,7 @@ public/api/_analytics-events.json: .yardoc .yardoc/objects/root.dat .yardoc .yardoc/objects/root.dat: app/services/analytics_events.rb bundle exec yard doc \ + --no-progress \ --fail-on-warning \ --type-tag identity.idp.previous_event_name:"Previous Event Name" \ --no-output \ diff --git a/app/controllers/concerns/idv/verify_info_concern.rb b/app/controllers/concerns/idv/verify_info_concern.rb index cfc9b4f37b7..be02dac4b43 100644 --- a/app/controllers/concerns/idv/verify_info_concern.rb +++ b/app/controllers/concerns/idv/verify_info_concern.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true module Idv + # @attr idv_session [Idv::Session] module VerifyInfoConcern extend ActiveSupport::Concern @@ -39,6 +40,12 @@ def shared_update threatmetrix_session_id: idv_session.threatmetrix_session_id, request_ip: request.remote_ip, ipp_enrollment_in_progress: ipp_enrollment_in_progress?, + proofing_components: ProofingComponents.new( + user: current_user, + idv_session:, + session:, + user_session:, + ), ) return true diff --git a/app/controllers/saml_idp_controller.rb b/app/controllers/saml_idp_controller.rb index 5c7ab57cbe5..bf3e3dbf9ca 100644 --- a/app/controllers/saml_idp_controller.rb +++ b/app/controllers/saml_idp_controller.rb @@ -150,13 +150,6 @@ def capture_analytics if result.success? && saml_request.signed? analytics_payload[:cert_error_details] = saml_request.cert_errors - - # analytics to determine if turning on SHA256 validation will break - # existing partners - if certs_different? - analytics_payload[:certs_different] = true - analytics_payload[:sha256_matching_cert] = sha256_alg_matching_cert_serial - end end analytics.saml_auth(**analytics_payload) @@ -168,21 +161,6 @@ def matching_cert_serial nil end - def sha256_alg_matching_cert - # if sha256_alg_matching_cert is nil, fallback to the "first" cert - saml_request.sha256_validation_matching_cert || - saml_request_service_provider&.ssl_certs&.first - rescue SamlIdp::XMLSecurity::SignedDocument::ValidationError - end - - def sha256_alg_matching_cert_serial - sha256_alg_matching_cert&.serial&.to_s - end - - def certs_different? - encryption_cert != sha256_alg_matching_cert - end - def log_external_saml_auth_request return unless external_saml_request? diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index 2b7570b4501..8b6a271fe7f 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -18,7 +18,7 @@ class SessionsController < Devise::SessionsController before_action :store_sp_metadata_in_session, only: [:new] before_action :check_user_needs_redirect, only: [:new] before_action :apply_secure_headers_override, only: [:new, :create] - before_action :clear_session_bad_password_count_if_window_expired, only: [:create] + before_action :clear_session_sign_in_failure_count_if_window_expired, only: [:create] before_action :set_analytics_user_from_params, only: :create before_action :allow_csp_recaptcha_src, if: :recaptcha_enabled? @@ -37,12 +37,14 @@ def new def create session[:sign_in_flow] = :sign_in - return process_rate_limited if session_bad_password_count_max_exceeded? + return process_rate_limited if session_sign_in_failure_count_max_exceeded? return process_locked_out_user if current_user && user_locked_out?(current_user) return process_rate_limited if rate_limited? - return process_failed_captcha unless recaptcha_response.success? || log_captcha_failures_only? rate_limit_password_failure = true + + return process_failed_captcha unless recaptcha_response.success? || log_captcha_failures_only? + self.resource = warden.authenticate!(auth_options) handle_valid_authentication ensure @@ -65,21 +67,21 @@ def analytics_user private - def clear_session_bad_password_count_if_window_expired - locked_at = session[:max_bad_passwords_at] - window = IdentityConfig.store.max_bad_passwords_window_in_seconds + def clear_session_sign_in_failure_count_if_window_expired + locked_at = session[:max_sign_in_failures_at] + window = IdentityConfig.store.max_sign_in_failures_window_in_seconds return if locked_at.nil? || (locked_at + window) > Time.zone.now.to_i - [:max_bad_passwords_at, :bad_password_count].each { |x| session.delete(x) } + [:max_sign_in_failures_at, :sign_in_failure_count].each { |x| session.delete(x) } end - def session_bad_password_count_max_exceeded? - session[:bad_password_count].to_i >= IdentityConfig.store.max_bad_passwords + def session_sign_in_failure_count_max_exceeded? + session[:sign_in_failure_count].to_i >= IdentityConfig.store.max_sign_in_failures end - def increment_session_bad_password_count - session[:bad_password_count] = session[:bad_password_count].to_i + 1 - return unless session_bad_password_count_max_exceeded? - session[:max_bad_passwords_at] ||= Time.zone.now.to_i + def increment_session_sign_in_failure_count + session[:sign_in_failure_count] = session[:sign_in_failure_count].to_i + 1 + return unless session_sign_in_failure_count_max_exceeded? + session[:max_sign_in_failures_at] ||= Time.zone.now.to_i end def process_rate_limited @@ -87,16 +89,16 @@ def process_rate_limited warden.lock! flash[:error] = t( - 'errors.sign_in.bad_password_limit', + 'errors.sign_in.sign_in_failure_limit', time_left: locked_out_time_remaining, ) redirect_to root_url end def locked_out_time_remaining - if session[:max_bad_passwords_at] - locked_at = session[:max_bad_passwords_at] - window = IdentityConfig.store.max_bad_passwords_window_in_seconds.seconds + if session[:max_sign_in_failures_at] + locked_at = session[:max_sign_in_failures_at] + window = IdentityConfig.store.max_sign_in_failures_window_in_seconds.seconds time_lockout_expires = Time.zone.at(locked_at) + window else time_lockout_expires = rate_limiter&.expires_at || Time.zone.now @@ -188,7 +190,7 @@ def process_locked_out_user def handle_invalid_authentication rate_limiter&.increment! - increment_session_bad_password_count + increment_session_sign_in_failure_count end def handle_valid_authentication @@ -223,7 +225,7 @@ def track_authentication_attempt rate_limited: rate_limited?, captcha_validation_performed: captcha_validation_performed?, valid_captcha_result: recaptcha_response.success?, - bad_password_count: session[:bad_password_count].to_i, + sign_in_failure_count: session[:sign_in_failure_count].to_i, sp_request_url_present: sp_session[:request_url].present?, remember_device: remember_device_cookie.present?, new_device: success ? new_device? : nil, diff --git a/app/forms/gpo_verify_form.rb b/app/forms/gpo_verify_form.rb index 150ae095719..6dbbf09b6e8 100644 --- a/app/forms/gpo_verify_form.rb +++ b/app/forms/gpo_verify_form.rb @@ -49,6 +49,7 @@ def submit(is_enhanced_ipp) pii_like_keypaths: [[:errors, :otp], [:error_details, :otp]], pending_in_person_enrollment: !!pending_profile&.in_person_enrollment&.pending?, fraud_check_failed: fraud_check_failed, + initiating_service_provider: pending_profile&.initiating_service_provider_issuer, }, ) end diff --git a/app/javascript/packages/document-capture/components/acuant-capture.tsx b/app/javascript/packages/document-capture/components/acuant-capture.tsx index e40bee7fb58..9f4cc97d979 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.tsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.tsx @@ -140,11 +140,6 @@ interface AcuantCaptureProps { */ const NBSP_UNICODE = '\u00A0'; -/** - * A noop function. - */ -const noop = () => {}; - /** * Returns true if the given Acuant capture failure was caused by the user declining access to the * camera, or false otherwise. @@ -491,6 +486,16 @@ function AcuantCapture( } } + /** + * Responds to a drag and drop file upload by either preventing the default action + * or allowing the file to be uploaded + */ + function startDragDropUpload(event) { + if (!allowUpload) { + event.preventDefault(); + } + } + /** * Responds to a click by starting capture if supported in the environment, or triggering the * default file picker prompt. The click event may originate from the file input itself, or @@ -783,7 +788,7 @@ function AcuantCapture( errorMessage={ownErrorMessage ?? errorMessage} isValuePending={hasStartedCropping} onClick={withLoggedClick('placeholder')(startCaptureOrTriggerUpload)} - onDrop={withLoggedClick('placeholder', { isDrop: true })(noop)} + onDrop={withLoggedClick('placeholder', { isDrop: true })(startDragDropUpload)} onChange={onUpload} onError={() => setOwnErrorMessage(null)} /> diff --git a/app/javascript/packages/phone-input/package.json b/app/javascript/packages/phone-input/package.json index 76fccbd037b..49a02dcfdbc 100644 --- a/app/javascript/packages/phone-input/package.json +++ b/app/javascript/packages/phone-input/package.json @@ -4,7 +4,7 @@ "version": "1.0.0", "dependencies": { "intl-tel-input": "^24.5.0", - "libphonenumber-js": "^1.11.4" + "libphonenumber-js": "^1.11.17" }, "sideEffects": [ "./index.ts" diff --git a/app/jobs/resolution_proofing_job.rb b/app/jobs/resolution_proofing_job.rb index 23e63b75e6e..378958ecc30 100644 --- a/app/jobs/resolution_proofing_job.rb +++ b/app/jobs/resolution_proofing_job.rb @@ -25,7 +25,7 @@ def perform( service_provider_issuer: nil, threatmetrix_session_id: nil, request_ip: nil, - proofing_components: nil, # rubocop:disable Lint/UnusedMethodArgument + proofing_components: nil, # DEPRECATED ARGUMENTS should_proof_state_id: false # rubocop:disable Lint/UnusedMethodArgument ) @@ -75,7 +75,7 @@ def perform( timing: timer.results, ) - if use_shadow_mode?(user:) + if use_shadow_mode?(user:, proofing_components:) SocureShadowModeProofingJob.perform_later( document_capture_session_result_id: document_capture_session&.result_id, encrypted_arguments:, @@ -86,15 +86,22 @@ def perform( end end - def use_shadow_mode?(user:) - IdentityConfig.store.idv_socure_shadow_mode_enabled && - AbTests::SOCURE_IDV_SHADOW_MODE.bucket( - request: nil, - service_provider: nil, - session: nil, - user:, - user_session: nil, - ) == :shadow_mode_enabled + # @param user [User] + # @param proofing_components [Hash,nil] + def use_shadow_mode?(user:, proofing_components:) + # Let idv_socure_shadow_mode_enabled setting control shadow mode globally + disabled_globally = !IdentityConfig.store.idv_socure_shadow_mode_enabled + return false if disabled_globally + + # If the user went through Socure docv, they are already a Socure user and + # are thus eligible for shadow mode. + enabled_for_docv_users = + IdentityConfig.store.idv_socure_shadow_mode_enabled_for_docv_users + is_docv_user = proofing_components&.dig(:document_check) == Idp::Constants::Vendors::SOCURE + return true if enabled_for_docv_users && is_docv_user + + # Otherwise fall back to A/B test + shadow_mode_ab_test_bucket(user:) == :socure_shadow_mode_for_non_docv_users end private @@ -150,4 +157,14 @@ def logger_info_hash(hash) def progressive_proofer @progressive_proofer ||= Proofing::Resolution::ProgressiveProofer.new end + + def shadow_mode_ab_test_bucket(user:) + AbTests::SOCURE_IDV_SHADOW_MODE_FOR_NON_DOCV_USERS.bucket( + request: nil, + service_provider: nil, + session: nil, + user:, + user_session: nil, + ) + end end diff --git a/app/models/document_capture_session.rb b/app/models/document_capture_session.rb index 7caa58febf5..47b618a6bb6 100644 --- a/app/models/document_capture_session.rb +++ b/app/models/document_capture_session.rb @@ -11,6 +11,7 @@ def load_result EncryptedRedisStructStorage.load(result_id, type: DocumentCaptureSessionResult) end + # @param doc_auth_response [DocAuth::Response] def store_result_from_response(doc_auth_response) session_result = load_result || DocumentCaptureSessionResult.new( id: generate_result_id, diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index 17ee037292a..1ae73fc8b8e 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -125,7 +125,7 @@ def account_reset_cancel_token_validation( # @param [String] user_id # @param [Integer, nil] account_age_in_days number of days since the account was confirmed # @param [Time] account_confirmed_at date that account creation was confirmed - # (rounded) or nil if the account was not confirmed + # (rounded) or nil if the account was not confirmed # @param [Hash] mfa_method_counts Hash of MFA method with the number of that method on the account # @param [Boolean] identity_verified if the deletion occurs on a verified account # @param [Hash] errors Errors resulting from form validation @@ -244,7 +244,7 @@ def account_visit # @param [Hash] error_details Details for errors that occurred in unsuccessful submission # @param [String] user_id User the email is linked to # @param [Boolean] from_select_email_flow Whether email was added as part of partner email - # selection. + # selection. # A user has clicked the confirmation link in an email def add_email_confirmation( user_id:, @@ -270,7 +270,7 @@ def add_email_confirmation( # @param [Hash] error_details Details for errors that occurred in unsuccessful submission # @param [String] domain_name Domain name of email address submitted # @param [Boolean] in_select_email_flow Whether email is being added as part of partner email - # selection. + # selection. # Tracks request for adding new emails to an account def add_email_request( success:, @@ -509,11 +509,11 @@ def edit_password_visit(required_password_change: false, **extra) # @param [Boolean] rate_limited Whether the user has exceeded user IP rate limiting # @param [Boolean] valid_captcha_result Whether user passed the reCAPTCHA check or was exempt # @param [Boolean] captcha_validation_performed Whether a reCAPTCHA check was performed - # @param [String] bad_password_count represents number of prior login failures + # @param [String] sign_in_failure_count represents number of prior login failures # @param [Boolean] sp_request_url_present if was an SP request URL in the session # @param [Boolean] remember_device if the remember device cookie was present # @param [Boolean, nil] new_device Whether the user is authenticating from a new device. Nil if - # the attempt was unsuccessful, since it cannot be known whether it's a new device. + # the attempt was unsuccessful, since it cannot be known whether it's a new device. # Tracks authentication attempts at the email/password screen def email_and_password_auth( success:, @@ -521,7 +521,7 @@ def email_and_password_auth( rate_limited:, valid_captcha_result:, captcha_validation_performed:, - bad_password_count:, + sign_in_failure_count:, sp_request_url_present:, remember_device:, new_device:, @@ -536,7 +536,7 @@ def email_and_password_auth( rate_limited:, valid_captcha_result:, captcha_validation_performed:, - bad_password_count:, + sign_in_failure_count:, sp_request_url_present:, remember_device:, new_device:, @@ -1086,7 +1086,7 @@ def idv_camera_info_error(error:, **_extra) # @param ["hybrid","standard"] flow_path Document capture user flow # @param [Array] camera_info Information on the users cameras max resolution - # as captured by the browser + # as captured by the browser def idv_camera_info_logged(flow_path:, camera_info:, **_extra) track_event( :idv_camera_info_logged, flow_path: flow_path, camera_info: camera_info @@ -1454,7 +1454,7 @@ def idv_doc_auth_exception_visited(step_name:, remaining_submit_attempts:, **ext # @param [String] side the side of the image submission # @param [Integer] submit_attempts Times that user has tried submitting (previously called - # "attempts") + # "attempts") # @param [Integer] remaining_submit_attempts (previously called "remaining_attempts") # @param ["hybrid","standard"] flow_path Document capture user flow # @param [String] liveness_checking_required Whether or not the selfie is required @@ -2984,7 +2984,7 @@ def idv_in_person_prepare_visited(flow_path:, opted_in_to_in_person_proofing:, * # @param [String] analytics_id # @param [Boolean] skip_hybrid_handoff Whether skipped hybrid handoff A/B test is active # @param [Boolean] opted_in_to_in_person_proofing User opted into in person proofing - # address page visited + # address page visited def idv_in_person_proofing_address_visited( flow_path:, step:, @@ -5253,6 +5253,7 @@ def idv_usps_auth_token_refresh_job_started(**extra) # @param [Integer] which_letter Sorted by enqueue time, which letter had this code # @param [Integer] letter_count How many letters did the user enqueue for this profile # @param [Integer] profile_age_in_seconds How many seconds have passed since profile created + # @param [String] initiating_service_provider The initiating service provider issuer # @param [Integer] submit_attempts Number of attempts to enter a correct code # (previously called "attempts") # @param [Boolean] pending_in_person_enrollment @@ -5267,6 +5268,7 @@ def idv_verify_by_mail_enter_code_submitted( which_letter:, letter_count:, profile_age_in_seconds:, + initiating_service_provider:, submit_attempts:, pending_in_person_enrollment:, fraud_check_failed:, @@ -5282,6 +5284,7 @@ def idv_verify_by_mail_enter_code_submitted( which_letter:, letter_count:, profile_age_in_seconds:, + initiating_service_provider:, submit_attempts:, pending_in_person_enrollment:, fraud_check_failed:, @@ -6439,7 +6442,7 @@ def phone_change_viewed # @param [Boolean] success # @param [Integer] phone_configuration_id - # tracks a phone number deletion event + # Tracks a phone number deletion event def phone_deletion(success:, phone_configuration_id:, **extra) track_event( 'Phone Number Deletion: Submitted', @@ -6482,7 +6485,7 @@ def piv_cac_delete_submitted( # @param [Hash] errors Errors resulting from form validation # @param [String, nil] key_id PIV/CAC key_id from PKI service # @param [Boolean] new_device Whether the user is authenticating from a new device - # tracks piv cac login event + # Tracks piv cac login event def piv_cac_login(success:, errors:, key_id:, new_device:, **extra) track_event( :piv_cac_login, @@ -6900,12 +6903,8 @@ def rules_of_use_visit # @param [Boolean] request_signed # @param [String] matching_cert_serial matches the request certificate in a successful, signed # request - # @param [Boolean] certs_different Whether the matching cert changes when SHA256 validations - # are turned on in the saml_idp gem # @param [Hash] cert_error_details Details for errors that occurred because of an invalid # signature - # @param [String] sha256_matching_cert serial of the cert that matches when sha256 validations - # are turned on # @param [String] unknown_authn_contexts space separated list of unknown contexts def saml_auth( success:, @@ -6923,8 +6922,6 @@ def saml_auth( matching_cert_serial:, error_details: nil, cert_error_details: nil, - certs_different: nil, - sha256_matching_cert: nil, unknown_authn_contexts: nil, **extra ) @@ -6945,8 +6942,6 @@ def saml_auth( request_signed:, matching_cert_serial:, cert_error_details:, - certs_different:, - sha256_matching_cert:, unknown_authn_contexts:, **extra, ) @@ -7040,17 +7035,17 @@ def security_event_received( ) end - # tracks if the session is kept alive + # Tracks if the session is kept alive def session_kept_alive track_event('Session Kept Alive') end - # tracks if the session timed out + # Tracks if the session timed out def session_timed_out track_event('Session Timed Out') end - # tracks when a user's session is timed out + # Tracks when a user's session is timed out def session_total_duration_timeout track_event('User Maximum Session Length Exceeded') end @@ -7061,7 +7056,7 @@ def sign_in_notification_timeframe_expired_absent end # @param [String] flash - # tracks when a user visits the sign in page + # Tracks when a user visits the sign in page def sign_in_page_visit(flash:, **extra) track_event('Sign in page visited', flash:, **extra) end @@ -7077,7 +7072,7 @@ def sign_in_security_check_failed_visited # @param [Boolean] new_user Whether this is an incomplete user (no associated MFA methods) # @param [Boolean] has_other_auth_methods Whether the user has other authentication methods # @param [Integer] phone_configuration_id Phone configuration associated with request - # tracks when a user opts into SMS + # Tracks when a user opts into SMS def sms_opt_in_submitted( success:, errors:, @@ -7102,7 +7097,7 @@ def sms_opt_in_submitted( # @param [Boolean] new_user # @param [Boolean] has_other_auth_methods # @param [Integer] phone_configuration_id - # tracks when a user visits the sms opt in page + # Tracks when a user visits the sms opt in page def sms_opt_in_visit( new_user:, has_other_auth_methods:, diff --git a/app/services/doc_auth/lexis_nexis/doc_pii_reader.rb b/app/services/doc_auth/lexis_nexis/doc_pii_reader.rb index 0c6e5b2556b..26e172491fc 100644 --- a/app/services/doc_auth/lexis_nexis/doc_pii_reader.rb +++ b/app/services/doc_auth/lexis_nexis/doc_pii_reader.rb @@ -106,7 +106,7 @@ def parse_sex_value(sex_attribute) end def parse_height_value(height_attribute) - height_match_data = height_attribute&.match(/(?\d)'(?\d{1,2})"/) + height_match_data = height_attribute&.match(/(?\d)' ?(?\d{1,2})"/) return unless height_match_data diff --git a/app/services/encrypted_doc_storage/doc_writer.rb b/app/services/encrypted_doc_storage/doc_writer.rb new file mode 100644 index 00000000000..3d2e0555108 --- /dev/null +++ b/app/services/encrypted_doc_storage/doc_writer.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module EncryptedDocStorage + class DocWriter + Result = Struct.new( + :name, + :encryption_key, + ) + + def write(image:, data_store: LocalStorage) + name = SecureRandom.uuid + storage = data_store.new + + storage.write_image( + encrypted_image: aes_cipher.encrypt(image, key), + name:, + ) + + Result.new( + name:, + encryption_key: Base64.strict_encode64(key), + ) + end + + private + + def aes_cipher + @aes_cipher ||= Encryption::AesCipherV2.new + end + + def key + @key ||= SecureRandom.bytes(32) + end + end +end diff --git a/app/services/encrypted_doc_storage/local_storage.rb b/app/services/encrypted_doc_storage/local_storage.rb new file mode 100644 index 00000000000..d048677bdbb --- /dev/null +++ b/app/services/encrypted_doc_storage/local_storage.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module EncryptedDocStorage + class LocalStorage + def write_image(encrypted_image:, name:) + FileUtils.mkdir_p(tmp_document_storage_dir) + + File.open(tmp_document_storage_dir.join(name), 'wb') do |f| + f.write(encrypted_image) + end + end + + private + + def tmp_document_storage_dir + Rails.root.join('tmp', 'encrypted_doc_storage') + end + end +end diff --git a/app/services/encrypted_doc_storage/s3_storage.rb b/app/services/encrypted_doc_storage/s3_storage.rb new file mode 100644 index 00000000000..8c4a219d6d6 --- /dev/null +++ b/app/services/encrypted_doc_storage/s3_storage.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module EncryptedDocStorage + class S3Storage + def write_image(encrypted_image:, name:) + s3_client.put_object( + bucket:, + body: encrypted_image, + key: name, + ) + end + + private + + def s3_client + Aws::S3::Client.new( + http_open_timeout: 5, + http_read_timeout: 5, + compute_checksums: false, + ) + end + + def bucket + IdentityConfig.store.encrypted_document_storage_s3_bucket + end + end +end diff --git a/app/services/idv/aamva_state_maintenance_window.rb b/app/services/idv/aamva_state_maintenance_window.rb index e22c667cb61..357d5beca98 100644 --- a/app/services/idv/aamva_state_maintenance_window.rb +++ b/app/services/idv/aamva_state_maintenance_window.rb @@ -3,10 +3,13 @@ module Idv class AamvaStateMaintenanceWindow # All AAMVA maintenance windows are expressed in 'ET' (LG-14028), - # except Montana's which we converted here from MST to ET. TZ = 'America/New_York' MAINTENANCE_WINDOWS = { + 'AL' => [ + # First Monday of each month from 1 am – 7 am ET + { cron: '0 1 * * Mon#1', duration_minutes: 6 * 60 }, + ], 'CA' => [ # Daily, 4:00 - 5:30 am. ET. { cron: '0 4 * * *', duration_minutes: 90 }, @@ -16,11 +19,17 @@ class AamvaStateMaintenanceWindow { cron: '0 1 * * Mon#1', duration_minutes: 3.5 * 60 }, { cron: '0 1 * * Mon#3', duration_minutes: 3.5 * 60 }, ], + 'CO' => [ + # 02:00 - 08:00 AM ET on the first Tuesday of every month. + { cron: '0 2 * * Tue#1', duration_minutes: 6 * 60 }, + ], 'CT' => [ - # Daily, 4:00 am. to 6:30 am. ET. - { cron: '0 4 * * *', duration_minutes: 90 }, - # Sunday 6:00 am. to 9:30 am. ET - { cron: '0 6 * * Mon', duration_minutes: 3.5 * 60 }, + # Daily, 3:00 am. to 4:00 am. ET. + { cron: '0 3 * * *', duration_minutes: 1 }, + # Sunday 5:00 am. to 7:00 am. ET + { cron: '0 6 * * Sun', duration_minutes: 2 * 60 }, + # second Sunday of month 4:00 am. to 8:00 am. ET + { cron: '0 4 * * Sun#2', duration_minutes: 4 * 60 }, ], 'DC' => [ # Daily, Midnight to 6 am. ET. @@ -34,40 +43,59 @@ class AamvaStateMaintenanceWindow # Sunday 7:00 am. to 12:00 pm. ET { cron: '0 7 * * Sun', duration_minutes: 5 * 60 }, ], + 'GA' => [ + # Daily, 5:00 am. to 6:00 am. ET. + { cron: '0 5 * * *', duration_minutes: 60 }, + ], 'IA' => [ - # "Daily system resets, normally at 4:45 am. to 5:15 am ET." + # "Daily, normally at 4:45 am. to 5:15 am ET." { cron: '45 4 * * *', duration_minutes: 30 }, + # (Also "Sunday mornings but only seconds at a time.") ], - 'IN' => [ - # Sunday morning maintenance from 6 am. to 10 am. ET. - { cron: '0 6 * * Sun', duration_minutes: 4 * 60 }, + 'ID' => [ + # "Every third Wednesday: 9:00 pm to midnight ET" + # This is impossible to model as a cron expression, and it's + # meaningless in English without identifying when it starts. + # I'm modeling this as _every_ Wednesday since we're really + # answering "Should we expect a maintenance window right now?", + # and we don't block the user from anything. + { cron: '0 21 * * Wed', duration_minutes: 3 * 60 }, ], 'IL' => [ - { cron: '30 2 * * *', duration_minutes: 2.5 * 60 }, # Daily, 2:30 am. to 5 am. ET. + # Daily, 2:30 am. to 5:00 am. ET. + { cron: '30 2 * * *', duration_minutes: 2.5 * 60 }, + ], + 'IN' => [ + # Sunday 5:00 am. to 10:00 am. ET. + { cron: '0 5 * * Sun', duration_minutes: 5 * 60 }, + ], + 'KS' => [ + # Sunday: 7:00 am. to 1:00 pm. ET + { cron: '0 7 * * Sun', duration_minutes: 6 * 60 }, ], 'KY' => [ - # Daily maintenance from 2:50 am. to 6:40 am. ET - { cron: '50 2 * * *', duration_minutes: 230 }, + # Daily maintenance from 2:35 am. to 6:40 am. ET + { cron: '35 2 * * *', duration_minutes: 245 }, + # "Monthly on Sunday, midnight to 10:00 am ET." + # (Okay, but _which_ Sunday?) ], 'MA' => [ - # Daily maintenance from 6 am. to 6:15 am. ET. - { cron: '0 6 * * *', duration_minutes: 15 }, - # Wednesday 7 am. to 7:30 am. ET. - { cron: '0 7 * * Wed', duration_minutes: 30 }, + # Daily 3:00 am. to 4:00 am. ET. + { cron: '0 3 * * *', duration_minutes: 60 }, # Saturday 10:00 pm. to Sunday 10:00 am { cron: '0 22 * * Sat', duration_minutes: 12 * 60 }, + # Sunday 2:00 am to 5:00 am ET + { cron: '0 2 * * Sun', duration_minutes: 3 * 60 }, # First Friday of each month: 12 to 6 am. ET. { cron: '0 0 * * Fri#1', duration_minutes: 6 * 60 }, ], 'MD' => [ - # Daily maintenance from 3 am. to 3:15 am. ET. - { cron: '0 3 * * *', duration_minutes: 15 }, # Sunday maintenance may occur from 6 am. to 10 am. ET. { cron: '0 6 * * Sun', duration_minutes: 4 * 60 }, ], 'MI' => [ - # Daily maintenance from 9 pm. to 9:15 pm. ET. - { cron: '0 21 * * *', duration_minutes: 15 }, + # Daily maintenance from 9 pm. to 9:30 pm. ET. + { cron: '0 21 * * *', duration_minutes: 30 }, ], 'MO' => [ # Daily maintenance from 2 am. to 4:30 am. ... @@ -76,45 +104,71 @@ class AamvaStateMaintenanceWindow { cron: '30 6 * * *', duration_minutes: 15 }, # ... and 8:30 am. to 8:35 am ET. { cron: '30 8 * * *', duration_minutes: 5 }, - # Sundays from 9 am. to 10:30 am. ET... - { cron: '0 9 * * Sun', duration_minutes: 90 }, - # ...and 5 am to 5:45 am ET on 2nd Sunday of month. - { cron: '0 5 * * Sun#2', duration_minutes: 45 }, ], 'MT' => [ - # Monthly maintenance occurs first Sunday of each month - # from 12:00 am to 6:00 am (Mountain Time zone). - { cron: '0 2 * * Sun#1', duration_minutes: 6 * 60 }, + # Third Saturday of odd numbered months from 12:00 am to 6:00 am ET + { cron: '0 2 * /2 Sat#3', duration_minutes: 6 * 60 }, ], 'NC' => [ # Daily, Midnight to 7:00 am. ET. { cron: '0 0 * * *', duration_minutes: 7 * 60 }, - # Sundays from 5am. till Noon - { cron: '0 5 * * Sun', duration_minutes: 7 * 60 }, ], - # NM: "Sunday mornings." (not modeling; too vague) + 'ND' => [ + # Wednesday around 7:30 pm to 7:35 pm ET + { cron: '30 19 * * Wed', duration_minutes: 5 }, + # 3rd Sunday of month, 5 minutes anytime between midnight and noon. + ], + 'NM' => [ + # Sundays 8:00 am. to noon ET. + { cron: '0 8 * * Sun', duration_minutes: 4 * 60 }, + ], + 'NV' => [ + # Tuesdays to Sundays: 2:00 am. to 3:15 am. ET + { cron: '0 2 * * Tue-Sun', duration_minutes: 1.25 * 60 }, + ], 'NY' => [ # Sunday maintenance 8 pm. to 9 pm. ET. { cron: '0 20 * * Sun', duration_minutes: 60 }, ], + 'OH' => [ + # Daily 4:00 am. to 4:30 am. ET + { cron: '0 4 * * *', duration_minutes: 30 }, + ], + 'OR' => [ + # Sunday 7:30 am. to 9:00 am. ET. + { cron: '30 7 * * Sun', duration_minutes: 1.5 * 60 }, + ], 'PA' => [ - # Sunday maintenance may occur, often between 5:30 am. & 7:00 am. ET - { cron: '30 5 * * Sun', duration_minutes: 90 }, + # Sunday 5:00 am. to 7:00 am. ET. + { cron: '0 5 * * Sun', duration_minutes: 2 * 60 }, + ], + 'RI' => [ + # Either 3rd or 4th Sunday of each month, 7:30 am. to 10:00 am. ET. + { cron: '30 7 * * Sun#3', duration_minutes: 2.5 * 60 }, + { cron: '30 7 * * Sun#4', duration_minutes: 2.5 * 60 }, ], 'SC' => [ - # Sunday maintenance from 7:00 pm. to 10:00 pm. ET. - { cron: '0 19 * * Sun', duration_minutes: 3 * 60 }, + # Sunday 6:00 pm. to 10:00 pm. ET. + { cron: '0 18 * * Sun', duration_minutes: 4 * 60 }, + ], + 'TN' => [ + # Last Sunday of every month from 11:00 pm Sunday to 2:00 am. Monday ET + { cron: '0 23 * * Sun#last', duration: 3 * 60 }, ], 'TX' => [ - # Downtime on weekends between 9 pm ET to 7 am ET. - { cron: '0 21 * * Sat,Sun', duration_minutes: 10 * 60 }, + # Saturday 9:00 pm. to Sunday 7:00 am. ET. + { cron: '0 21 * * Sat', duration_minutes: 10 * 60 }, + ], + 'UT' => [ + # 3rd Sunday of every month 1:00 am. to 9:00 am. ET + { cron: '0 1 0 0 Sun#3', duration_minutes: 8 * 60 }, ], 'VA' => [ - # Sunday morning maintenance 3:00 am. to 5 am. ET. - { cron: '0 3 * * Sun', duration_minutes: 120 }, - # Daily maintenance from 5 am. to 5:30 am. + # Daily 5:00 am. to 5:30 am. ET { cron: '0 5 * * *', duration_minutes: 30 }, - # "Might not respond for short spells, daily between 7 pm and 8:30 pm." (not modeling this) + # Sunday morning maintenance 3:00 am. to 5 am. ET. + { cron: '0 3 * * Sun', duration_minutes: 2 * 60 }, + # "Might not respond for short spells, daily between 7 pm and 8:30 pm." (not modeling this) ], 'VT' => [ # Daily maintenance from midnight to 5 am. ET. @@ -131,8 +185,8 @@ class AamvaStateMaintenanceWindow { cron: '0 6 * * Sun', duration_minutes: 4 * 60 }, ], 'WV' => [ - # Occasional Sunday maintenance from 6:00 am. to noon ET. - { cron: '0 6 * * Sun', duration_minutes: 6 * 60 }, + # Sunday 6:00 am. to 6:20 am. ET + { cron: '0 6 * * Sun', duration_minutes: 20 }, ], 'WY' => [ # Daily, 2 am. to 5 am. ET. diff --git a/app/services/idv/agent.rb b/app/services/idv/agent.rb index 6a0cb8bedd5..b8908bd78db 100644 --- a/app/services/idv/agent.rb +++ b/app/services/idv/agent.rb @@ -6,13 +6,16 @@ def initialize(applicant) @applicant = applicant.symbolize_keys end + # @param document_capture_session [DocumentCaptureSession] + # @param proofing_components [Idv::ProofingComponents] def proof_resolution( document_capture_session, trace_id:, user_id:, threatmetrix_session_id:, request_ip:, - ipp_enrollment_in_progress: + ipp_enrollment_in_progress:, + proofing_components: ) document_capture_session.create_proofing_session @@ -29,6 +32,7 @@ def proof_resolution( threatmetrix_session_id: threatmetrix_session_id, request_ip: request_ip, ipp_enrollment_in_progress: ipp_enrollment_in_progress, + proofing_components: proofing_components.to_h, } if IdentityConfig.store.ruby_workers_idv_enabled diff --git a/app/services/idv/proofing_components.rb b/app/services/idv/proofing_components.rb index 172848b7b93..b7432c5c4c1 100644 --- a/app/services/idv/proofing_components.rb +++ b/app/services/idv/proofing_components.rb @@ -37,10 +37,7 @@ def residential_resolution_check end def resolution_check - if idv_session.verify_info_step_complete? - # NOTE: Fallback to LexisNexis to handle 50/50 state, will be removed later - idv_session.resolution_vendor || Idp::Constants::Vendors::LEXIS_NEXIS - end + idv_session.resolution_vendor if idv_session.verify_info_step_complete? end def address_check diff --git a/app/services/proofing/aamva/applicant.rb b/app/services/proofing/aamva/applicant.rb index a0e697b0e8c..24dece1cb46 100644 --- a/app/services/proofing/aamva/applicant.rb +++ b/app/services/proofing/aamva/applicant.rb @@ -87,10 +87,11 @@ def self.from_proofer_applicant(applicant) return if height.nil? # From the AAMVA DLDV guide regarding formatting the height: - # - # The height is provided in feet-inches (i.e. 5 foot 10 inches is presented as "510"). - # - [(height / 12).to_s, (height % 12).to_s].join('') + # > Height data should be 3 characters (i.e. 5 foot 7 inches is submitted as 507) + feet = (height / 12).floor + inches = (height % 12).floor + + "#{feet}#{format('%02d', inches)}" end end.freeze end diff --git a/app/views/idv/in_person/ready_to_verify/show.html.erb b/app/views/idv/in_person/ready_to_verify/show.html.erb index 55f01e51174..109f9cdd468 100644 --- a/app/views/idv/in_person/ready_to_verify/show.html.erb +++ b/app/views/idv/in_person/ready_to_verify/show.html.erb @@ -171,7 +171,7 @@ <% end %> -<%# What to expect at the Post Office %> +<%# What to do at the Post Office %>

<%= t('in_person_proofing.body.barcode.what_to_expect') %>

<%= render ProcessListComponent.new(heading_level: :h3) do |c| %> diff --git a/app/views/user_mailer/shared/_in_person_ready_to_verify.html.erb b/app/views/user_mailer/shared/_in_person_ready_to_verify.html.erb index 46e0a0090e6..3db2200a903 100644 --- a/app/views/user_mailer/shared/_in_person_ready_to_verify.html.erb +++ b/app/views/user_mailer/shared/_in_person_ready_to_verify.html.erb @@ -207,7 +207,7 @@
<% end %> -<%# What to expect at the Post Office %> +<%# What to do at the Post Office %>

<%= t('in_person_proofing.body.barcode.what_to_expect') %> diff --git a/bin/summarize-user-events b/bin/summarize-user-events new file mode 100755 index 00000000000..0650f35c840 --- /dev/null +++ b/bin/summarize-user-events @@ -0,0 +1,286 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +Dir.chdir(__dir__) { require 'bundler/setup' } + +require 'active_support' +require 'active_support/core_ext/integer/time' +require 'active_support/core_ext/object/blank' +require 'active_support/time' +require 'aws-sdk-cloudwatchlogs' +require 'concurrent-ruby' +require 'optparse' + +$LOAD_PATH.unshift(File.expand_path(File.join(__dir__, '../lib'))) +require 'reporting/cloudwatch_client' +require 'reporting/cloudwatch_query_quoting' + +# Require all *_matcher.rb files in lib/event_summarizer +Dir[File.expand_path( + File.join(__dir__, '../lib/event_summarizer', '**', '*_matcher.rb'), +)].sort.each do |f| + require f +end + +class SummarizeUserEvents + attr_reader :file_name, :uuid, :from_date, :stderr, :stdout, :to_date, :zone + + NICE_DATE_AND_TIME_FORMAT = '%B %d, %Y at %I:%M %p %Z' + TIME_ONLY_FORMAT = '%I:%M %p' + + def initialize( + file_name: nil, + user_uuid: nil, + start_time: nil, + end_time: nil, + zone: 'UTC', + stdout: STDOUT, + stderr: STDERR + ) + @file_name = file_name + @zone = zone + @uuid = user_uuid + @from_date = parse_time(start_time) || 1.week.ago + @to_date = parse_time(end_time) || ( + start_time.present? ? + from_date + 1.week : + ActiveSupport::TimeZone[zone].now + ) + @stdout = stdout + @stderr = stderr + end + + def parse_time(time_str) + return nil if time_str.nil? + + parsed = ActiveSupport::TimeZone['UTC'].parse(time_str) + parsed = parsed.in_time_zone(zone) if zone && parsed + + parsed + end + + def matchers + @matchers ||= [ + EventSummarizer::ExampleMatcher.new, + EventSummarizer::AccountDeletionMatcher.new, + EventSummarizer::IdvMatcher.new, + ] + end + + def self.parse_command_line_options(argv) + options = { + zone: 'America/New_York' + } + basename = File.basename($0) + + # rubocop:disable Metrics/LineLength + optparse = OptionParser.new do |opts| + opts.banner = <<~EOM + + Summarize user events in a human-readable format + + Cloudwatch logs can be read from a file as newline-delimited JSON (ndjson), + or fetched directly via aws-vault. + + Usage: #{basename} [OPTIONS] + + Examples: + #{basename} -f events.ndjson + aws-vault exec prod-power -- #{basename} -u 1234-5678-90ab-cdef -s 2024-12-09T10:00:00 -e 2024-12-09T14:30:00 -z America/New_York + + EOM + + opts.on('-f', '--file_name FILE_NAME', 'filename from which to read the events') do |val| + options[:file_name] = val + end + + opts.on('-h', '--help', 'Display this message') do + warn opts + exit + end + + opts.on('-u', '--user-uuid USER_UUID', 'UUID of the protagonist of the story') do |val| + options[:user_uuid] = val + end + + opts.on('-s', '--start-time START_TIME', 'Time of the start of the query period (e.g. 2024-12-09T10:00:00Z), default: 1 week ago') do |val| + options[:start_time] = val + end + + opts.on('-e', '--end-time END_TIME', 'Time of the end of the query period (e.g. 2024-12-09T14:30:00Z), default: 1 week from start') do |val| + options[:end_time] = val + end + + opts.on('-z', '--timezone TIMEZONE', 'Timezone to use (e.g. America/New_York), default: UTC') do |val| + options[:zone] = val + end + end + # rubocop:enable Metrics/LineLength + + optparse.parse!(argv) + + options + end + + + def run + in_correct_time_zone do + find_cloudwatch_events do |event| + # Time.zone is thread-local, and CloudwatchClient may use multiple + # threads to make requests. So we have to make double-sure we're + # in the right Timezone. + in_correct_time_zone do + normalize_event!(event) + + matchers.each do |matcher| + matcher.handle_cloudwatch_event(event) + end + end + end + + overall_results = [] + + matchers.each do |matcher| + results_for_matcher = matcher.finish + overall_results.append(*results_for_matcher) + end + + stdout.puts format_results(overall_results) + end + end + + def format_results(results) + # Each Hash in results should have _at least_ a :title key defined + + results. + sort_by { |r| r[:timestamp] || r[:started_at] || Time.zone.at(0) }. + map do |r| + timestamp = r[:timestamp] || r[:started_at] + + heading = r[:title] + + if timestamp + heading = "#{heading} (#{format_time(timestamp)})" + end + + prev_timestamp = timestamp + + list_items = r[:attributes] + &.sort_by { |attr| attr[:timestamp] || Time.zone.at(0) } + &.map do |attr| + text = attr[:description] + + formatted_timestamp = format_time(attr[:timestamp], prev_timestamp) + prev_timestamp = attr[:timestamp] + + text = "(#{formatted_timestamp}) #{text}" if formatted_timestamp + + "* #{text}" + end + + [ + "## #{heading}", + *list_items, + '', + ] + end.join("\n") + end + + def format_time(timestamp, prev_timestamp = nil) + return if timestamp.blank? + + timestamp = timestamp.in_time_zone(zone) + prev_timestamp = prev_timestamp&.in_time_zone(zone) + + same_date = timestamp.to_date == prev_timestamp&.to_date + + if same_date + timestamp.strftime(TIME_ONLY_FORMAT) + else + timestamp.strftime(NICE_DATE_AND_TIME_FORMAT) + end + end + + def query + format(<<~QUERY) + fields + name + , properties.event_properties.success as success + , @message + , @timestamp + | filter properties.user_id = '#{uuid}' + | sort @timestamp asc + | limit 10000 + QUERY + end + + def cloudwatch_client + @cloudwatch_client ||= Reporting::CloudwatchClient.new( + num_threads: 5, + ensure_complete_logs: true, + log_group_name: 'prod_/srv/idp/shared/log/events.log', + ) + end + + def find_cloudwatch_events(&block) + unless file_name.nil? + warn 'Reading Cloudwatch events as newline-delimited JSON (ndjson) file' + file_source(&block) + else + cloudwatch_source(&block) + end + end + + def file_source(&block) + events = [] + + File.read(file_name).each_line do |line| + next if line.blank? + events << JSON.parse(line) + end + + events.sort_by! { |e| e['@timestamp'] } + + events.each do |e| + block.call(e) + end + end + + def cloudwatch_source(&block) + cloudwatch_client.fetch( + query: query, + from: from_date, + to: to_date, + &block + ) + end + + def in_correct_time_zone + old_time_zone = Time.zone + Time.zone = zone + yield + ensure + Time.zone = old_time_zone + end + + def normalize_event!(event) + if event['@timestamp'].is_a?(String) + event['@timestamp'] = ActiveSupport::TimeZone['UTC'].parse(event['@timestamp']) + end + + if event['@message'].is_a?(String) + event['@message'] = JSON.parse(event['@message']) + end + + event['name'] ||= event['@message']['name'] + end +end + +def main + options = SummarizeUserEvents.parse_command_line_options(ARGV) + SummarizeUserEvents.new(**options).run +end + +if $PROGRAM_NAME == __FILE__ + main +end diff --git a/config/application.yml.default b/config/application.yml.default index 0c72eaaba56..af18e673bb8 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -130,6 +130,7 @@ enable_load_testing_mode: false enable_rate_limiting: true enable_test_routes: true enable_usps_verification: true +encrypted_document_storage_s3_bucket: 'test-bucket' event_disavowal_expiration_hours: 240 facial_match_general_availability_enabled: true feature_idv_force_gpo_verification_enabled: false @@ -168,6 +169,7 @@ idv_send_link_attempt_window_in_minutes: 10 idv_send_link_max_attempts: 5 idv_socure_reason_code_download_enabled: false idv_socure_shadow_mode_enabled: false +idv_socure_shadow_mode_enabled_for_docv_users: true idv_sp_required: false in_person_completion_survey_url: 'https://login.gov' in_person_doc_auth_button_enabled: true @@ -247,13 +249,13 @@ logins_per_ip_track_only_mode: false logo_upload_enabled: false mailer_domain_name: http://localhost:3000 max_auth_apps_per_account: 2 -max_bad_passwords: 5 -max_bad_passwords_window_in_seconds: 60 max_emails_per_account: 12 max_mail_events: 4 max_mail_events_window_in_days: 30 max_phone_numbers_per_account: 5 max_piv_cac_per_account: 2 +max_sign_in_failures: 5 +max_sign_in_failures_window_in_seconds: 60 mfa_report_config: '[]' min_password_score: 3 minimum_wait_before_another_usps_letter_in_hours: 24 @@ -522,6 +524,7 @@ production: email_registrations_per_ip_track_only_mode: true enable_test_routes: false enable_usps_verification: false + encrypted_document_storage_s3_bucket: '' facial_match_general_availability_enabled: false feature_select_email_to_share_enabled: false idv_sp_required: true diff --git a/config/initializers/ab_tests.rb b/config/initializers/ab_tests.rb index aff0162a210..bc053803f56 100644 --- a/config/initializers/ab_tests.rb +++ b/config/initializers/ab_tests.rb @@ -97,11 +97,11 @@ def self.all }, ).freeze - SOCURE_IDV_SHADOW_MODE = AbTest.new( + SOCURE_IDV_SHADOW_MODE_FOR_NON_DOCV_USERS = AbTest.new( experiment_name: 'Socure shadow mode', should_log: ['IdV: doc auth verify proofing results'].to_set, buckets: { - shadow_mode_enabled: IdentityConfig.store.socure_idplus_shadow_mode_percent, + socure_shadow_mode_for_non_docv_users: IdentityConfig.store.socure_idplus_shadow_mode_percent, }, ).freeze diff --git a/config/locales/en.yml b/config/locales/en.yml index 3a2e615c72f..332fd7014e1 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -799,7 +799,7 @@ errors.messages.wrong_length.one: is the wrong length (should be 1 character) errors.messages.wrong_length.other: is the wrong length (should be %{count} characters) errors.piv_cac_setup.unique_name: That name is already taken. Please choose a different name. errors.registration.terms: Before you can continue, you must give us permission. Please check the box below and then click continue. -errors.sign_in.bad_password_limit: You have exceeded the maximum sign in attempts. You must wait %{time_left} before trying again. +errors.sign_in.sign_in_failure_limit: You have exceeded the maximum sign in attempts. You must wait %{time_left} before trying again. errors.two_factor_auth_setup.must_select_additional_option: Select an additional authentication method. errors.two_factor_auth_setup.must_select_option: Select an authentication method. errors.verify_personal_key.rate_limited: You tried too many times, please try again in %{timeout}. @@ -1223,7 +1223,7 @@ image_description.warning: Yellow caution sign in_person_proofing.body.barcode.cancel_link_text: Cancel your barcode in_person_proofing.body.barcode.close_window: You may now close this window. in_person_proofing.body.barcode.deadline: You must visit any participating Post Office by %{deadline}. -in_person_proofing.body.barcode.deadline_restart: If you go after this deadline, your information will not be saved and you will need to restart the process. +in_person_proofing.body.barcode.deadline_restart: If you go after this deadline, your barcode will not work. You will need to restart the process. in_person_proofing.body.barcode.eipp_tag: GSA Enhanced Pilot Barcode in_person_proofing.body.barcode.eipp_what_to_bring: 'Depending on your ID, you may need to show supporting documents. Review the following options carefully:' in_person_proofing.body.barcode.email_sent: We have sent your barcode and the information below to the email you used to sign in @@ -1233,11 +1233,11 @@ in_person_proofing.body.barcode.questions: Questions? in_person_proofing.body.barcode.retail_hours: Retail hours in_person_proofing.body.barcode.retail_hours_closed: Closed in_person_proofing.body.barcode.return_to_partner_link: Return to %{sp_name} -in_person_proofing.body.barcode.what_to_expect: What to expect at the Post Office +in_person_proofing.body.barcode.what_to_expect: What to do at the Post Office in_person_proofing.body.cta.button: Try in person in_person_proofing.body.cta.prompt_detail: Most people who are unable to complete this step online are successful in verifying their identity at a participating Post Office. No appointment needed. Locations are available nationwide. in_person_proofing.body.expect.heading: What to expect after your visit -in_person_proofing.body.expect.info: We’ll send you an email to let you know if your identity verification was successful or unsuccessful within 24 hours of your visit to the Post Office. +in_person_proofing.body.expect.info: You’ll get an email within 24 hours of visiting a Post Office. We’ll tell you if your identity verification was successful or unsuccessful. Check your email for a message from no-reply@login.gov. in_person_proofing.body.location.change_location_find_other_locations: Find other participating Post Office locations. in_person_proofing.body.location.change_location_heading: Need to change your Post Office location? in_person_proofing.body.location.change_location_info_html: You don’t need to create a new barcode, you can bring this barcode to any participating Post Office location. %{find_other_locations_link_html} @@ -1316,7 +1316,7 @@ in_person_proofing.form.state_id.state_id_number_hint_spaces: spaces in_person_proofing.form.state_id.state_id_number_texas_hint: This is the 8-digit number on your ID. Enter only numbers in this field. in_person_proofing.form.state_id.zipcode: ZIP Code in_person_proofing.headings.address: Enter your current residential address -in_person_proofing.headings.barcode: Show this barcode and your state‑issued ID at a Post Office to finish verifying your identity +in_person_proofing.headings.barcode: Show this barcode and your state ID at a Post Office to finish verifying your identity in_person_proofing.headings.barcode_eipp: Bring this barcode and supporting documents to a Post Office to finish verifying your identity in_person_proofing.headings.barcode_what_to_bring: What to bring to the Post Office in_person_proofing.headings.cta: Try verifying your ID in person @@ -1366,7 +1366,7 @@ in_person_proofing.process.state_id.heading_eipp: Show your ID and supporting do in_person_proofing.process.state_id.info: This must not be expired. We do not currently accept any other forms of identification, such as passports and military IDs. in_person_proofing.process.state_id.info_eipp: The retail associate will scan your ID. This must not be expired. Depending on the type of ID that you present, you may need to show supporting documents. See the requirements in the “What to bring to the Post Office” section. in_person_proofing.process.what_to_do.heading: Stand in any line -in_person_proofing.process.what_to_do.info: Tell the Post Office retail associate you are here to verify your identity with %{app_name}. +in_person_proofing.process.what_to_do.info: Tell the Post Office retail associate you are here to verify your identity. If they don’t know how to proceed, ask for a supervisor to help you. instructions.account.reactivate.begin: Let’s get started. instructions.account.reactivate.explanation: 'When you created your account, we gave you a list of words and asked you to store them in a safe place. It looked similar to this:' instructions.account.reactivate.intro: We take extra steps to keep your personal information secure and private, so resetting your password takes a little extra effort. diff --git a/config/locales/es.yml b/config/locales/es.yml index a8b5b98d7b1..00686cb34f0 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -810,7 +810,7 @@ errors.messages.wrong_length.one: tiene la longitud incorrecta (debe ser de 1 c errors.messages.wrong_length.other: tiene la longitud incorrecta (debe ser de %{count} caracteres) errors.piv_cac_setup.unique_name: Ese nombre ya fue seleccionado. Elija un nombre diferente. errors.registration.terms: Antes de continuar, debe darnos permiso. Marque la casilla a continuación y luego haga clic en continuar. -errors.sign_in.bad_password_limit: Superó el número máximo de intentos de inicio de sesión. Debe esperar %{time_left} antes de volver a intentarlo. +errors.sign_in.sign_in_failure_limit: Superó el número máximo de intentos de inicio de sesión. Debe esperar %{time_left} antes de volver a intentarlo. errors.two_factor_auth_setup.must_select_additional_option: Seleccione un método de autenticación adicional. errors.two_factor_auth_setup.must_select_option: Seleccione un método de autenticación. errors.verify_personal_key.rate_limited: Lo intentó demasiadas veces; vuelva a intentarlo en %{timeout}. @@ -1233,8 +1233,8 @@ image_description.us_flag: Bandera de los EE. UU. image_description.warning: Señal amarilla de precaución in_person_proofing.body.barcode.cancel_link_text: Cancele su código de barras in_person_proofing.body.barcode.close_window: Ya puede cerrar esta ventana. -in_person_proofing.body.barcode.deadline: Debe acudir a cualquier oficina de correos participante antes del %{deadline} -in_person_proofing.body.barcode.deadline_restart: Si acude después del plazo, su información no se guardará y tendrá que reiniciar el proceso. +in_person_proofing.body.barcode.deadline: Debe acudir a cualquier oficina de correos participante antes del %{deadline}. +in_person_proofing.body.barcode.deadline_restart: Si acude después de esta fecha límite, su código de barras ya no funcionará y tendrá que reiniciar el proceso. in_person_proofing.body.barcode.eipp_tag: Código de barras piloto mejorado GSA in_person_proofing.body.barcode.eipp_what_to_bring: 'Según el tipo de identificación que tenga, es posible que deba presentar documentos comprobatorios. Lea con atención las opciones siguientes:' in_person_proofing.body.barcode.email_sent: Enviamos el código de barras y la información más abajo al correo electrónico que usó para iniciar sesión @@ -1244,11 +1244,11 @@ in_person_proofing.body.barcode.questions: '¿Tiene alguna pregunta?' in_person_proofing.body.barcode.retail_hours: Horario de atención al público in_person_proofing.body.barcode.retail_hours_closed: Cerrado in_person_proofing.body.barcode.return_to_partner_link: Volver a %{sp_name} -in_person_proofing.body.barcode.what_to_expect: Qué esperar en la oficina de correos +in_person_proofing.body.barcode.what_to_expect: Qué hacer en la oficina de correos in_person_proofing.body.cta.button: Intentar en persona in_person_proofing.body.cta.prompt_detail: La mayoría de las personas que no pueden hacer la verificación de su identidad en línea logran verificarla en una oficina de correos participante. No es necesario hacer cita para ello, y hay oficinas en todo el país. in_person_proofing.body.expect.heading: Qué esperar después de la visita -in_person_proofing.body.expect.info: En las 24 horas siguientes a su visita a la oficina de correos, recibirá un correo electrónico para informarle si se logró o no su verificación de identidad. +in_person_proofing.body.expect.info: En las 24 horas siguientes a su visita a la oficina de correos, recibirá un correo electrónico para informarle si se logró o no su verificación de identidad. Busque un mensaje de no-reply@login.gov en su correo electrónico. in_person_proofing.body.location.change_location_find_other_locations: Busque otras oficinas de correos participantes. in_person_proofing.body.location.change_location_heading: ¿Necesita cambiar su oficina de correos? in_person_proofing.body.location.change_location_info_html: No necesita crear un nuevo código de barras, puede llevar este código de barras a cualquier oficina de correos participante. %{find_other_locations_link_html} @@ -1327,7 +1327,7 @@ in_person_proofing.form.state_id.state_id_number_hint_spaces: espacios in_person_proofing.form.state_id.state_id_number_texas_hint: Este es el número de 8 dígitos de su identificación. Ingrese únicamente números en este campo. in_person_proofing.form.state_id.zipcode: Código postal in_person_proofing.headings.address: Ingrese su domicilio actual -in_person_proofing.headings.barcode: Muestre este código de barras y su identificación emitida por el estado en una oficina de correos para terminar de verificar su identidad. +in_person_proofing.headings.barcode: Muestre este código de barras y su identificación el estado en una oficina de correos para terminar de verificar su identidad. in_person_proofing.headings.barcode_eipp: Lleve este código de barras y los documentos comprobatorios a una oficina de correos para terminar de verificar su identidad in_person_proofing.headings.barcode_what_to_bring: Lo que debe llevar a la oficina de correos in_person_proofing.headings.cta: Intente verificar su identidad en persona @@ -1377,7 +1377,7 @@ in_person_proofing.process.state_id.heading_eipp: Muestre su identificación y l in_person_proofing.process.state_id.info: No debe estar vencida. Actualmente no aceptamos otras formas de identificación, como pasaportes o identificaciones militares. in_person_proofing.process.state_id.info_eipp: El empleado escaneará su identificación. No debe estar vencida. Según el tipo de identificación que presente, es posible que deba mostrar documentos comprobatorios. Consulte los requisitos en la sección “Lo que debe llevar a la oficina de correos”. in_person_proofing.process.what_to_do.heading: Colóquese en cualquier fila -in_person_proofing.process.what_to_do.info: Diga al empleado de la oficina de correos que desea verificar su identidad con %{app_name}. +in_person_proofing.process.what_to_do.info: Diga al empleado de la oficina de correos que desea verificar su identidad. Si el empleado no sabe qué hacer, pida la ayuda de un supervisor. instructions.account.reactivate.begin: Empecemos. instructions.account.reactivate.explanation: 'Cuando creó su cuenta, le dimos una lista de palabras y le pedimos que las guardara en un lugar seguro. Era similar a esto:' instructions.account.reactivate.intro: Adoptamos medidas adicionales para mantener su información personal segura y privada, por lo que restablecer su contraseña requiere un poco más de trabajo. diff --git a/config/locales/fr.yml b/config/locales/fr.yml index cdbd12ab40c..827f65367ad 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -799,7 +799,7 @@ errors.messages.wrong_length.one: n’est pas de la bonne longueur (devrait êtr errors.messages.wrong_length.other: n’est pas de la bonne longueur (devrait être de %{count} caractères) errors.piv_cac_setup.unique_name: Ce nom est déjà pris. Veuillez choisir un autre nom. errors.registration.terms: Avant de pouvoir continuer, vous devez nous donner la permission. Veuillez cocher la case ci-dessous, puis cliquez sur Suite. -errors.sign_in.bad_password_limit: Vous avez dépassé le nombre maximal de tentatives de connexion. Vous devez attendre %{time_left} avant de réessayer. +errors.sign_in.sign_in_failure_limit: Vous avez dépassé le nombre maximal de tentatives de connexion. Vous devez attendre %{time_left} avant de réessayer. errors.two_factor_auth_setup.must_select_additional_option: Sélectionnez une méthode d’authentification supplémentaire. errors.two_factor_auth_setup.must_select_option: Sélectionnez une méthode d’authentification. errors.verify_personal_key.rate_limited: Vous avez essayé trop de fois, veuillez réessayer dans %{timeout}. @@ -997,7 +997,7 @@ help_text.requested_attributes.full_name: Nom complet help_text.requested_attributes.ial2_reverified_consent_info_html: 'Étant donné que vous avez revérifié votre identité, nous avons besoin de votre autorisation pour partager ces informations avec %{sp_html} :' help_text.requested_attributes.intro_html: 'Nous partagerons ces informations avec %{sp_html}:' help_text.requested_attributes.phone: Numéro de téléphone -help_text.requested_attributes.select_email_link: Sélectionner l’e-mail +help_text.requested_attributes.select_email_link: Sélectionner l’adresse e-mail help_text.requested_attributes.social_security_number: Numéro de sécurité sociale help_text.requested_attributes.verified_at: Mis à jour le help_text.requested_attributes.x509_issuer: Émetteur PIV/CAC @@ -1222,8 +1222,8 @@ image_description.us_flag: Drapeau des États-Unis image_description.warning: Panneau d’avertissement jaune in_person_proofing.body.barcode.cancel_link_text: Annuler votre code-barres in_person_proofing.body.barcode.close_window: Vous pouvez maintenant fermer cette fenêtre -in_person_proofing.body.barcode.deadline: Vous devez vous rendre dans un bureau de poste participant avant le %{deadline} -in_person_proofing.body.barcode.deadline_restart: Si vous y allez après cette date limite, vos informations ne seront pas sauvegardées et vous devrez recommencer le processus. +in_person_proofing.body.barcode.deadline: Vous devez vous rendre dans un bureau de poste participant avant le %{deadline}. +in_person_proofing.body.barcode.deadline_restart: Si vous laissez passer la date limite, votre code-barres ne fonctionnera pas. Vous devrez recommencer toute la procédure. in_person_proofing.body.barcode.eipp_tag: Code-barres pilote amélioré de la GSA in_person_proofing.body.barcode.eipp_what_to_bring: 'Selon la pièce d’identité dont vous disposez, il pourra vous être demandé de présenter des documents complémentaires. Étudiez attentivement les options suivantes:' in_person_proofing.body.barcode.email_sent: Nous avons envoyé votre code-barre et les informations ci-dessous à l’adresse e-mail que vous avez utilisée pour vous connecter @@ -1233,11 +1233,11 @@ in_person_proofing.body.barcode.questions: Des questions ? in_person_proofing.body.barcode.retail_hours: Heures d’ouverture in_person_proofing.body.barcode.retail_hours_closed: Fermé in_person_proofing.body.barcode.return_to_partner_link: Retourner à %{sp_name} -in_person_proofing.body.barcode.what_to_expect: À quoi s’attendre au bureau de poste +in_person_proofing.body.barcode.what_to_expect: Que faire au bureau de poste in_person_proofing.body.cta.button: Essayer en personne in_person_proofing.body.cta.prompt_detail: La plupart des personnes qui ne parviennent pas à effectuer cette étape en ligne réussissent à confirmer leur identité dans un bureau de poste participant. Sans rendez-vous. Il existe des sites dans l’ensemble du pays. in_person_proofing.body.expect.heading: Que faire après votre visite -in_person_proofing.body.expect.info: Nous vous enverrons un e-mail pour vous informer de la réussite ou de l’échec de la vérification de votre identité dans les 24 heures suivant votre visite au bureau de poste. +in_person_proofing.body.expect.info: Vous recevrez un e-mail dans les 24 heures suivant votre visite dans un bureau de poste. Nous vous informerons si la vérification de votre identité a réussi ou échoué. Vérifiez si vous avez reçu un message de no-reply@login.gov dans votre messagerie. in_person_proofing.body.location.change_location_find_other_locations: Trouvez d’autres bureaux de poste participants. in_person_proofing.body.location.change_location_heading: Vous devez changer de bureau de poste ? in_person_proofing.body.location.change_location_info_html: Pas besoin de créer un nouveau code-barres. Vous pouvez apporter le vôtre à n’importe quel bureau de poste participant. %{find_other_locations_link_html} @@ -1316,7 +1316,7 @@ in_person_proofing.form.state_id.state_id_number_hint_spaces: des espaces in_person_proofing.form.state_id.state_id_number_texas_hint: Il s’agit du numéro à huit chiffres figurant sur votre pièce d’identité. Dans ce champ, ne saisissez que des chiffres. in_person_proofing.form.state_id.zipcode: Code postal in_person_proofing.headings.address: Saisissez votre adresse résidentielle actuelle -in_person_proofing.headings.barcode: Présentez ce code-barres et votre pièce d’identité délivrée par l’État à un bureau de poste pour terminer la vérification de votre identité +in_person_proofing.headings.barcode: Présentez ce code-barres et votre pièce d’identité l’État à un bureau de poste pour terminer la vérification de votre identité in_person_proofing.headings.barcode_eipp: Présenter ce code-barres et les documents complémentaires à un bureau de poste pour terminer la vérification de votre identité in_person_proofing.headings.barcode_what_to_bring: À apporter au bureau de poste in_person_proofing.headings.cta: Essayez de confirmer votre identité en personne @@ -1366,7 +1366,7 @@ in_person_proofing.process.state_id.heading_eipp: Présenter votre pièce d’id in_person_proofing.process.state_id.info: Ce document ne doit pas être périmé. Nous n’acceptons actuellement aucune autre pièce d’identité, comme les passeports et les cartes d’identité militaires. in_person_proofing.process.state_id.info_eipp: Le préposé numérisera votre pièce d’identité. Ce document ne doit pas être périmé. Selon le type de pièce d’identité que vous présentez, il pourra vous être demandé de produire des documents complémentaires. Vous trouverez les exigences à respecter dans la section « Qu’apporter au bureau de poste ». in_person_proofing.process.what_to_do.heading: Faites la queue -in_person_proofing.process.what_to_do.info: Dites au préposé du bureau de poste que vous êtes là pour confirmer votre identité auprès de %{app_name}. +in_person_proofing.process.what_to_do.info: Dites à l’employé que vous venez pour une vérification d’identité. Si la personne ne sait pas comment procéder, demandez qu’un responsable vienne vous aider. instructions.account.reactivate.begin: Commençons. instructions.account.reactivate.explanation: 'Lorsque vous avez créé votre compte, nous vous avons donné une liste de mots et vous avons demandé de les placer en lieu sûr. La liste ressemblait à ceci :' instructions.account.reactivate.intro: Comme nous prenons des mesures supplémentaires pour maintenir vos informations sécurisées et confidentielles, la réinitialisation de votre mot de passe est un peu plus fastidieuse. diff --git a/config/locales/zh.yml b/config/locales/zh.yml index ea0185944b9..00a41aa73e8 100644 --- a/config/locales/zh.yml +++ b/config/locales/zh.yml @@ -64,7 +64,7 @@ account.index.continue_to_service_provider: 继续到 %{service_provider} account.index.default: 默认 account.index.device: 在 %{os}的 %{browser} account.index.email: 电邮地址 -account.index.email_add: 添加新电邮地址 +account.index.email_add: 添加新电邮 account.index.email_addresses: 电邮地址 account.index.email_preferences: 电邮选择 account.index.password: 密码 @@ -810,7 +810,7 @@ errors.messages.wrong_length.one: 长度不对(应当是 1 个字符) errors.messages.wrong_length.other: 长度不对(应当是 %{count} 个字符) errors.piv_cac_setup.unique_name: 这个名字已被使用。请选择一个不同的名字。 errors.registration.terms: 在你能继续之前,你必须授予我们你的同意。请在下面的框打勾然后点击继续。 -errors.sign_in.bad_password_limit: 你已超出登录尝试允许最多次数。你必须等待 %{time_left} 才能重试。 +errors.sign_in.sign_in_failure_limit: 你已超出登录尝试允许最多次数。你必须等待 %{time_left} 才能重试。 errors.two_factor_auth_setup.must_select_additional_option: 请选择一个额外的身份证实方法。 errors.two_factor_auth_setup.must_select_option: 选择一个身份证实方法。 errors.verify_personal_key.rate_limited: 你尝试了太多次。请在 %{timeout}后再试。 @@ -1236,7 +1236,7 @@ image_description.warning: 黄色警告标志 in_person_proofing.body.barcode.cancel_link_text: 取消你的条形码 in_person_proofing.body.barcode.close_window: 你现在可以关闭这一窗口。 in_person_proofing.body.barcode.deadline: 你必须在 %{deadline}之前去任何参与邮局。 -in_person_proofing.body.barcode.deadline_restart: 如果你在截止日期后才去邮局,你的信息不会被存储,你又得从头开始这一过程。 +in_person_proofing.body.barcode.deadline_restart: 如果你过了截止日期才去邮局,那你的条形码将无法使用。你将需要重新开始这一流程。 in_person_proofing.body.barcode.eipp_tag: GSA 增强型试行条形码 in_person_proofing.body.barcode.eipp_what_to_bring: 取决于您身份证件类型,您也许需要显示支持文件。请仔细阅读以下选项: in_person_proofing.body.barcode.email_sent: 我们已将条形码和以下信息发到了您用来登录的电邮地址 @@ -1246,11 +1246,11 @@ in_person_proofing.body.barcode.questions: 有问题吗? in_person_proofing.body.barcode.retail_hours: 营业时间 in_person_proofing.body.barcode.retail_hours_closed: 关闭 in_person_proofing.body.barcode.return_to_partner_link: 返回 %{sp_name} -in_person_proofing.body.barcode.what_to_expect: 在邮局会发生什么 +in_person_proofing.body.barcode.what_to_expect: 到了邮局做什么 in_person_proofing.body.cta.button: 尝试亲身去 in_person_proofing.body.cta.prompt_detail: 无法在网上完成这一步骤的大多数人都能在一个参与本项目的邮局成功地证实身份。去邮局无需预约。全国各地都有参与本项目的邮局。 in_person_proofing.body.expect.heading: 去邮局后会发生什么 -in_person_proofing.body.expect.info: 你去邮局后 24 小时内,我们会给你发电邮,告诉你是否成功验证了身份。 +in_person_proofing.body.expect.info: 你去邮局后24小时内会收到一封电邮。我们会告诉你你身份验证是否成功。检查你的电邮中是否有来自no-reply@login.gov的一则信息。 in_person_proofing.body.location.change_location_find_other_locations: 查找其他参与本项目的邮局地点。 in_person_proofing.body.location.change_location_heading: 需要更改你的邮局地点吗? in_person_proofing.body.location.change_location_info_html: 你不需要创建新的条形码,你可以将这个条形码带到任何参与本项目的邮局。 %{find_other_locations_link_html} @@ -1329,7 +1329,7 @@ in_person_proofing.form.state_id.state_id_number_hint_spaces: 空格 in_person_proofing.form.state_id.state_id_number_texas_hint: 这是你身份证件上的8位数在这一字段中只输入数字 in_person_proofing.form.state_id.zipcode: 邮编 in_person_proofing.headings.address: 输入你当前住宅地址 -in_person_proofing.headings.barcode: 到邮局出示这一条形码和你州政府颁发的身份证件来完成验证身份。 +in_person_proofing.headings.barcode: 到邮局出示这一条形码和你政府颁发的身份证件来完成验证身份。 in_person_proofing.headings.barcode_eipp: 携带该条形码和支持性文件到邮局去完成验证身份。 in_person_proofing.headings.barcode_what_to_bring: 到邮局要带什么 in_person_proofing.headings.cta: 尝试亲身去验证身份证件 @@ -1379,7 +1379,7 @@ in_person_proofing.process.state_id.heading_eipp: 出示你的身份证件以及 in_person_proofing.process.state_id.info: 该证件必须在有效期内。我们目前不接受任何其他形式的身份证件,比如护照和军队身份证件。 in_person_proofing.process.state_id.info_eipp: 邮局工作人员会扫描你的身份证件。该证件必须在有效期内。取决于您出示的身份证件类型,您也许需要显示支持文件。参见“到邮局要带什么”部分中的规定。 in_person_proofing.process.what_to_do.heading: 请排在任何一队里 -in_person_proofing.process.what_to_do.info: 告诉邮局工作人员你是为了%{app_name}验证你的身份。 +in_person_proofing.process.what_to_do.info: 告诉邮局工作人员你是来验证身份的。如果他们不知道如何处理,要求让一位主管来帮你。 instructions.account.reactivate.begin: 我们开始吧。 instructions.account.reactivate.explanation: 你设立账户时,我们给了你一个单词清单并请你将其存放在一个安全的地方。清单像这样: instructions.account.reactivate.intro: 我们采取额外步骤来保护你个人信息的安全和私密,所以重设密码需要多花点精力。 @@ -1666,7 +1666,7 @@ titles.reactivate_account: 重新激活你账户 titles.registrations.new: 设立账户 titles.revoke_consent: 撤销同意 titles.rules_of_use: 使用规则 -titles.select_email: 选择你比较愿意分享的电邮 +titles.select_email: 选择你想用的电邮 titles.sign_up.completion_consent_expired_ial1: 从你上次授权我们分享你的信息已经一年了。 titles.sign_up.completion_consent_expired_ial2: 从你上次授权我们分享你验证过的身份已经一年了。 titles.sign_up.completion_first_sign_in: 继续到 %{sp} diff --git a/db/primary_migrate/20250106232958_drop_proofing_components_table.rb b/db/primary_migrate/20250106232958_drop_proofing_components_table.rb new file mode 100644 index 00000000000..417d4f1d4bc --- /dev/null +++ b/db/primary_migrate/20250106232958_drop_proofing_components_table.rb @@ -0,0 +1,23 @@ +class DropProofingComponentsTable < ActiveRecord::Migration[7.2] + def change + drop_table :proofing_components do |t| + t.integer "user_id", null: false, comment: "sensitive=false" + t.string "document_check", comment: "sensitive=false" + t.string "document_type", comment: "sensitive=false" + t.string "source_check", comment: "sensitive=false" + t.string "resolution_check", comment: "sensitive=false" + t.string "address_check", comment: "sensitive=false" + t.datetime "verified_at", precision: nil, comment: "sensitive=false" + t.datetime "created_at", precision: nil, null: false, comment: "sensitive=false" + t.datetime "updated_at", precision: nil, null: false, comment: "sensitive=false" + t.string "liveness_check", comment: "sensitive=false" + t.string "device_fingerprinting_vendor", comment: "sensitive=false" + t.boolean "threatmetrix", comment: "sensitive=false" + t.string "threatmetrix_review_status", comment: "sensitive=false" + t.string "threatmetrix_risk_rating", comment: "sensitive=false" + t.string "threatmetrix_policy_score", comment: "sensitive=false" + t.index ["user_id"], name: "index_proofing_components_on_user_id", unique: true + t.index ["verified_at"], name: "index_proofing_components_on_verified_at" + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 135f2b0231c..376cc842b52 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2024_12_03_163014) do +ActiveRecord::Schema[7.2].define(version: 2025_01_06_232958) do # These are extensions that must be enabled in order to support this database enable_extension "citext" enable_extension "pg_stat_statements" @@ -471,26 +471,6 @@ t.index ["user_id"], name: "index_profiles_on_user_id" end - create_table "proofing_components", force: :cascade do |t| - t.integer "user_id", null: false, comment: "sensitive=false" - t.string "document_check", comment: "sensitive=false" - t.string "document_type", comment: "sensitive=false" - t.string "source_check", comment: "sensitive=false" - t.string "resolution_check", comment: "sensitive=false" - t.string "address_check", comment: "sensitive=false" - t.datetime "verified_at", precision: nil, comment: "sensitive=false" - t.datetime "created_at", precision: nil, null: false, comment: "sensitive=false" - t.datetime "updated_at", precision: nil, null: false, comment: "sensitive=false" - t.string "liveness_check", comment: "sensitive=false" - t.string "device_fingerprinting_vendor", comment: "sensitive=false" - t.boolean "threatmetrix", comment: "sensitive=false" - t.string "threatmetrix_review_status", comment: "sensitive=false" - t.string "threatmetrix_risk_rating", comment: "sensitive=false" - t.string "threatmetrix_policy_score", comment: "sensitive=false" - t.index ["user_id"], name: "index_proofing_components_on_user_id", unique: true - t.index ["verified_at"], name: "index_proofing_components_on_verified_at" - end - create_table "registration_logs", force: :cascade do |t| t.integer "user_id", null: false, comment: "sensitive=false" t.datetime "registered_at", precision: nil, comment: "sensitive=false" diff --git a/dockerfiles/nginx.Dockerfile b/dockerfiles/nginx.Dockerfile index c96304c1ffa..ad189fa8db1 100644 --- a/dockerfiles/nginx.Dockerfile +++ b/dockerfiles/nginx.Dockerfile @@ -1,4 +1,4 @@ -FROM public.ecr.aws/docker/library/alpine:3 +FROM public.ecr.aws/docker/library/alpine:3.20 RUN apk upgrade --no-cache RUN apk add --no-cache jq curl nginx nginx-mod-http-headers-more openssl diff --git a/lib/analytics_events_documenter.rb b/lib/analytics_events_documenter.rb index 6200eab1216..f14f1b794c1 100644 --- a/lib/analytics_events_documenter.rb +++ b/lib/analytics_events_documenter.rb @@ -129,6 +129,16 @@ def missing_documentation errors << "#{error_prefix} #{tag.name} missing types" if !tag.types end + description = method_description(method_object) + if description.present? && !method_object.docstring.match?(/\A[A-Z]/) + indented_description = description.lines.map { |line| " #{line.chomp}" }.join("\n") + + errors << <<~MSG + #{error_prefix} method description starts with lowercase, check indentation: + #{indented_description} + MSG + end + errors end end @@ -156,7 +166,7 @@ def as_json { event_name: extract_event_name(method_object), previous_event_names: method_object.tags(PREVIOUS_EVENT_NAME_TAG).map(&:text), - description: method_object.docstring.presence, + description: method_description(method_object), attributes: attributes, method_name: method_object.name, source_line: method_object.line, @@ -168,6 +178,12 @@ def as_json { events: events_json_summary } end + # Strips Rubocop directives from description text + # @return [String, nil] + def method_description(method_object) + method_object.docstring.to_s.gsub(/^rubocop.+$/, '').presence&.chomp + end + private # Naive attempt to pull tracked event string or symbol from source code diff --git a/lib/event_summarizer/account_deletion_matcher.rb b/lib/event_summarizer/account_deletion_matcher.rb new file mode 100644 index 00000000000..ec21f689cce --- /dev/null +++ b/lib/event_summarizer/account_deletion_matcher.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module EventSummarizer + class AccountDeletionMatcher + ACCOUNT_DELETION_STARTED_EVENT = 'Account Reset: request' + ACCOUNT_DELETION_SUBMITTED_EVENT = 'Account Reset: delete' + ACCOUNT_DELETION_CANCELED_EVENT = 'Account Reset: cancel' + + EVENT_PROPERTIES = ['@message', 'properties', 'event_properties'].freeze + + attr_accessor :account_deletion_events, :event_summaries + + def initialize + @account_deletion_events = Array.new + @event_summaries = Array.new + account_deletion_events + end + + def handle_cloudwatch_event(event) + case event['name'] + when ACCOUNT_DELETION_STARTED_EVENT + process_account_reset_request(event) + when ACCOUNT_DELETION_SUBMITTED_EVENT + process_account_reset_delete(event) + when ACCOUNT_DELETION_CANCELED_EVENT + process_account_reset_cancel(event) + end + end + + def finish + event_summaries + end + + private + + def process_account_reset_request(event) + event_message = { + title: 'Account deletion Request', + attributes: [ + { type: :account_deletion_request, + description: "On #{event["@timestamp"]} user initiated account deletion" }, + ], + } + event_summaries.push(event_message) + end + + def process_account_reset_cancel(event) + event_message = { + title: 'Account deletion cancelled', + attributes: [ + { type: :account_deletion_cancelled, + description: "On #{event["@timestamp"]} user canceled account deletion" }, + ], + } + event_summaries.push(event_message) + end + + def process_account_reset_delete(event) + message = event['@message'] + age = message['properties']['event_properties']['account_age_in_days'] + date = event['@timestamp'] + event_message = { + title: 'Account deleted', + attributes: [ + { + type: :account_deleted, + description: "On #{date} user deleted their account which was #{age} days old", + }, + ], + } + event_summaries.push(event_message) + end + end +end diff --git a/lib/event_summarizer/example_matcher.rb b/lib/event_summarizer/example_matcher.rb new file mode 100644 index 00000000000..b7807ec47f0 --- /dev/null +++ b/lib/event_summarizer/example_matcher.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module EventSummarizer + class ExampleMatcher + attr_reader :event_count + + def initialize + @event_count = 0 + end + + def handle_cloudwatch_event(_event) + @event_count += 1 + end + + def finish + [ + { + title: 'Processed some events', + attributes: [ + { type: :event_count, description: "Processed #{event_count} event(s)" }, + ], + }.tap do + @event_count = 0 + end, + ] + end + end +end diff --git a/lib/event_summarizer/idv_matcher.rb b/lib/event_summarizer/idv_matcher.rb new file mode 100644 index 00000000000..fdade426166 --- /dev/null +++ b/lib/event_summarizer/idv_matcher.rb @@ -0,0 +1,538 @@ +# frozen_string_literal: true + +require 'active_support' +require 'active_support/time' + +require 'event_summarizer/vendor_result_evaluators/aamva' +require 'event_summarizer/vendor_result_evaluators/instant_verify' +require 'event_summarizer/vendor_result_evaluators/true_id' + +module EventSummarizer + class IdvMatcher + IDV_WELCOME_SUBMITTED_EVENT = 'IdV: doc auth welcome submitted' + IDV_GPO_CODE_SUBMITTED_EVENT = 'IdV: enter verify by mail code submitted' + IDV_FINAL_RESOLUTION_EVENT = 'IdV: final resolution' + IDV_IMAGE_UPLOAD_VENDOR_SUBMITTED_EVENT = 'IdV: doc auth image upload vendor submitted' + IDV_VERIFY_PROOFING_RESULTS_EVENT = 'IdV: doc auth verify proofing results' + IPP_ENROLLMENT_STATUS_UPDATED_EVENT = 'GetUspsProofingResultsJob: Enrollment status updated' + PROFILE_ENCRYPTION_INVALID_EVENT = 'Profile Encryption: Invalid' + RATE_LIMIT_REACHED_EVENT = 'Rate Limit Reached' + + EVENT_PROPERTIES = ['@message', 'properties', 'event_properties'].freeze + + VENDORS = { + 'TrueID' => { + id: :trueid, + name: 'True ID', + evaluator_module: EventSummarizer::VendorResultEvaluators::TrueId, + }, + 'lexisnexis:instant_verify' => { + id: :instant_verify, + name: 'Instant Verify', + evaluator_module: EventSummarizer::VendorResultEvaluators::InstantVerify, + }, + 'aamva:state_id' => { + id: :aamva, + name: 'AAMVA', + evaluator_module: EventSummarizer::VendorResultEvaluators::Aamva, + }, + }.freeze + + UNKNOWN_VENDOR = { + id: :unknown, + name: 'Unknown vendor', + }.freeze + + IdvAttempt = Data.define( + :started_at, + :significant_events, + ) do + def initialize(started_at:, significant_events: []) + super(started_at:, significant_events:) + end + + def flagged_for_fraud? + self.significant_events.any? { |e| e.type == :flagged_for_fraud } + end + + def gpo? + self.significant_events.any? { |e| e.type == :start_gpo } + end + + def gpo_pending? + gpo? && !self.significant_events.any? { |e| e.type == :gpo_code_success } + end + + def ipp? + self.significant_events.any? { |e| e.type == :start_ipp } + end + + def ipp_pending? + ipp? && !self.significant_events.any? { |e| e.type == :ipp_enrollment_complete } + end + + def successful? + self.significant_events.any? do |e| + e.type == :verified + end + end + + def workflow_complete? + gpo? || ipp? || flagged_for_fraud? || successful? + end + end.freeze + + SignificantIdvEvent = Data.define( + :timestamp, + :type, + :description, + ).freeze + + attr_reader :current_idv_attempt + attr_reader :idv_attempts + attr_reader :idv_abandoned_event + + def initialize + @idv_attempts = [] + @current_idv_attempt = nil + end + + # @return {Hash,nil} + def handle_cloudwatch_event(event) + case event['name'] + when IDV_WELCOME_SUBMITTED_EVENT + start_new_idv_attempt(event:) + + when IDV_FINAL_RESOLUTION_EVENT + + for_current_idv_attempt(event:) do + handle_final_resolution_event(event:) + end + + when IDV_GPO_CODE_SUBMITTED_EVENT + for_current_idv_attempt(event:) do + handle_gpo_code_submission(event:) + end + + when IPP_ENROLLMENT_STATUS_UPDATED_EVENT + for_current_idv_attempt(event:) do + handle_ipp_enrollment_status_update(event:) + end + + when IDV_IMAGE_UPLOAD_VENDOR_SUBMITTED_EVENT + for_current_idv_attempt(event:) do + handle_image_upload_vendor_submitted(event:) + end + + when IDV_VERIFY_PROOFING_RESULTS_EVENT + for_current_idv_attempt(event:) do + handle_verify_proofing_results_event(event:) + end + + when PROFILE_ENCRYPTION_INVALID_EVENT + for_current_idv_attempt(event:) do + handle_profile_encryption_error(event:) + end + + when RATE_LIMIT_REACHED_EVENT + handle_rate_limit_reached(event:) + + else + if ENV['LOG_UNHANDLED_EVENTS'] + warn "#{event['@timestamp']} #{event['name']}" + end + end + + check_for_idv_abandonment(event) + end + + def finish + finish_current_idv_attempt + + self.idv_attempts.map { |a| summarize_idv_attempt(a) }.tap do + idv_attempts.clear + end + end + + private + + def add_significant_event( + timestamp:, + type:, + description: + ) + current_idv_attempt.significant_events << SignificantIdvEvent.new( + timestamp:, + type:, + description:, + ) + end + + def check_for_idv_abandonment(event) + return if current_idv_attempt.nil? + + looks_like_idv = /^idv/i.match(event['name']) + return if !looks_like_idv + + if idv_abandoned_event.nil? + @idv_abandoned_event = event + return + end + + is_a_little_bit_newer = event['@timestamp'] - idv_abandoned_event['@timestamp'] < 1.hour + + if is_a_little_bit_newer + @idv_abandoned_event = event + end + end + + def for_current_idv_attempt(event:, &block) + if !current_idv_attempt + warn <<~WARNING + Encountered #{event['name']} without seeing a '#{IDV_WELCOME_SUBMITTED_EVENT}' event first. + This could indicate you need to include earlier events in your request. + WARNING + return + end + + block.call(event) + end + + def finish_current_idv_attempt + if !current_idv_attempt.nil? + looks_like_abandonment = + !current_idv_attempt.workflow_complete? && + !idv_abandoned_event.nil? && + idv_abandoned_event['@timestamp'] < 1.hour.ago + + if looks_like_abandonment + add_significant_event( + type: :idv_abandoned, + timestamp: idv_abandoned_event['@timestamp'], + description: 'User abandoned identity verification', + ) + end + end + + idv_attempts << current_idv_attempt if current_idv_attempt + @current_idv_attempt = nil + @idv_abandoned_event = nil + end + + # @return {Hash,nil} + def handle_final_resolution_event(event:) + timestamp = event['@timestamp'] + + gpo_pending = !!event.dig( + *EVENT_PROPERTIES, + 'gpo_verification_pending', + ) + + if gpo_pending + add_significant_event( + type: :start_gpo, + timestamp:, + description: 'User requested a letter to verify by mail', + ) + end + + ipp_pending = !!event.dig( + *EVENT_PROPERTIES, + 'in_person_verification_pending', + ) + + if ipp_pending + add_significant_event( + type: :start_ipp, + timestamp:, + descirption: 'User entered the in-person proofing flow', + ) + end + + fraud_review_pending = !!event.dig( + *EVENT_PROPERTIES, + 'fraud_review_pending', + ) + + if fraud_review_pending + add_significant_event( + type: :flagged_for_fraud, + timestamp:, + description: 'User was flagged for fraud', + ) + end + + pending = + gpo_pending || + ipp_pending || + fraud_review_pending + + if !pending + add_significant_event( + type: :verified, + timestamp:, + description: 'User completed identity verification (remote unsupervised flow)', + ) + + finish_current_idv_attempt + end + end + + def handle_gpo_code_submission(event:) + timestamp = event['@timestamp'] + success = event.dig(*EVENT_PROPERTIES, 'success') + + if !success + add_significant_event( + type: :gpo_code_failure, + timestamp:, + description: 'The user entered an invalid GPO code', + ) + return + end + + # User successfully entered GPO code. If fraud review is not pending, + # then they are fully verified + fraud_review_pending = !!event.dig( + *EVENT_PROPERTIES, + 'fraud_check_failed', + ) + + fully_verified = !fraud_review_pending + + add_significant_event( + type: :gpo_code_success, + timestamp:, + description:, + ) + + if fully_verified + add_significant_event( + type: :verified, + timestamp:, + description: 'User completed identity verification', + ) + + finish_current_idv_attempt + end + end + + def handle_ipp_enrollment_status_update(event:) + timestamp = event['@timestamp'] + passed = event.dig(*EVENT_PROPERTIES, 'passed') + tmx_status = event.dig(*EVENT_PROPERTIES, 'tmx_status') + + return if !passed + + add_significant_event( + type: :ipp_enrollment_complete, + timestamp:, + description: 'User visited the post office and completed IPP enrollment', + ) + + verified = tmx_status != 'review' && tmx_status != 'reject' + + if verified + current_idv_attempt.event << SignificantIdvEvent.new( + type: :verified, + timestamp:, + description: 'User is fully verified', + ) + end + end + + def handle_profile_encryption_error(event:) + caveats = [ + # TODO these need to check if GPO/IPP were still pending at time of the event + current_idv_attempt.gpo? ? 'The user will not be able to enter a GPO code' : nil, + current_idv_attempt.ipp? ? 'the user will not be able to verify in-person' : nil, + ].compact + + add_significant_event( + type: :password_reset, + timestamp: event['@timestamp'], + description: [ + 'The user reset their password and did not provide their personal key.', + caveats.length > 0 ? + "The user will not be able to #{caveats.join(' or ')}" : + nil, + ].compact.join(' '), + ) + end + + def handle_rate_limit_reached(event:) + limiters = { + 'idv_doc_auth' => 'Doc Auth', + } + + limiter_type = event.dig(*EVENT_PROPERTIES, 'limiter_type') + + limit_name = limiters[limiter_type] + + return if limit_name.blank? + + timestamp = event['@timestamp'] + + for_current_idv_attempt(event:) do + add_significant_event( + type: :rate_limited, + timestamp:, + description: "Rate limited for #{limit_name}", + ) + end + end + + def handle_image_upload_vendor_submitted(event:) + timestamp = event['@timestamp'] + success = event.dig(*EVENT_PROPERTIES, 'success') + doc_type = event.dig(*EVENT_PROPERTIES, 'DocClassName') + + if success + prior_failures = current_idv_attempt.significant_events.count do |e| + e.type == :failed_document_capture + end + attempts = prior_failures > 0 ? "after #{prior_failures} tries" : 'on the first attempt' + + add_significant_event( + timestamp:, + type: :passed_document_capture, + description: "User successfully verified their #{doc_type.downcase} #{attempts}", + ) + return + end + + prev_count = current_idv_attempt.significant_events.count + + alerts = event.dig(*EVENT_PROPERTIES, 'processed_alerts') + alerts['success'] = false + alerts['vendor_name'] = event.dig(*EVENT_PROPERTIES, 'vendor') + + add_events_for_failed_vendor_result( + alerts, + timestamp:, + ) + + any_events_added = current_idv_attempt.significant_events.count > prev_count + + if !any_events_added + add_significant_event( + timestamp:, + type: :failed_document_capture, + description: "User failed to verify their #{doc_type.downcase} (check logs for reason)", + ) + end + end + + def handle_verify_proofing_results_event(event:) + timestamp = event['@timestamp'] + success = event.dig(*EVENT_PROPERTIES, 'success') + + if success + # We only really care about passing identity resolution if the + # user previously failed in this attempt + + prior_failures = current_idv_attempt.significant_events.count do |e| + e.type == :failed_identity_resolution + end + + if prior_failures > 0 + # TODO: What changed that made them be able to pass? + + add_significant_event( + timestamp:, + type: :passed_identity_resolution, + description: "User passed identity resolution after #{prior_failures + 1} tries", + ) + end + + return + end + + # Failing identity resolution is where it gets interesting + + prev_count = current_idv_attempt.significant_events.count + + add_events_for_failed_vendor_result( + event.dig( + *EVENT_PROPERTIES, 'proofing_results', 'context', 'stages', 'resolution' + ), + timestamp:, + ) + + add_events_for_failed_vendor_result( + event.dig( + *EVENT_PROPERTIES, 'proofing_results', 'context', 'stages', 'residential_address' + ), + timestamp:, + ) + + add_events_for_failed_vendor_result( + event.dig( + *EVENT_PROPERTIES, 'proofing_results', 'context', 'stages', 'state_id' + ), + timestamp:, + ) + + any_events_added = current_idv_attempt.significant_events.count > prev_count + + if !any_events_added + add_significant_event( + timestamp:, + type: :failed_identity_resolution, + description: 'User failed identity resolution (check logs for reason)', + ) + end + end + + def add_events_for_failed_vendor_result(result, timestamp:) + return if result['success'] + + vendor = VENDORS[result['vendor_name']] || UNKNOWN_VENDOR + evaluator = vendor[:evaluator_module] + + if !evaluator.present? + add_significant_event( + type: :"#{vendor[:id]}_request_failed", + timestamp:, + description: "Request to #{vendor[:name]} failed.", + ) + return + end + + evaluation = evaluator.evaluate_result(result) + add_significant_event(**evaluation, timestamp:) if evaluation + end + + # @return {IdvAttempt,nil} The previous IdvAttempt (if any) + def start_new_idv_attempt(event:) + finish_current_idv_attempt if current_idv_attempt + + @current_idv_attempt = IdvAttempt.new( + started_at: event['@timestamp'], + ) + end + + def summarize_idv_attempt(attempt) + type = :idv + title = 'Identity verification started' + attributes = attempt.significant_events.map do |e| + { + type: e.type, + timestamp: e.timestamp, + description: e.description, + } + end + + if attempt.successful? + title = 'Identity verified' + end + + { + started_at: attempt.started_at, + title:, + type:, + attributes:, + } + end + end +end diff --git a/lib/event_summarizer/vendor_result_evaluators/aamva.rb b/lib/event_summarizer/vendor_result_evaluators/aamva.rb new file mode 100644 index 00000000000..1929b8acd9f --- /dev/null +++ b/lib/event_summarizer/vendor_result_evaluators/aamva.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +module EventSummarizer + module VendorResultEvaluators + module Aamva + ID_TYPES = { + 'state_id_card' => 'non-driving ID card', + 'drivers_license' => "drivers' license", + }.freeze + + # TODO: Load these from the AAMVA proofer or put them somewhere common + + REQUIRED_VERIFICATION_ATTRIBUTES = %i[ + state_id_number + dob + last_name + first_name + ].freeze + + REQUIRED_IF_PRESENT_ATTRIBUTES = [:state_id_expiration].freeze + + # @param result {Hash} The result structure logged to Cloudwatch + # @return [Hash] A Hash with a type, timestamp, and description key. + def self.evaluate_result(result) + if result['success'] + return { + type: :aamva_success, + description: 'AAMVA call succeeded', + } + end + + if result['timed_out'] + return { + type: :aamva_timed_out, + description: 'AAMVA request timed out.', + } + end + + if result['mva_exception'] + state = result['state_id_jurisdiction'] + return { + type: :aamva_mva_exception, + # rubocop:disable Layout/LineLength + description: "AAMVA request failed because the MVA in #{state} failed to return a response.", + # rubocop:enable Layout/LineLength + } + end + + if result['exception'] + + description = 'AAMVA request resulted in an exception' + + m = /ExceptionText: (.+?),/.match(result['exception']) + if m.present? + description = "#{description} (#{m[1]})" + end + + return { + type: :aamva_exception, + description:, + } + end + + # The API call failed because of actual errors in the user's data. + # Try to come up with an explanation + + explanation = explain_errors(result) || 'Check logs for more info.' + + return { + type: :aamva_error, + description: "AAMVA request failed. #{explanation}", + } + end + + def self.explain_errors(result) + # The values in the errors object are arrays + attributes = {} + result['errors'].each do |key, values| + attributes[key] = values.first + end + + id_type = ID_TYPES[result['state_id_type']] || 'id card' + state = result['state_id_jurisdiction'] + + if mva_says_invalid_id_number?(attributes) + # rubocop:disable Layout/LineLength + return "The ID # from the user's #{id_type} was invalid according to the state of #{state}" + # rubocop:enable Layout/LineLength + end + + failed_attributes = relevant_failed_attributes(attributes) + + if !failed_attributes.empty? + plural = failed_attributes.length == 1 ? '' : 's' + + # rubocop:disable Layout/LineLength + "#{failed_attributes.length} attribute#{plural} failed to validate: #{failed_attributes.join(', ')}" + # rubocop:enable Layout/LineLength + end + end + + def self.mva_says_invalid_id_number?(attributes) + # When all attributes are marked "MISSING", except ID number, + # which is marked "UNVERIFIED", that indicates the MVA could not + # find the ID number to compare PII + + missing_count = attributes.count do |_attr, status| + status == 'MISSING' + end + + attributes['state_id_number'] == 'UNVERIFIED' && missing_count == attributes.count - 1 + end + + def self.relevant_failed_attributes(attributes) + failed_attributes = Set.new + + REQUIRED_VERIFICATION_ATTRIBUTES.each do |attr| + failed_attributes << attr if attributes[attr] != 'VERIFIED' + end + + REQUIRED_IF_PRESENT_ATTRIBUTES.each do |attr| + failed_attributes << attr if attributes[attr].present? && attributes[attr] != 'VERIFIED' + end + + failed_attributes + end + end + end +end diff --git a/lib/event_summarizer/vendor_result_evaluators/instant_verify.rb b/lib/event_summarizer/vendor_result_evaluators/instant_verify.rb new file mode 100644 index 00000000000..e8d7941ee0e --- /dev/null +++ b/lib/event_summarizer/vendor_result_evaluators/instant_verify.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'active_support/core_ext/string/inflections' + +module EventSummarizer + module VendorResultEvaluators + module InstantVerify + # @param result {Hash} The result structure logged to Cloudwatch + # @return [Hash] A Hash with a type and description keys. + def self.evaluate_result(result) + if result['success'] + { + type: :instant_verify_success, + description: 'Instant Verify call succeeded', + } + elsif result['timed_out'] + { + type: :instant_verify_timed_out, + description: 'Instant Verify request timed out.', + } + elsif result['exception'] + { + type: :instant_verify_exception, + description: 'Instant Verify request resulted in an exception', + } + else + # The API call failed because of actual errors in the user's data. + # Try to come up with an explanation + + explanation = explain_errors(result) || 'Check logs for more info.' + + { + type: :instant_verify_error, + description: "Instant Verify request failed. #{explanation}", + } + end + end + + # Attempts to render a legible explanation of what went wrong in a + # LexisNexis Instant Verify request. + # @param result {Hash} The result structure logged to Cloudwatch + # @return {String} + def self.explain_errors(result) + # (The structure of the 'errors' key for Instant Verify is kind of weird) + + failed_items = [] + + result.dig('errors', 'InstantVerify')&.each do |iv_instance| + next if iv_instance['ProductStatus'] != 'fail' + iv_instance['Items'].each do |item| + if item['ItemStatus'] == 'fail' + failed_items << item + end + end + end + + if failed_items.empty? + return 'Check the full logs for more info.' + end + + checks = failed_items.map do |item| + name = item['ItemName'] + reason = item['ItemReason'] + reason_code = reason ? reason['Code'] : nil + + if reason_code + # TODO: Translate these reason codes to plain language + # TODO: Add suggestions for how the user could remedy + "#{name} (#{reason_code})" + else + name + end + end + + "#{checks.length} #{'check'.pluralize(checks.length)} failed: #{checks.join(", ")}" + end + end + end +end diff --git a/lib/event_summarizer/vendor_result_evaluators/true_id.rb b/lib/event_summarizer/vendor_result_evaluators/true_id.rb new file mode 100644 index 00000000000..f7b59694974 --- /dev/null +++ b/lib/event_summarizer/vendor_result_evaluators/true_id.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module EventSummarizer + module VendorResultEvaluators + module TrueId + # @param result {Hash} The array of processed_alerts.failed logged to Cloudwatch + # @return [Hash] A Hash with a type and description keys. + def self.evaluate_result(result) + alerts = [] + result['failed'].each do |alert| + if alert['result'] == 'Failed' + alerts << { + type: :"trueid_#{alert['name'].parameterize(separator: '_')}", + description: alert['disposition'], + } + end + end + + if alerts.present? + alerts.uniq! { |a| a[:description] } + return { + type: :trueid_failures, + description: "TrueID request failed. #{alerts.map { |a| a[:description] }.join(' ')}", + } + end + end + end + end +end diff --git a/lib/identity_config.rb b/lib/identity_config.rb index 9ea782ad0d8..5dcc7b7556d 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -148,6 +148,7 @@ def self.store config.add(:enable_rate_limiting, type: :boolean) config.add(:enable_test_routes, type: :boolean) config.add(:enable_usps_verification, type: :boolean) + config.add(:encrypted_document_storage_s3_bucket, type: :string) config.add(:event_disavowal_expiration_hours, type: :integer) config.add(:facial_match_general_availability_enabled, type: :boolean) config.add(:feature_idv_force_gpo_verification_enabled, type: :boolean) @@ -194,6 +195,7 @@ def self.store config.add(:idv_send_link_max_attempts, type: :integer) config.add(:idv_socure_reason_code_download_enabled, type: :boolean) config.add(:idv_socure_shadow_mode_enabled, type: :boolean) + config.add(:idv_socure_shadow_mode_enabled_for_docv_users, type: :boolean) config.add(:idv_sp_required, type: :boolean) config.add(:in_person_completion_survey_url, type: :string) config.add(:in_person_doc_auth_button_enabled, type: :boolean) @@ -265,8 +267,8 @@ def self.store config.add(:logo_upload_enabled, type: :boolean) config.add(:mailer_domain_name) config.add(:max_auth_apps_per_account, type: :integer) - config.add(:max_bad_passwords, type: :integer) - config.add(:max_bad_passwords_window_in_seconds, type: :integer) + config.add(:max_sign_in_failures, type: :integer) + config.add(:max_sign_in_failures_window_in_seconds, type: :integer) config.add(:max_emails_per_account, type: :integer) config.add(:max_mail_events, type: :integer) config.add(:max_mail_events_window_in_days, type: :integer) diff --git a/package.json b/package.json index 74a0f354452..ebb73b0c461 100644 --- a/package.json +++ b/package.json @@ -92,9 +92,5 @@ "swr": "^2.0.0", "typescript": "^5.7.2", "yarn-deduplicate": "^6.0.2" - }, - "resolutions": { - "minimist": "1.2.6", - "postcss": "8.4.31" } } diff --git a/spec/bin/summarize-user-events_spec.rb b/spec/bin/summarize-user-events_spec.rb new file mode 100644 index 00000000000..9923123c849 --- /dev/null +++ b/spec/bin/summarize-user-events_spec.rb @@ -0,0 +1,187 @@ +require 'rails_helper' +load Rails.root.join('bin/summarize-user-events') + +RSpec.describe SummarizeUserEvents do + let(:user_uuid) { nil } + let(:start_time) { nil } + let(:end_time) { nil } + let(:zone) { 'America/New_York' } + let(:stdout) { StringIO.new } + let(:stderr) { StringIO.new } + + subject(:instance) do + described_class.new( + file_name: nil, + user_uuid:, + start_time:, + end_time:, + zone:, + stdout:, + stderr:, + ) + end + + describe '#normalize_event!' do + let(:event) do + { + 'name' => 'test event', + '@timestamp' => '2024-12-31 21:21:47.374', + '@message' => { + '@timestamp' => '2024-12-31 21:21:47.374', + 'name' => 'test event', + }, + } + end + + subject(:normalized_event) do + event.dup.tap do |event| + instance.normalize_event!(event) + end + end + + context 'when @message is a string' do + let(:event) do + super().tap do |event| + event['@message'] = JSON.generate(event['@message']) + end + end + it 'parses @message as JSON' do + expect(event['@message']).to be_a(String) + expect(normalized_event['@message']).to eql( + '@timestamp' => '2024-12-31 21:21:47.374', + 'name' => 'test event', + ) + end + end + + context 'when @timestamp is a string' do + it 'parses it in UTC' do + expected = Time.zone.parse('2024-12-31 21:21:47.374 UTC') + Time.use_zone('America/Los_Angeles') do + expect(normalized_event['@timestamp']).to eql(expected) + end + end + end + end + + describe '#parse_command_line_options' do + let(:argv) do + [] + end + + subject(:parsed) do + described_class.parse_command_line_options(argv) + end + + it 'parses default options' do + expect(parsed).to eql( + { zone: 'America/New_York' }, + ) + end + + context '-z' do + let(:argv) { ['-z', 'America/Los_Angeles'] } + + it 'parses zone' do + expect(parsed).to eql( + { + zone: 'America/Los_Angeles', + }, + ) + end + end + end + + describe '#parse_time' do + let(:input) { nil } + + subject(:actual) do + instance.parse_time(input) + end + + context 'with valid UTC timestamp' do + let(:input) do + '2025-01-07T19:56:03Z' + end + it 'parses it as UTC, then converts to configured zone' do + expect(actual.to_s).to eql( + '2025-01-07 14:56:03 -0500', + ) + end + end + + context 'with a timestamp with no zone specified' do + let(:input) do + '2025-01-07T19:56:03' + end + it 'parses it as UTC, then converts to configured zone' do + expect(actual.to_s).to eql( + '2025-01-07 14:56:03 -0500', + ) + end + end + + context 'with a timestamp with a different zone specified' do + let(:input) do + '2025-01-07T19:56:03 -0600' + end + it 'parses it as UTC, then converts to configured zone' do + expect(actual.to_s).to eql( + '2025-01-07 20:56:03 -0500', + ) + end + end + + context 'with an invalid time value' do + let(:input) { 'not even a time' } + it 'returns nil' do + expect(actual).to eql(nil) + end + end + + context 'with blank string' do + let(:input) { '' } + it 'returns nil' do + expect(actual).to eql(nil) + end + end + end + + describe '#run' do + subject(:command_output) do + instance.run + stdout.string + end + + let(:cloudwatch_events) do + [ + { + '@timestamp' => '2024-12-30 15:42:51.336', + '@message' => JSON.generate( + { + name: 'IdV: doc auth welcome submitted', + }, + ), + }, + ] + end + + before do + allow(instance).to receive(:cloudwatch_source) do |&block| + cloudwatch_events.each do |raw_event| + block.call(raw_event) + end + end + end + + it 'matches expected output' do + expect(command_output).to eql(<<~END) + ## Processed some events + * Processed 1 event(s) + + ## Identity verification started (December 30, 2024 at 10:42 AM EST) + * (10:42 AM) User abandoned identity verification + END + end + end +end diff --git a/spec/config/initializers/ab_tests_spec.rb b/spec/config/initializers/ab_tests_spec.rb index b6b47801ade..b2fe27b55c5 100644 --- a/spec/config/initializers/ab_tests_spec.rb +++ b/spec/config/initializers/ab_tests_spec.rb @@ -53,17 +53,21 @@ }, } end + it 'returns a bucket' do expect(bucket).not_to be_nil end end + context 'and the user does not have an Idv::Session' do let(:user_session) do {} end + it 'does not return a bucket' do expect(bucket).to be_nil end + it 'does not write :idv key in user_session' do expect { bucket }.not_to change { user_session } end @@ -75,6 +79,7 @@ let(:session) do { document_capture_session_uuid: 'a-random-uuid' } end + it 'returns a bucket' do expect(bucket).not_to be_nil end @@ -90,6 +95,7 @@ context 'when A/B test is disabled and it would otherwise assign a bucket' do let(:user) { build(:user) } + let(:user_session) do { idv: { @@ -102,6 +108,7 @@ disable_ab_test.call reload_ab_tests end + it 'does not assign a bucket' do expect(bucket).to be_nil end @@ -260,11 +267,11 @@ end end - describe 'SOCURE_IDV_SHADOW_MODE' do + describe 'SOCURE_IDV_SHADOW_MODE_FOR_NON_DOCV_USERS' do let(:user) { create(:user) } subject(:bucket) do - AbTests::SOCURE_IDV_SHADOW_MODE.bucket( + AbTests::SOCURE_IDV_SHADOW_MODE_FOR_NON_DOCV_USERS.bucket( request: nil, service_provider: nil, session: nil, @@ -295,7 +302,7 @@ end it 'returns a bucket' do - expect(bucket).to eq :shadow_mode_enabled + expect(bucket).to eq :socure_shadow_mode_for_non_docv_users end end end diff --git a/spec/controllers/idv/by_mail/enter_code_controller_spec.rb b/spec/controllers/idv/by_mail/enter_code_controller_spec.rb index 277187e96cc..168558f6752 100644 --- a/spec/controllers/idv/by_mail/enter_code_controller_spec.rb +++ b/spec/controllers/idv/by_mail/enter_code_controller_spec.rb @@ -181,8 +181,11 @@ let(:user) { create(:user, :with_pending_gpo_profile, created_at: 2.days.ago) } let!(:pending_profile) { user.gpo_verification_pending_profile } + let(:initiating_service_provider) { create(:service_provider) } let(:success) { true } + before { pending_profile.update!(initiating_service_provider:) } + it 'uses the PII from the pending profile' do # action will make the profile active, so grab the ID here. pending_profile_id = pending_profile.id @@ -202,6 +205,7 @@ fraud_check_failed: false, enqueued_at: pending_profile.gpo_confirmation_codes.last.code_sent_at, profile_age_in_seconds: instance_of(Integer), + initiating_service_provider: initiating_service_provider.issuer, which_letter: 1, letter_count: 1, submit_attempts: 1, @@ -247,6 +251,7 @@ fraud_check_failed: false, enqueued_at: pending_profile.gpo_confirmation_codes.last.code_sent_at, profile_age_in_seconds: instance_of(Integer), + initiating_service_provider: initiating_service_provider.issuer, which_letter: 1, letter_count: 1, submit_attempts: 1, @@ -276,6 +281,7 @@ fraud_check_failed: true, enqueued_at: pending_profile.gpo_confirmation_codes.last.code_sent_at, profile_age_in_seconds: instance_of(Integer), + initiating_service_provider: initiating_service_provider.issuer, which_letter: 1, letter_count: 1, submit_attempts: 1, @@ -313,6 +319,7 @@ fraud_check_failed: true, enqueued_at: user.pending_profile.gpo_confirmation_codes.last.code_sent_at, profile_age_in_seconds: instance_of(Integer), + initiating_service_provider: initiating_service_provider.issuer, which_letter: 1, letter_count: 1, submit_attempts: 1, @@ -355,6 +362,7 @@ fraud_check_failed: true, enqueued_at: user.pending_profile.gpo_confirmation_codes.last.code_sent_at, profile_age_in_seconds: instance_of(Integer), + initiating_service_provider: initiating_service_provider.issuer, which_letter: 1, letter_count: 1, submit_attempts: 1, diff --git a/spec/controllers/idv/in_person/verify_info_controller_spec.rb b/spec/controllers/idv/in_person/verify_info_controller_spec.rb index 0d190f44107..7ba8e8e4ae5 100644 --- a/spec/controllers/idv/in_person/verify_info_controller_spec.rb +++ b/spec/controllers/idv/in_person/verify_info_controller_spec.rb @@ -349,6 +349,7 @@ user_id: anything, request_ip: request.remote_ip, ipp_enrollment_in_progress: false, + proofing_components: Idv::ProofingComponents, ) put :update @@ -364,6 +365,7 @@ user_id: anything, request_ip: anything, ipp_enrollment_in_progress: true, + proofing_components: Idv::ProofingComponents, ) put :update @@ -392,6 +394,7 @@ user_id: anything, request_ip: request.remote_ip, ipp_enrollment_in_progress: true, + proofing_components: Idv::ProofingComponents, ) put :update diff --git a/spec/controllers/saml_idp_controller_spec.rb b/spec/controllers/saml_idp_controller_spec.rb index 1e23ba2b050..7d5a2b5a49d 100644 --- a/spec/controllers/saml_idp_controller_spec.rb +++ b/spec/controllers/saml_idp_controller_spec.rb @@ -1694,93 +1694,66 @@ def name_id_version(format_urn) ) end - context 'when request is using SHA1 as the signature method algorithm' do + context 'when request is using SHA1 as the digest method algorithm' do let(:auth_settings) do saml_settings( overrides: { security: { authn_requests_signed:, - signature_method: 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha1', + digest_method: 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha1', + signature_method:, }, }, ) end - context 'when the certificate matches' do - it 'does not note that certs are different in the event' do + context 'when request is using SHA256 as the signature method algorithm' do + let(:signature_method) do + 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha1' + end + + it 'notes an error in the event' do user.identities.last.update!(verified_attributes: ['email']) generate_saml_response(user, auth_settings) expect(response.status).to eq(200) expect(@analytics).to have_logged_event( - 'SAML Auth', hash_not_including( - certs_different: true, - sha256_matching_cert: matching_cert_serial, + 'SAML Auth', hash_including( + request_signed: authn_requests_signed, + cert_error_details: [ + { + cert: '16692258094164984098', + error_code: :wrong_sig_algorithm, + }, + { + cert: '14834808178619537243', + error_code: :request_cert_not_registered, + }, + ], ) ) end end - context 'when the certificate does not match' do - let(:wrong_cert) do - OpenSSL::X509::Certificate.new( - Rails.root.join('certs', 'sp', 'saml_test_sp2.crt').read, - ) - end - - before do - service_provider.update!(certs: [wrong_cert, saml_test_sp_cert]) + context 'when request is using SHA1 as the signature method algorithm' do + let(:signature_method) do + 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256' end - it 'notes that certs are different in the event' do + it 'notes an error in the event' do user.identities.last.update!(verified_attributes: ['email']) generate_saml_response(user, auth_settings) expect(response.status).to eq(200) expect(@analytics).to have_logged_event( - 'SAML Auth', hash_including( - certs_different: true, - sha256_matching_cert: wrong_cert.serial.to_s, + 'SAML Auth', hash_not_including( + :cert_error_details, ) ) end end end - context 'when request is using SHA1 as the digest method algorithm' do - let(:auth_settings) do - saml_settings( - overrides: { - security: { - authn_requests_signed:, - digest_method: 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha1', - }, - }, - ) - end - - it 'notes an error in the event' do - user.identities.last.update!(verified_attributes: ['email']) - generate_saml_response(user, auth_settings) - - expect(response.status).to eq(200) - expect(@analytics).to have_logged_event( - 'SAML Auth', hash_including( - request_signed: authn_requests_signed, - cert_error_details: [ - { - cert: '16692258094164984098', - error_code: :fingerprint_mismatch, - }, - { - cert: '14834808178619537243', error_code: :fingerprint_mismatch - }, - ], - ) - ) - end - end - context 'Certificate sig validation fails because of namespace bug' do let(:request_sp) { double } @@ -1836,7 +1809,7 @@ def name_id_version(format_urn) cert_error_details = [ { cert: saml_test_sp_cert_serial, - error_code: :fingerprint_mismatch, + error_code: :request_cert_not_registered, }, ] @@ -2384,7 +2357,8 @@ def name_id_version(format_urn) end it 'has valid signature' do - expect(xmldoc.saml_document.valid_signature?(idp_fingerprint)).to eq(true) + cert = OpenSSL::X509::Certificate.new(saml_test_idp_cert) + expect(xmldoc.saml_document.valid_signature?(cert)).to eq(true) end context 'Reference' do diff --git a/spec/controllers/users/sessions_controller_spec.rb b/spec/controllers/users/sessions_controller_spec.rb index a033b885eef..39d72de7baa 100644 --- a/spec/controllers/users/sessions_controller_spec.rb +++ b/spec/controllers/users/sessions_controller_spec.rb @@ -98,7 +98,7 @@ rate_limited: false, valid_captcha_result: true, captcha_validation_performed: false, - bad_password_count: 0, + sign_in_failure_count: 0, sp_request_url_present: false, remember_device: false, new_device: true, @@ -171,7 +171,7 @@ rate_limited: false, valid_captcha_result: true, captcha_validation_performed: false, - bad_password_count: 0, + sign_in_failure_count: 0, sp_request_url_present: false, remember_device: false, new_device: false, @@ -183,11 +183,11 @@ context 'locked out session' do let(:locked_at) { Time.zone.now } let(:user) { create(:user, :fully_registered) } - let(:bad_password_window) { IdentityConfig.store.max_bad_passwords_window_in_seconds } + let(:sign_in_failure_window) { IdentityConfig.store.max_sign_in_failures_window_in_seconds } before do - session[:bad_password_count] = IdentityConfig.store.max_bad_passwords + 1 - session[:max_bad_passwords_at] = locked_at.to_i + session[:sign_in_failure_count] = IdentityConfig.store.max_sign_in_failures + 1 + session[:max_sign_in_failures_at] = locked_at.to_i end it 'renders an error letting user know they are locked out for a period of time', @@ -196,14 +196,14 @@ current_time = Time.zone.now time_in_hours = distance_of_time_in_words( current_time, - (locked_at + bad_password_window.seconds), + (locked_at + sign_in_failure_window.seconds), true, ) expect(response).to redirect_to root_url expect(flash[:error]).to eq( t( - 'errors.sign_in.bad_password_limit', + 'errors.sign_in.sign_in_failure_limit', time_left: time_in_hours, ), ) @@ -211,7 +211,7 @@ end it 'prevents attempt and logs after exceeding maximum rate limit' do - allow(IdentityConfig.store).to receive(:max_bad_passwords).and_return(10_000) + allow(IdentityConfig.store).to receive(:max_sign_in_failures).and_return(10_000) allow(RateLimiter).to receive(:rate_limit_config).and_return( sign_in_user_id_per_ip: { max_attempts: 6, @@ -243,7 +243,7 @@ post :create, params: { user: { email: user.email, password: 'incorrect' } } expect(flash[:error]).to eq( t( - 'errors.sign_in.bad_password_limit', + 'errors.sign_in.sign_in_failure_limit', time_left: distance_of_time_in_words(12.hours), ), ) @@ -254,7 +254,7 @@ rate_limited: true, valid_captcha_result: true, captcha_validation_performed: false, - bad_password_count: 8, + sign_in_failure_count: 8, sp_request_url_present: false, remember_device: false, ) @@ -276,7 +276,7 @@ rate_limited: false, valid_captcha_result: true, captcha_validation_performed: false, - bad_password_count: 1, + sign_in_failure_count: 1, sp_request_url_present: false, remember_device: false, ) @@ -296,7 +296,7 @@ rate_limited: false, valid_captcha_result: true, captcha_validation_performed: false, - bad_password_count: 1, + sign_in_failure_count: 1, sp_request_url_present: false, remember_device: false, ) @@ -320,7 +320,7 @@ rate_limited: false, valid_captcha_result: true, captcha_validation_performed: false, - bad_password_count: 0, + sign_in_failure_count: 0, sp_request_url_present: false, remember_device: false, ) @@ -371,7 +371,7 @@ rate_limited: false, valid_captcha_result: false, captcha_validation_performed: true, - bad_password_count: 0, + sign_in_failure_count: 1, remember_device: false, sp_request_url_present: false, ) @@ -385,6 +385,37 @@ expect(response).to redirect_to sign_in_security_check_failed_url end end + + context 'recaptcha lock out' do + let(:locked_at) { Time.zone.now } + let(:sign_in_failure_window) { IdentityConfig.store.max_sign_in_failures_window_in_seconds } + it 'prevents attempt after exceeding maximum rate limit' do + allow(IdentityConfig.store).to receive(:max_sign_in_failures).and_return(5) + + user = create(:user, :fully_registered) + freeze_time do + current_time = Time.zone.now + rate_limit_time_left = distance_of_time_in_words( + current_time, + (locked_at + sign_in_failure_window.seconds), + true, + ) + 6.times do + post :create, params: { + user: { email: user.email, password: user.password, score: 0.1 }, + } + end + + expect(response).to redirect_to root_url + expect(flash[:error]).to eq( + t( + 'errors.sign_in.sign_in_failure_limit', + time_left: rate_limit_time_left, + ), + ) + end + end + end end it 'tracks count of multiple unsuccessful authentication attempts' do @@ -404,7 +435,7 @@ rate_limited: false, valid_captcha_result: true, captcha_validation_performed: false, - bad_password_count: 2, + sign_in_failure_count: 2, sp_request_url_present: false, remember_device: false, ) @@ -423,7 +454,7 @@ rate_limited: false, valid_captcha_result: true, captcha_validation_performed: false, - bad_password_count: 1, + sign_in_failure_count: 1, sp_request_url_present: true, remember_device: false, ) @@ -594,7 +625,7 @@ rate_limited: false, valid_captcha_result: true, captcha_validation_performed: false, - bad_password_count: 0, + sign_in_failure_count: 0, sp_request_url_present: false, remember_device: false, new_device: true, @@ -719,7 +750,7 @@ rate_limited: false, valid_captcha_result: true, captcha_validation_performed: false, - bad_password_count: 0, + sign_in_failure_count: 0, sp_request_url_present: false, remember_device: true, new_device: true, @@ -746,7 +777,7 @@ rate_limited: false, valid_captcha_result: true, captcha_validation_performed: false, - bad_password_count: 0, + sign_in_failure_count: 0, sp_request_url_present: false, remember_device: true, new_device: true, diff --git a/spec/features/visitors/bad_password_spec.rb b/spec/features/visitors/bad_password_spec.rb index 846afa05716..e2f5de02848 100644 --- a/spec/features/visitors/bad_password_spec.rb +++ b/spec/features/visitors/bad_password_spec.rb @@ -4,7 +4,7 @@ include ActionView::Helpers::DateHelper let(:user) { create(:user, :fully_registered) } let(:bad_password) { 'badpassword' } - let(:window) { IdentityConfig.store.max_bad_passwords_window_in_seconds.seconds } + let(:window) { IdentityConfig.store.max_sign_in_failures_window_in_seconds.seconds } scenario 'visitor tries too many bad passwords gets locked out then waits window seconds' do visit new_user_session_path @@ -12,12 +12,12 @@ 'devise.failure.invalid_html', link_html: t('devise.failure.invalid_link_text'), ) - IdentityConfig.store.max_bad_passwords.times do + IdentityConfig.store.max_sign_in_failures.times do fill_in_credentials_and_submit(user.email, bad_password) expect(page).to have_content(error_message) expect(page).to have_current_path(new_user_session_path) end - locked_at = Time.zone.at(page.get_rack_session['max_bad_passwords_at']) + locked_at = Time.zone.at(page.get_rack_session['max_sign_in_failures_at']) # Need to do this because getting rack session changes the url. visit new_user_session_path 2.times do @@ -29,7 +29,7 @@ time_left = distance_of_time_in_words(Time.zone.now, new_time, true) expect(page).to have_content( t( - 'errors.sign_in.bad_password_limit', + 'errors.sign_in.sign_in_failure_limit', time_left: time_left, ), ) @@ -43,13 +43,13 @@ time_left = distance_of_time_in_words(Time.zone.now, new_time, true) expect(page).to have_content( t( - 'errors.sign_in.bad_password_limit', + 'errors.sign_in.sign_in_failure_limit', time_left: time_left, ), ) end - travel_to(IdentityConfig.store.max_bad_passwords_window_in_seconds.seconds.from_now) do + travel_to(IdentityConfig.store.max_sign_in_failures_window_in_seconds.seconds.from_now) do fill_in_credentials_and_submit(user.email, bad_password) expect(page).to have_content(error_message) fill_in_credentials_and_submit(user.email, user.password) diff --git a/spec/javascript/packages/document-capture/components/acuant-capture-spec.jsx b/spec/javascript/packages/document-capture/components/acuant-capture-spec.jsx index 5c8e5b3caee..9a63f206b08 100644 --- a/spec/javascript/packages/document-capture/components/acuant-capture-spec.jsx +++ b/spec/javascript/packages/document-capture/components/acuant-capture-spec.jsx @@ -74,6 +74,29 @@ describe('document-capture/components/acuant-capture', () => { }); } + /** + * Mimics Drag Drop a file to the given input. Unlike `@testing-library/user-event`, + * this does not call any click handlers associated with the input. + * + * @param {HTMLInputElement} input + * @param {File} value + */ + function dragDropFile(input, value) { + fireEvent( + input, + createEvent('input', input, { + target: { files: [value] }, + bubbles: true, + cancelable: false, + composed: true, + }), + ); + + fireEvent.drop(input, { + target: { files: [value] }, + }); + } + describe('getNormalizedAcuantCaptureFailureMessage', () => { beforeEach(() => { window.AcuantJavascriptWebSdk = { @@ -610,6 +633,23 @@ describe('document-capture/components/acuant-capture', () => { ); }); + it('onChange not called if allowUpload is false and user drags drops file', () => { + const onChange = sinon.stub(); + const { getByLabelText } = render( + + + + + , + ); + + initialize({ isCameraSupported: false }); + + const input = getByLabelText('Image'); + dragDropFile(input, validUpload); + expect(onChange).not.to.have.been.called(); + }); + it('renders error message and logs metadata if capture succeeds but the document type identified is unsupported', async () => { const trackEvent = sinon.spy(); const { getByText, findByText } = render( diff --git a/spec/jobs/resolution_proofing_job_spec.rb b/spec/jobs/resolution_proofing_job_spec.rb index c28352a132b..e1e3254a441 100644 --- a/spec/jobs/resolution_proofing_job_spec.rb +++ b/spec/jobs/resolution_proofing_job_spec.rb @@ -18,6 +18,7 @@ let(:proofing_device_profiling) { :enabled } let(:lexisnexis_threatmetrix_mock_enabled) { false } let(:ipp_enrollment_in_progress) { false } + let(:proofing_components) { nil } before do allow(IdentityConfig.store).to receive(:proofing_device_profiling) @@ -43,6 +44,7 @@ threatmetrix_session_id: threatmetrix_session_id, request_ip: request_ip, ipp_enrollment_in_progress: ipp_enrollment_in_progress, + proofing_components: proofing_components, ) end @@ -544,70 +546,152 @@ end end - context 'socure shadow mode' do - context 'turned on' do - before do - allow(instance).to receive(:use_shadow_mode?).and_return(true) + context 'Socure shadow mode test' do + let(:idv_socure_shadow_mode_enabled_for_docv_users) { false } + let(:idv_socure_shadow_mode_enabled) { false } + let(:doc_auth_vendor) { nil } + let(:in_shadow_mode_ab_test_bucket) { false } + + before do + allow(IdentityConfig.store).to receive(:idv_socure_shadow_mode_enabled_for_docv_users) + .and_return(idv_socure_shadow_mode_enabled_for_docv_users) + allow(IdentityConfig.store).to receive(:idv_socure_shadow_mode_enabled) + .and_return(idv_socure_shadow_mode_enabled) + + allow(instance).to receive(:shadow_mode_ab_test_bucket) do |user:| + expect(user).not_to eql(nil) + if in_shadow_mode_ab_test_bucket + :socure_shadow_mode_for_non_docv_users + end end - it 'schedules a SocureShadowModeProofingJob' do - stub_vendor_requests - expect(SocureShadowModeProofingJob).to receive(:perform_later).with( - user_email: user.email, - user_uuid: user.uuid, - document_capture_session_result_id: document_capture_session.result_id, - encrypted_arguments: satisfy do |ciphertext| - json = JSON.parse( - Encryption::Encryptors::BackgroundProofingArgEncryptor.new.decrypt(ciphertext), - symbolize_names: true, - ) - expect(json[:applicant_pii]).to eql( + stub_vendor_requests + end + + context 'when enabled' do + let(:idv_socure_shadow_mode_enabled) { true } + + context 'and user is selected in A/B test' do + let(:in_shadow_mode_ab_test_bucket) { true } + + it 'schedules a SocureShadowModeProofingJob' do + expect(SocureShadowModeProofingJob).to receive(:perform_later).with( + user_email: user.email, + user_uuid: user.uuid, + document_capture_session_result_id: document_capture_session.result_id, + encrypted_arguments: satisfy do |ciphertext| + json = JSON.parse( + Encryption::Encryptors::BackgroundProofingArgEncryptor.new.decrypt(ciphertext), + symbolize_names: true, + ) + expect(json[:applicant_pii]).to eql( + { + first_name: 'FAKEY', + middle_name: nil, + last_name: 'MCFAKERSON', + name_suffix: 'JR', + address1: '1 FAKE RD', + identity_doc_address1: '1 FAKE RD', + identity_doc_address2: nil, + identity_doc_city: 'GREAT FALLS', + identity_doc_address_state: 'MT', + identity_doc_zipcode: '59010-1234', + issuing_country_code: 'US', + address2: nil, + same_address_as_id: 'true', + city: 'GREAT FALLS', + state: 'MT', + zipcode: '59010-1234', + dob: '1938-10-06', + sex: 'male', + height: 72, + weight: nil, + eye_color: nil, + ssn: '900-66-1234', + state_id_jurisdiction: 'ND', + state_id_expiration: '2099-12-31', + state_id_issued: '2019-12-31', + state_id_number: '1111111111111', + state_id_type: 'drivers_license', + }, + ) + end, + service_provider_issuer: service_provider.issuer, + ) + + perform + end + + context 'and shadow mode also enabled for docv users' do + let(:idv_socure_shadow_mode_enabled_for_docv_users) { true } + + context 'when the user is a docv user' do + let(:proofing_components) do { - first_name: 'FAKEY', - middle_name: nil, - last_name: 'MCFAKERSON', - name_suffix: 'JR', - address1: '1 FAKE RD', - identity_doc_address1: '1 FAKE RD', - identity_doc_address2: nil, - identity_doc_city: 'GREAT FALLS', - identity_doc_address_state: 'MT', - identity_doc_zipcode: '59010-1234', - issuing_country_code: 'US', - address2: nil, - same_address_as_id: 'true', - city: 'GREAT FALLS', - state: 'MT', - zipcode: '59010-1234', - dob: '1938-10-06', - sex: 'male', - height: 72, - weight: nil, - eye_color: nil, - ssn: '900-66-1234', - state_id_jurisdiction: 'ND', - state_id_expiration: '2099-12-31', - state_id_issued: '2019-12-31', - state_id_number: '1111111111111', - state_id_type: 'drivers_license', - }, - ) - end, - service_provider_issuer: service_provider.issuer, - ) + document_check: Idp::Constants::Vendors::SOCURE, + } + end + it 'only schedules 1 SocureShadowModeProofingJob' do + expect(SocureShadowModeProofingJob).to receive(:perform_later).once + perform + end + end + end + end - perform + context 'and user is NOT selected in A/B test' do + let(:in_shadow_mode_ab_test_bucket) { false } + + it 'does not schedule a shadow mode job' do + expect(SocureShadowModeProofingJob).not_to receive(:perform_later) + perform + end + + context 'but shadow mode is enabled for docv users' do + let(:idv_socure_shadow_mode_enabled_for_docv_users) { true } + + context 'and the user happens to be a docv user' do + let(:proofing_components) do + { + document_check: Idp::Constants::Vendors::SOCURE, + } + end + + it 'schedules a SocureShadowModeProofingJob' do + expect(SocureShadowModeProofingJob).to receive(:perform_later).once + perform + end + end + + context 'except the user did not use Socure docv' do + it 'does not schedule a SocureShadowModeProofingJob' do + expect(SocureShadowModeProofingJob).not_to receive(:perform_later) + perform + end + end + end end end - context 'turned off' do + context 'when disabled' do + let(:idv_socure_shadow_mode_enabled) { false } + it 'does not schedule a SocureShadowModeProofingJob' do stub_vendor_requests - expect(SocureShadowModeProofingJob).not_to receive(:perform_later) - perform end + + context 'but the flag to enable shadow mode for docv users was left on' do + let(:idv_socure_shadow_mode_enabled_for_docv_users) { true } + context 'when user is a docv user' do + it 'does not schedule a SocureShadowModeProofingJob' do + stub_vendor_requests + expect(SocureShadowModeProofingJob).not_to receive(:perform_later) + perform + end + end + end end end diff --git a/spec/lib/analytics_events_documenter_spec.rb b/spec/lib/analytics_events_documenter_spec.rb index 81c2c24ef78..d6dc013fbf7 100644 --- a/spec/lib/analytics_events_documenter_spec.rb +++ b/spec/lib/analytics_events_documenter_spec.rb @@ -204,6 +204,43 @@ def some_event(*) end end + context 'param description gets munged into method descripion' do + let(:source_code) { <<~RUBY } + class AnalyticsEvents + # @param val [String] some value that + # does things and this should be part of the param + # Some Event + def some_event(val:, **extra) + track_event('Some Event', val:, **extra) + end + end + RUBY + + it 'errors' do + expect(documenter.missing_documentation.first) + .to include('method description starts with lowercase, check indentation') + end + end + + context 'rubocop comment around params description' do + let(:source_code) { <<~RUBY } + class AnalyticsEvents + # @param val [String] some value that + # does things and this should be part of the param + # Some Event + # rubocop:disable Layout/LineLength + def some_event(val:, **extra) + track_event('Some Event', val:, **extra) + end + # rubocop:enable Layout/LineLength + end + RUBY + + it 'ignores rubocop lines' do + expect(documenter.missing_documentation).to be_empty + end + end + describe '#as_json' do let(:source_code) { <<~RUBY } class AnalyticsEvents @@ -211,9 +248,11 @@ class AnalyticsEvents # @param [Integer] count number of attempts # The event that does something with stuff # @option extra [String] 'DocumentName' the document name + # rubocop:disable Layout/LineLength def some_event(success:, count:, **extra) track_event('Some Event', **extra) end + # rubocop:enable Layout/LineLength # @identity.idp.previous_event_name The Old Other Event # @identity.idp.previous_event_name Even Older Other Event @@ -223,7 +262,7 @@ def other_event end RUBY - it 'is a JSON representation of params for each event' do + it 'is a JSON representation of params for each event, ignoring rubocop directives' do expect(documenter.as_json[:events]).to match_array( [ { @@ -277,7 +316,7 @@ def some_event(success:, **extra) { event_name: 'some_event', previous_event_names: [], - description: '', + description: nil, attributes: [ { name: 'success', types: ['Boolean'], description: nil }, ], diff --git a/spec/lib/event_summarizer/idv_matcher_spec.rb b/spec/lib/event_summarizer/idv_matcher_spec.rb new file mode 100644 index 00000000000..8c90dd3e9c4 --- /dev/null +++ b/spec/lib/event_summarizer/idv_matcher_spec.rb @@ -0,0 +1,56 @@ +require 'active_support' +require 'active_support/time' + +require 'event_summarizer/idv_matcher' + +RSpec.describe EventSummarizer::IdvMatcher do + describe '#handle_cloudwatch_event' do + let(:event) do + { + '@timestamp': '2024-01-02T03:04:05Z', + } + end + + subject(:matcher) do + described_class.new + end + + around do |example| + Time.use_zone('UTC') do + example.run + end + end + + context 'On unknown event' do + let(:event) { super().merge('name' => 'Some random event') } + it 'does not throw' do + matcher.handle_cloudwatch_event(event) + end + end + + context "On 'IdV: doc auth welcome submitted' event" do + let(:event) { super().merge('name' => 'IdV: doc auth welcome submitted') } + + it 'starts a new IdV attempt' do + matcher.handle_cloudwatch_event(event) + expect(matcher.current_idv_attempt).not_to eql(nil) + end + + context 'with an IdV attempt already started' do + before do + allow(matcher).to receive(:current_idv_attempt).and_return( + EventSummarizer::IdvMatcher::IdvAttempt.new( + started_at: Time.zone.now, + ), + ) + end + + it 'finishes it' do + expect(matcher.idv_attempts.length).to eql(0) + matcher.handle_cloudwatch_event(event) + expect(matcher.idv_attempts.length).to eql(1) + end + end + end + end +end diff --git a/spec/lib/event_summarizer/vendor_result_evaluators/instant_verify_spec.rb b/spec/lib/event_summarizer/vendor_result_evaluators/instant_verify_spec.rb new file mode 100644 index 00000000000..c22291b53f5 --- /dev/null +++ b/spec/lib/event_summarizer/vendor_result_evaluators/instant_verify_spec.rb @@ -0,0 +1,91 @@ +require 'json' +require 'event_summarizer/vendor_result_evaluators/instant_verify' + +RSpec.describe EventSummarizer::VendorResultEvaluators::InstantVerify do + let(:instant_verify_result) do + { + success: true, + errors: {}, + exception: nil, + timed_out: false, + } + end + + subject(:evaluation) do + described_class.evaluate_result( + JSON.parse(JSON.generate(instant_verify_result)), + ) + end + + context 'successful result' do + it 'looks correct' do + expect(evaluation).to eql( + { + type: :instant_verify_success, + description: 'Instant Verify call succeeded', + }, + ) + end + end + + context 'request timed out' do + let(:instant_verify_result) do + super().merge( + success: false, + errors: {}, + timed_out: true, + ) + end + + it 'reports the error appropriately' do + expect(evaluation).to eql( + { + type: :instant_verify_timed_out, + description: 'Instant Verify request timed out.', + }, + ) + end + end + + context 'failed result' do + let(:instant_verify_result) do + { + success: false, + errors: { + base: ["Verification failed with code: 'priority.scoring.model.verification.fail'"], + InstantVerify: [ + { + ProductType: 'InstantVerify', + ExecutedStepName: 'InstantVerify', + ProductConfigurationName: 'blah.config', + ProductStatus: 'fail', + ProductReason: { + Code: 'priority.scoring.model.verification.fail', + }, + Items: [ + { ItemName: 'Check1', ItemStatus: 'pass' }, + { ItemName: 'Check2', ItemStatus: 'fail' }, + { + ItemName: 'CheckWithCode', + ItemStatus: 'fail', + ItemReason: { Code: 'some_obscure_code ' }, + }, + ], + }, + ], + }, + exception: nil, + timed_out: false, + } + end + + it 'returns the correct result' do + expect(evaluation).to eql( + { + description: 'Instant Verify request failed. 2 checks failed: Check2, CheckWithCode (some_obscure_code )', # rubocop:disable Layout/LineLength + type: :instant_verify_error, + }, + ) + end + end +end diff --git a/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb b/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb index 16cf8f0af3a..51d3608784f 100644 --- a/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb +++ b/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb @@ -251,12 +251,14 @@ end context 'when doc_auth_read_additional_pii_attributes_enabled is enabled' do - let(:success_response_body) { LexisNexisFixtures.true_id_response_success } - - it 'reads the additional PII attributes' do + before do allow(IdentityConfig.store).to receive(:doc_auth_read_additional_pii_attributes_enabled) .and_return(true) + end + + let(:success_response_body) { LexisNexisFixtures.true_id_response_success } + it 'reads the additional PII attributes' do pii_from_doc = response.pii_from_doc expect(pii_from_doc.first_name).to eq('LICENSE') @@ -264,6 +266,17 @@ expect(pii_from_doc.sex).to eq('male') expect(pii_from_doc.height).to eq(68) end + + context 'when the height has a space in it' do + # This fixture has the height returns as "5' 9\"" + let(:success_response_body) { LexisNexisFixtures.true_id_response_success_3 } + + it 'reads parses the height correctly' do + pii_from_doc = response.pii_from_doc + + expect(pii_from_doc.height).to eq(69) + end + end end end diff --git a/spec/services/encrypted_doc_storage/doc_writer_spec.rb b/spec/services/encrypted_doc_storage/doc_writer_spec.rb new file mode 100644 index 00000000000..d413f8f4704 --- /dev/null +++ b/spec/services/encrypted_doc_storage/doc_writer_spec.rb @@ -0,0 +1,52 @@ +require 'rails_helper' + +RSpec.describe EncryptedDocStorage::DocWriter do + describe '#write' do + let(:img_path) { Rails.root.join('app', 'assets', 'images', 'logo.svg') } + let(:image) { File.read(img_path) } + + subject do + EncryptedDocStorage::DocWriter.new + end + + it 'encrypts the document and writes it to storage' do + result = subject.write(image:) + + key = Base64.strict_decode64(result.encryption_key) + aes_cipher = Encryption::AesCipherV2.new + + written_image = aes_cipher.decrypt( + File.read(file_path(result.name)), + key, + ) + + # cleanup + File.delete(file_path(result.name)) + + expect(written_image).to eq(image) + end + + it 'uses LocalStorage by default' do + expect_any_instance_of(EncryptedDocStorage::LocalStorage).to receive(:write_image).once + expect_any_instance_of(EncryptedDocStorage::S3Storage).to_not receive(:write_image) + + subject.write(image:) + end + + context 'when S3Storage is passed in' do + it 'uses S3' do + expect_any_instance_of(EncryptedDocStorage::S3Storage).to receive(:write_image).once + expect_any_instance_of(EncryptedDocStorage::LocalStorage).not_to receive(:write_image) + + subject.write( + image:, + data_store: EncryptedDocStorage::S3Storage, + ) + end + end + + def file_path(uuid) + Rails.root.join('tmp', 'encrypted_doc_storage', uuid) + end + end +end diff --git a/spec/services/encrypted_doc_storage/local_storage_spec.rb b/spec/services/encrypted_doc_storage/local_storage_spec.rb new file mode 100644 index 00000000000..fc476df3378 --- /dev/null +++ b/spec/services/encrypted_doc_storage/local_storage_spec.rb @@ -0,0 +1,30 @@ +require 'rails_helper' + +RSpec.describe EncryptedDocStorage::LocalStorage do + let(:img_path) { Rails.root.join('app', 'assets', 'images', 'logo.svg') } + let(:image) { File.read(img_path) } + let(:encrypted_image) do + Encryption::AesCipherV2.new.encrypt(image, SecureRandom.bytes(32)) + end + + describe '#write_image' do + it 'writes the document to the disk' do + name = SecureRandom.uuid + + EncryptedDocStorage::LocalStorage.new.write_image( + encrypted_image:, + name:, + ) + path = Rails.root.join('tmp', 'encrypted_doc_storage', name) + + f = File.new(path, 'rb') + result = f.read + f.close + + # cleanup + File.delete(path) + + expect(result).to eq(encrypted_image) + end + end +end diff --git a/spec/services/encrypted_doc_storage/s3_storage_spec.rb b/spec/services/encrypted_doc_storage/s3_storage_spec.rb new file mode 100644 index 00000000000..9ef5db410fa --- /dev/null +++ b/spec/services/encrypted_doc_storage/s3_storage_spec.rb @@ -0,0 +1,31 @@ +require 'rails_helper' + +RSpec.describe EncryptedDocStorage::S3Storage do + subject { EncryptedDocStorage::S3Storage.new } + let(:img_path) { Rails.root.join('app', 'assets', 'images', 'logo.svg') } + let(:image) { File.read(img_path) } + let(:encrypted_image) do + Encryption::AesCipherV2.new.encrypt(image, SecureRandom.bytes(32)) + end + + describe '#write_image' do + let(:stubbed_s3_client) { Aws::S3::Client.new(stub_responses: true) } + + before do + allow(subject).to receive(:s3_client).and_return(stubbed_s3_client) + allow(stubbed_s3_client).to receive(:put_object) + end + + it 'writes the document to S3' do + name = '123abc' + + subject.write_image(encrypted_image:, name:) + + expect(stubbed_s3_client).to have_received(:put_object).with( + bucket: IdentityConfig.store.encrypted_document_storage_s3_bucket, + key: name, + body: encrypted_image, + ) + end + end +end diff --git a/spec/services/idv/agent_spec.rb b/spec/services/idv/agent_spec.rb index f3ebb0f9619..bead0c38774 100644 --- a/spec/services/idv/agent_spec.rb +++ b/spec/services/idv/agent_spec.rb @@ -15,6 +15,25 @@ Idp::Constants::MOCK_IDV_APPLICANT_WITH_SSN end let(:document_capture_session) { DocumentCaptureSession.new(result_id: SecureRandom.hex) } + let(:session) { {} } + let(:user_session) { {} } + let(:idv_session) do + Idv::Session.new( + user_session:, + current_user: user, + service_provider: issuer, + ).tap do |idv_session| + idv_session.pii_from_doc = applicant + end + end + let(:proofing_components) do + Idv::ProofingComponents.new( + idv_session:, + session:, + user:, + user_session:, + ) + end subject(:agent) { Idv::Agent.new(applicant) } @@ -35,6 +54,7 @@ threatmetrix_session_id: nil, request_ip: request_ip, ipp_enrollment_in_progress: ipp_enrollment_in_progress, + proofing_components:, ) end @@ -123,6 +143,18 @@ proof_resolution end + it 'passes proofing components to ResolutionProofingJob' do + expect(ResolutionProofingJob).to receive(:perform_later).with( + hash_including( + proofing_components: { + document_check: 'mock', + document_type: 'state_id', + }, + ), + ) + proof_resolution + end + context 'when a proofing timeout occurs' do let(:applicant) do super().merge(first_name: 'Time Exception') diff --git a/spec/services/idv/proofing_components_spec.rb b/spec/services/idv/proofing_components_spec.rb index 745eb9df054..bdea18c079e 100644 --- a/spec/services/idv/proofing_components_spec.rb +++ b/spec/services/idv/proofing_components_spec.rb @@ -39,6 +39,7 @@ .and_return(true) idv_session.threatmetrix_review_status = 'pass' idv_session.source_check_vendor = 'aamva' + idv_session.resolution_vendor = 'lexis_nexis' end it 'returns expected result' do @@ -168,16 +169,6 @@ expect(subject.residential_resolution_check).to eql('AReallyGoodVendor') end end - - context 'when resolution done but residential_resolution_vendor nil because of 50/50 state' do - before do - idv_session.mark_verify_info_step_complete! - end - - it 'returns nil to match previous behavior' do - expect(subject.residential_resolution_check).to be(nil) - end - end end describe '#resolution_check' do @@ -195,16 +186,6 @@ expect(subject.resolution_check).to eql('AReallyGoodVendor') end end - - context 'when resolution done but resolution_vendor nil because of 50/50 state' do - before do - idv_session.mark_verify_info_step_complete! - end - - it 'returns LexisNexis to match previous behavior' do - expect(subject.resolution_check).to eql('lexis_nexis') - end - end end describe '#address_check' do diff --git a/spec/services/proofing/aamva/applicant_spec.rb b/spec/services/proofing/aamva/applicant_spec.rb index cb53ffeed65..011d89e3174 100644 --- a/spec/services/proofing/aamva/applicant_spec.rb +++ b/spec/services/proofing/aamva/applicant_spec.rb @@ -65,11 +65,22 @@ expect(aamva_applicant[:dob]).to eq('') end - it 'should format the height' do - proofer_applicant[:height] = 73 - aamva_applicant = Proofing::Aamva::Applicant.from_proofer_applicant(proofer_applicant) + context 'when height includes inches >= 10' do + it 'formats as expected' do + proofer_applicant[:height] = 95 + aamva_applicant = Proofing::Aamva::Applicant.from_proofer_applicant(proofer_applicant) + expect(aamva_applicant[:height]).to eq('711') + end + end - # This is intended to describe 6'1" - expect(aamva_applicant[:height]).to eq('61') + context 'when height includes inches < 10' do + it 'formats as expected' do + proofer_applicant[:height] = 67 + aamva_applicant = Proofing::Aamva::Applicant.from_proofer_applicant(proofer_applicant) + + # From the DLDV user guide: + # > Height data should be 3 characters (i.e. 5 foot 7 inches is submitted as 507) + expect(aamva_applicant[:height]).to eq('507') + end end end diff --git a/spec/support/shared_examples/sign_in.rb b/spec/support/shared_examples/sign_in.rb index 07c1c258f91..61809a45c63 100644 --- a/spec/support/shared_examples/sign_in.rb +++ b/spec/support/shared_examples/sign_in.rb @@ -118,7 +118,7 @@ end it 'gets bad password error' do - ial2_sign_in_with_piv_cac_gets_bad_password_error(sp) + ial2_sign_in_with_piv_cac_gets_sign_in_failure_error(sp) end end @@ -487,7 +487,7 @@ def no_authn_context_sign_in_with_piv_cac_goes_to_sp(sp) ) end -def ial2_sign_in_with_piv_cac_gets_bad_password_error(sp) +def ial2_sign_in_with_piv_cac_gets_sign_in_failure_error(sp) user = create(:user, :proofed, :with_piv_or_cac) visit_idp_from_sp_with_ial2(sp) diff --git a/yarn.lock b/yarn.lock index ca61d2a5cc6..fb667e03011 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4081,10 +4081,10 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" -libphonenumber-js@^1.11.4: - version "1.11.4" - resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.11.4.tgz#e63fe553f45661b30bb10bb8c82c9cf2b22ec32a" - integrity sha512-F/R50HQuWWYcmU/esP5jrH5LiWYaN7DpN0a/99U8+mnGGtnx8kmRE+649dQh3v+CowXXZc8vpkf5AmYkO0AQ7Q== +libphonenumber-js@^1.11.17: + version "1.11.17" + resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.11.17.tgz#37ddbf16dc4dd45c723a150996c253c58dad034b" + integrity sha512-Jr6v8thd5qRlOlc6CslSTzGzzQW03uiscab7KHQZX1Dfo4R6n6FDhZ0Hri6/X7edLIDv9gl4VMZXhxTjLnl0VQ== lightningcss-darwin-arm64@1.23.0: version "1.23.0" @@ -4364,7 +4364,7 @@ minimatch@^9.0.4: dependencies: brace-expansion "^2.0.1" -minimist@1.2.6, minimist@^1.2.0, minimist@^1.2.6: +minimist@^1.2.0, minimist@^1.2.6: version "1.2.6" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== @@ -4444,10 +4444,10 @@ mute-stream@^2.0.0: resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-2.0.0.tgz#a5446fc0c512b71c83c44d908d5c7b7b4c493b2b" integrity sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA== -nanoid@^3.3.6: - version "3.3.7" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" - integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== +nanoid@^3.3.7: + version "3.3.8" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf" + integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w== natural-compare@^1.4.0: version "1.4.0" @@ -4748,7 +4748,7 @@ pathval@^1.1.1: resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d" integrity sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ== -picocolors@^1.0.0, picocolors@^1.1.0: +picocolors@^1.0.0, picocolors@^1.1.0, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== @@ -4817,14 +4817,14 @@ postcss-value-parser@^4.2.0: resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== -postcss@8.4.31, postcss@^8.4.33: - version "8.4.31" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d" - integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ== +postcss@^8.4.33: + version "8.4.49" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.49.tgz#4ea479048ab059ab3ae61d082190fabfd994fe19" + integrity sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA== dependencies: - nanoid "^3.3.6" - picocolors "^1.0.0" - source-map-js "^1.0.2" + nanoid "^3.3.7" + picocolors "^1.1.1" + source-map-js "^1.2.1" prelude-ls@^1.2.1: version "1.2.1" @@ -5436,10 +5436,10 @@ source-list-map@^2.0.1: resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34" integrity sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw== -source-map-js@^1.0.1, source-map-js@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" - integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== +source-map-js@^1.0.1, source-map-js@^1.0.2, source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== source-map-loader@^4.0.0: version "4.0.0" From df96241bd55e96d779f7b56d0f9c08dec5dcb112 Mon Sep 17 00:00:00 2001 From: Mitchell Henke Date: Fri, 10 Jan 2025 11:03:06 -0600 Subject: [PATCH 8/8] Revert "Update newrelic_rpm gem (#11699)" (#11730) This reverts commit 0d65152d4187b16d7d044dd831414b10574b2ddb. --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 643e32bac0e..aa5b10f1a52 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -458,7 +458,7 @@ GEM net-smtp (0.5.0) net-protocol net-ssh (6.1.0) - newrelic_rpm (9.16.1) + newrelic_rpm (9.7.0) nio4r (2.7.4) nokogiri (1.16.8) mini_portile2 (~> 2.8.2)