diff --git a/app/controllers/concerns/ab_testing_concern.rb b/app/controllers/concerns/ab_testing_concern.rb index 16eb6bb2769..6353360e4d5 100644 --- a/app/controllers/concerns/ab_testing_concern.rb +++ b/app/controllers/concerns/ab_testing_concern.rb @@ -4,7 +4,7 @@ module AbTestingConcern # @param [Symbol] test Name of the test, which should correspond to an A/B test defined in # # config/initializer/ab_tests.rb. # @return [Symbol,nil] Bucket to use for the given test, or nil if the test is not active. - def ab_test_bucket(test_name) + def ab_test_bucket(test_name, user: current_user) test = AbTests.all[test_name] raise "Unknown A/B test: #{test_name}" unless test @@ -12,7 +12,7 @@ def ab_test_bucket(test_name) request:, service_provider: current_sp&.issuer, session:, - user: current_user, + user:, user_session:, ) end diff --git a/app/controllers/idv/enter_password_controller.rb b/app/controllers/idv/enter_password_controller.rb index 2ca053235a1..12881a61a5c 100644 --- a/app/controllers/idv/enter_password_controller.rb +++ b/app/controllers/idv/enter_password_controller.rb @@ -51,6 +51,7 @@ def create gpo_verification_pending: idv_session.profile.gpo_verification_pending?, in_person_verification_pending: idv_session.profile.in_person_verification_pending?, deactivation_reason: idv_session.profile.deactivation_reason, + proofing_workflow_time_in_seconds: idv_session.proofing_workflow_time_in_seconds, **ab_test_analytics_buckets, ) Funnel::DocAuth::RegisterStep.new(current_user.id, current_sp&.issuer). @@ -62,6 +63,7 @@ def create gpo_verification_pending: idv_session.profile.gpo_verification_pending?, in_person_verification_pending: idv_session.profile.in_person_verification_pending?, deactivation_reason: idv_session.profile.deactivation_reason, + proofing_workflow_time_in_seconds: idv_session.proofing_workflow_time_in_seconds, **ab_test_analytics_buckets, ) diff --git a/app/controllers/idv/welcome_controller.rb b/app/controllers/idv/welcome_controller.rb index c2928c47884..1008336ded2 100644 --- a/app/controllers/idv/welcome_controller.rb +++ b/app/controllers/idv/welcome_controller.rb @@ -9,6 +9,7 @@ class WelcomeController < ApplicationController before_action :confirm_not_rate_limited def show + idv_session.proofing_started_at ||= Time.zone.now.iso8601 analytics.idv_doc_auth_welcome_visited(**analytics_arguments) Funnel::DocAuth::RegisterStep.new(current_user.id, sp_session[:issuer]). diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index ee9205870ae..f88d1424409 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -9,6 +9,7 @@ class SessionsController < Devise::SessionsController include Api::CsrfTokenConcern include ForcedReauthenticationConcern include NewDeviceConcern + include AbTestingConcern rescue_from ActionController::InvalidAuthenticityToken, with: :redirect_to_signin @@ -41,7 +42,7 @@ def create handle_valid_authentication ensure handle_invalid_authentication if rate_limit_password_failure && !current_user - track_authentication_attempt(auth_params[:email]) + track_authentication_attempt end def destroy @@ -97,13 +98,24 @@ def locked_out_time_remaining def valid_captcha_result? return @valid_captcha_result if defined?(@valid_captcha_result) - @valid_captcha_result = SignInRecaptchaForm.new(**recaptcha_form_args).submit( - email: auth_params[:email], + @valid_captcha_result = recaptcha_form.submit( recaptcha_token: params.require(:user)[:recaptcha_token], - device_cookie: cookies[:device], ).success? end + def recaptcha_form + @recaptcha_form ||= SignInRecaptchaForm.new( + email: auth_params[:email], + device_cookie: cookies[:device], + ab_test_bucket: ab_test_bucket(:RECAPTCHA_SIGN_IN, user: user_from_params), + **recaptcha_form_args, + ) + end + + def captcha_validation_performed? + !recaptcha_form.exempt? + end + def process_failed_captcha sign_out(:user) warden.lock! @@ -140,6 +152,11 @@ def check_user_needs_redirect end end + def user_from_params + return @user_from_params if defined?(@user_from_params) + @user_from_params = User.find_with_email(auth_params[:email]) + end + def auth_params params.require(:user).permit(:email, :password) end @@ -169,14 +186,15 @@ def handle_valid_authentication user_id: current_user.id, email: auth_params[:email], ) + user_session[:captcha_validation_performed_at_sign_in] = captcha_validation_performed? user_session[:platform_authenticator_available] = params[:platform_authenticator_available] == 'true' check_password_compromised redirect_to next_url_after_valid_authentication end - def track_authentication_attempt(email) - user = User.find_with_email(email) || AnonymousUser.new + def track_authentication_attempt + user = user_from_params || AnonymousUser.new success = current_user.present? && !user_locked_out?(user) && valid_captcha_result? analytics.email_and_password_auth( @@ -184,6 +202,7 @@ def track_authentication_attempt(email) user_id: user.uuid, user_locked_out: user_locked_out?(user), rate_limited: rate_limited?, + captcha_validation_performed: captcha_validation_performed?, valid_captcha_result: valid_captcha_result?, bad_password_count: session[:bad_password_count].to_i, sp_request_url_present: sp_session[:request_url].present?, @@ -202,7 +221,7 @@ def rate_limited? def rate_limiter return @rate_limiter if defined?(@rate_limiter) - user = User.find_with_email(auth_params[:email]) + user = user_from_params return @rate_limiter = nil unless user @rate_limiter = RateLimiter.new( rate_limit_type: :sign_in_user_id_per_ip, diff --git a/app/forms/sign_in_recaptcha_form.rb b/app/forms/sign_in_recaptcha_form.rb index 206e742ea70..5fe5605074f 100644 --- a/app/forms/sign_in_recaptcha_form.rb +++ b/app/forms/sign_in_recaptcha_form.rb @@ -5,24 +5,37 @@ class SignInRecaptchaForm RECAPTCHA_ACTION = 'sign_in' - attr_reader :form_class, :form_args, :email, :recaptcha_token, :device_cookie + attr_reader :form_class, :form_args, :email, :recaptcha_token, :device_cookie, :ab_test_bucket validate :validate_recaptcha_result - def initialize(form_class: RecaptchaForm, **form_args) + def initialize( + email:, + device_cookie:, + ab_test_bucket:, + form_class: RecaptchaForm, + **form_args + ) + @email = email + @device_cookie = device_cookie + @ab_test_bucket = ab_test_bucket @form_class = form_class @form_args = form_args end - def submit(email:, recaptcha_token:, device_cookie:) - @email = email + def submit(recaptcha_token:) @recaptcha_token = recaptcha_token - @device_cookie = device_cookie success = valid? FormResponse.new(success:, errors:, serialize_error_details_only: true) end + def exempt? + IdentityConfig.store.sign_in_recaptcha_score_threshold.zero? || + ab_test_bucket != :sign_in_recaptcha || + device.present? + end + private def validate_recaptcha_result @@ -35,7 +48,7 @@ def device end def score_threshold - if IdentityConfig.store.sign_in_recaptcha_score_threshold.zero? || device.present? + if exempt? 0.0 else IdentityConfig.store.sign_in_recaptcha_score_threshold diff --git a/app/forms/webauthn_setup_form.rb b/app/forms/webauthn_setup_form.rb index 7148c980d33..297b6fa71c1 100644 --- a/app/forms/webauthn_setup_form.rb +++ b/app/forms/webauthn_setup_form.rb @@ -61,7 +61,7 @@ def generic_error_message private - attr_reader :success, :transports, :invalid_transports, :protocol + attr_reader :success, :transports, :aaguid, :invalid_transports, :protocol attr_accessor :user, :challenge, :attestation_object, :client_data_json, :name, :platform_authenticator, :authenticator_data_flags, :device_name @@ -110,6 +110,7 @@ def valid_attestation_response?(protocol) ) begin + @aaguid = attestation_response.authenticator_data.aaguid attestation_response.valid?(@challenge.pack('c*'), original_origin) rescue StandardError false @@ -141,6 +142,7 @@ def create_webauthn_configuration platform_authenticator: platform_authenticator, transports: transports.presence, authenticator_data_flags: authenticator_data_flags, + aaguid: aaguid, ) end @@ -172,6 +174,7 @@ def extra_analytics_attributes pii_like_keypaths: [[:mfa_method_counts, :phone]], authenticator_data_flags: authenticator_data_flags, unknown_transports: invalid_transports.presence, + aaguid: aaguid, }.compact end end diff --git a/app/forms/webauthn_verification_form.rb b/app/forms/webauthn_verification_form.rb index 9dec32f69fe..686770007ff 100644 --- a/app/forms/webauthn_verification_form.rb +++ b/app/forms/webauthn_verification_form.rb @@ -92,6 +92,7 @@ def valid_assertion_response? client_data_json.blank? || signature.blank? || challenge.blank? + WebAuthn::AuthenticatorAssertionResponse.new( authenticator_data: Base64.decode64(authenticator_data), client_data_json: Base64.decode64(client_data_json), @@ -165,6 +166,7 @@ def extra_analytics_attributes { webauthn_configuration_id: webauthn_configuration&.id, frontend_error: webauthn_error.presence, + webauthn_aaguid: webauthn_configuration&.aaguid, }.compact end end diff --git a/app/jobs/reports/fraud_metrics_report.rb b/app/jobs/reports/fraud_metrics_report.rb index 33aa19c5685..704d9beb6fd 100644 --- a/app/jobs/reports/fraud_metrics_report.rb +++ b/app/jobs/reports/fraud_metrics_report.rb @@ -29,7 +29,7 @@ def perform(date = Time.zone.yesterday.end_of_day) ReportMailer.tables_report( email: email_addresses, - subject: "Fraud Metrics Report - #{date.to_date}", + subject: "Fraud Metrics Report - #{report_date.to_date}", reports: reports, message: preamble, attachment_format: :xlsx, @@ -60,9 +60,7 @@ def preamble(env: Identity::Hostdata.env || 'local') end def reports - @reports ||= [ - fraud_metrics_lg99_report.as_emailable_reports, - ] + @reports ||= fraud_metrics_lg99_report.as_emailable_reports end def fraud_metrics_lg99_report diff --git a/app/jobs/resolution_proofing_job.rb b/app/jobs/resolution_proofing_job.rb index a0959d4f7bc..44401a8914e 100644 --- a/app/jobs/resolution_proofing_job.rb +++ b/app/jobs/resolution_proofing_job.rb @@ -73,6 +73,16 @@ def perform( device_profiling_success: callback_log_data&.device_profiling_success, timing: timer.results, ) + + if IdentityConfig.store.idv_socure_shadow_mode_enabled + SocureShadowModeProofingJob.perform_later( + document_capture_session_result_id: document_capture_session.result_id, + encrypted_arguments:, + service_provider_issuer:, + user_email: user_email_for_proofing(user), + user_uuid: user.uuid, + ) + end end private @@ -89,7 +99,7 @@ def make_vendor_proofing_requests( ) result = progressive_proofer.proof( applicant_pii: applicant_pii, - user_email: user.confirmed_email_addresses.first.email, + user_email: user_email_for_proofing(user), threatmetrix_session_id: threatmetrix_session_id, request_ip: request_ip, ipp_enrollment_in_progress: ipp_enrollment_in_progress, @@ -109,6 +119,10 @@ def make_vendor_proofing_requests( ) end + def user_email_for_proofing(user) + user.confirmed_email_addresses.first.email + end + def log_threatmetrix_info(threatmetrix_result, user) logger_info_hash( name: 'ThreatMetrix', diff --git a/app/jobs/socure_shadow_mode_proofing_job.rb b/app/jobs/socure_shadow_mode_proofing_job.rb new file mode 100644 index 00000000000..fc39a3280c2 --- /dev/null +++ b/app/jobs/socure_shadow_mode_proofing_job.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +class SocureShadowModeProofingJob < ApplicationJob + include JobHelpers::StaleJobHelper + + queue_as :low + + discard_on JobHelpers::StaleJobHelper::StaleJobError + + # @param [String] document_capture_session_result_id + # @param [String] encrypted_arguments + # @param [String,nil] service_provider_issuer + # @param [String] user_email + # @param [String] user_uuid + def perform( + document_capture_session_result_id:, + encrypted_arguments:, + service_provider_issuer:, + user_email:, + user_uuid: + ) + raise_stale_job! if stale_job?(enqueued_at) + + user = User.find_by(uuid: user_uuid) + raise "User not found: #{user_uuid}" if !user + + analytics = create_analytics( + user:, + service_provider_issuer:, + ) + + proofing_result = load_proofing_result(document_capture_session_result_id:) + if !proofing_result + analytics.idv_socure_shadow_mode_proofing_result_missing + return + end + + applicant = build_applicant(encrypted_arguments:, user_email:) + + socure_result = proofer.proof(applicant) + + analytics.idv_socure_shadow_mode_proofing_result( + resolution_result: format_proofing_result_for_logs(proofing_result), + socure_result: socure_result.to_h, + user_id: user.uuid, + pii_like_keypaths: [ + [:errors, :ssn], + [:resolution_result, :context, :stages, :resolution, :errors, :ssn], + [:resolution_result, :context, :stages, :residential_address, :errors, :ssn], + [:resolution_result, :context, :stages, :threatmetrix, :response_body, :first_name], + [:resolution_result, :context, :stages, :state_id, :state_id_jurisdiction], + ], + ) + end + + def create_analytics( + user:, + service_provider_issuer: + ) + Analytics.new( + user:, + request: nil, + sp: service_provider_issuer, + session: {}, + ) + end + + def format_proofing_result_for_logs(proofing_result) + proofing_result.to_h.tap do |hash| + hash.dig(:context, :stages, :threatmetrix)&.delete(:response_body) + end + end + + def load_proofing_result(document_capture_session_result_id:) + DocumentCaptureSession.new( + result_id: document_capture_session_result_id, + ).load_proofing_result&.result + end + + def build_applicant( + encrypted_arguments:, + user_email: + ) + decrypted_arguments = JSON.parse( + Encryption::Encryptors::BackgroundProofingArgEncryptor.new.decrypt(encrypted_arguments), + symbolize_names: true, + ) + + applicant_pii = decrypted_arguments[:applicant_pii] + + { + **applicant_pii.slice( + :first_name, + :last_name, + :address1, + :address2, + :city, + :state, + :zipcode, + :phone, + :dob, + :ssn, + ), + email: user_email, + } + end + + def proofer + @proofer ||= Proofing::Socure::IdPlus::Proofer.new( + Proofing::Socure::IdPlus::Config.new( + api_key: IdentityConfig.store.socure_idplus_api_key, + base_url: IdentityConfig.store.socure_idplus_base_url, + timeout: IdentityConfig.store.socure_idplus_timeout_in_seconds, + ), + ) + end +end diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index d1a2b002959..40ff7c31086 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -430,7 +430,8 @@ def edit_password_visit(required_password_change: false, **extra) # @param [String] user_id # @param [Boolean] user_locked_out if the user is currently locked out of their second factor # @param [Boolean] rate_limited Whether the user has exceeded user IP rate limiting - # @param [Boolean] valid_captcha_result Whether user passed the reCAPTCHA check + # @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 [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 @@ -443,6 +444,7 @@ def email_and_password_auth( user_locked_out:, rate_limited:, valid_captcha_result:, + captcha_validation_performed:, bad_password_count:, sp_request_url_present:, remember_device:, @@ -456,6 +458,7 @@ def email_and_password_auth( user_locked_out:, rate_limited:, valid_captcha_result:, + captcha_validation_performed:, bad_password_count:, sp_request_url_present:, remember_device:, @@ -1858,6 +1861,7 @@ def idv_doc_auth_welcome_visited(step:, analytics_id:, skip_hybrid_handoff: nil, # @param [String, nil] deactivation_reason Reason user's profile was deactivated, if any. # @param [String,nil] active_profile_idv_level ID verification level of user's active profile. # @param [String,nil] pending_profile_idv_level ID verification level of user's pending profile. + # @param [Integer,nil] proofing_workflow_time_in_seconds The time since starting proofing # @identity.idp.previous_event_name IdV: review info visited def idv_enter_password_submitted( success:, @@ -1871,6 +1875,7 @@ def idv_enter_password_submitted( proofing_components: nil, active_profile_idv_level: nil, pending_profile_idv_level: nil, + proofing_workflow_time_in_seconds: nil, **extra ) track_event( @@ -1886,6 +1891,7 @@ def idv_enter_password_submitted( proofing_components:, active_profile_idv_level:, pending_profile_idv_level:, + proofing_workflow_time_in_seconds:, **extra, ) end @@ -1946,6 +1952,7 @@ def idv_enter_password_visited( # @param [String,nil] active_profile_idv_level ID verification level of user's active profile. # @param [String,nil] pending_profile_idv_level ID verification level of user's pending profile. # @param [Array,nil] profile_history Array of user's profiles (oldest to newest). + # @param [Integer,nil] proofing_workflow_time_in_seconds The time since starting proofing # @see Reporting::IdentityVerificationReport#query This event is used by the identity verification # report. Changes here should be reflected there. # Tracks the last step of IDV, indicates the user successfully proofed @@ -1962,6 +1969,7 @@ def idv_final( active_profile_idv_level: nil, pending_profile_idv_level: nil, profile_history: nil, + proofing_workflow_time_in_seconds: nil, **extra ) track_event( @@ -1978,6 +1986,7 @@ def idv_final( active_profile_idv_level:, pending_profile_idv_level:, profile_history:, + proofing_workflow_time_in_seconds:, **extra, ) end @@ -4195,6 +4204,28 @@ def idv_session_error_visited( ) end + # Logs a Socure KYC result alongside a resolution result for later comparison. + # @param [Hash] socure_result Result from Socure KYC API call + # @param [Hash] resolution_result Result from resolution proofing + def idv_socure_shadow_mode_proofing_result( + socure_result:, + resolution_result:, + **extra + ) + track_event( + :idv_socure_shadow_mode_proofing_result, + resolution_result: resolution_result.to_h, + socure_result: socure_result.to_h, + **extra, + ) + end + + # Indicates that no proofing result was found when SocureShadowModeProofingJob + # attempted to look for one. + def idv_socure_shadow_mode_proofing_result_missing(**extra) + track_event(:idv_socure_shadow_mode_proofing_result_missing, **extra) + end + # @param [String] step # @param [String] location # @param [Hash,nil] proofing_components User's current proofing components diff --git a/app/services/encryption/contextless_kms_client.rb b/app/services/encryption/contextless_kms_client.rb index 3500b968fcb..392f9d0e34c 100644 --- a/app/services/encryption/contextless_kms_client.rb +++ b/app/services/encryption/contextless_kms_client.rb @@ -18,14 +18,14 @@ class ContextlessKmsClient KMS: 'KMSx', }.freeze - def encrypt(plaintext) - KmsLogger.log(:encrypt, key_id: IdentityConfig.store.aws_kms_key_id) + def encrypt(plaintext, log_context: nil) + KmsLogger.log(:encrypt, key_id: IdentityConfig.store.aws_kms_key_id, log_context: log_context) return encrypt_kms(plaintext) if FeatureManagement.use_kms? encrypt_local(plaintext) end - def decrypt(ciphertext) - KmsLogger.log(:decrypt, key_id: IdentityConfig.store.aws_kms_key_id) + def decrypt(ciphertext, log_context: nil) + KmsLogger.log(:decrypt, key_id: IdentityConfig.store.aws_kms_key_id, log_context: log_context) return decrypt_kms(ciphertext) if use_kms?(ciphertext) decrypt_local(ciphertext) end diff --git a/app/services/encryption/kms_client.rb b/app/services/encryption/kms_client.rb index 308606a7d1c..55d44d24cd5 100644 --- a/app/services/encryption/kms_client.rb +++ b/app/services/encryption/kms_client.rb @@ -38,7 +38,9 @@ def encrypt(plaintext, encryption_context) end def decrypt(ciphertext, encryption_context) - return decrypt_contextless_kms(ciphertext) if self.class.looks_like_contextless?(ciphertext) + if self.class.looks_like_contextless?(ciphertext) + return decrypt_contextless_kms(ciphertext, encryption_context) + end KmsLogger.log(:decrypt, context: encryption_context, key_id: kms_key_id) return decrypt_kms(ciphertext, encryption_context) if use_kms?(ciphertext) decrypt_local(ciphertext, encryption_context) @@ -135,8 +137,8 @@ def local_encryption_key(encryption_context) ) end - def decrypt_contextless_kms(ciphertext) - ContextlessKmsClient.new.decrypt(ciphertext) + def decrypt_contextless_kms(ciphertext, encryption_context) + ContextlessKmsClient.new.decrypt(ciphertext, log_context: encryption_context) end # chunk plaintext into ~4096 byte chunks, but not less than 1024 bytes in a chunk if chunking. diff --git a/app/services/encryption/kms_logger.rb b/app/services/encryption/kms_logger.rb index 4276f9d2e98..9824a4ef9ff 100644 --- a/app/services/encryption/kms_logger.rb +++ b/app/services/encryption/kms_logger.rb @@ -2,11 +2,12 @@ module Encryption class KmsLogger - def self.log(action, key_id:, context: nil) + def self.log(action, key_id:, context: nil, log_context: nil) output = { kms: { action: action, encryption_context: context, + log_context: log_context, key_id: key_id, }, log_filename: Idp::Constants::KMS_LOG_FILENAME, diff --git a/app/services/encryption/password_verifier.rb b/app/services/encryption/password_verifier.rb index 95f0a52d6e9..7e5e80fc05d 100644 --- a/app/services/encryption/password_verifier.rb +++ b/app/services/encryption/password_verifier.rb @@ -73,7 +73,7 @@ def create_digest_pair(password:, user_uuid:) def verify(password:, digest_pair:, user_uuid:) digest = digest_pair.multi_or_single_region_ciphertext password_digest = PasswordDigest.parse_from_string(digest) - return verify_uak_digest(password, digest) if stale_digest?(digest) + return verify_uak_digest(password, digest) if password_digest.uak_password_digest? verify_password_against_digest( password: password, diff --git a/app/services/encryption/user_access_key.rb b/app/services/encryption/user_access_key.rb index f3b9363c7e5..bf0887648f8 100644 --- a/app/services/encryption/user_access_key.rb +++ b/app/services/encryption/user_access_key.rb @@ -45,7 +45,7 @@ def unlock(encryption_key_arg) self.masked_ciphertext = Base64.strict_decode64(encryption_key_arg) z1_padded = z1.dup.rjust(masked_ciphertext.length, '0') encrypted_random_r = xor(z1_padded, masked_ciphertext) - self.random_r = kms_client.decrypt(encrypted_random_r) + self.random_r = kms_client.decrypt(encrypted_random_r, log_context: 'user-access-key') self.cek = OpenSSL::Digest::SHA256.hexdigest(z2 + random_r) self end diff --git a/app/services/idv/session.rb b/app/services/idv/session.rb index 9e76d23f2d6..e1c12d0e067 100644 --- a/app/services/idv/session.rb +++ b/app/services/idv/session.rb @@ -22,6 +22,7 @@ class Session phone_for_mobile_flow previous_phone_step_params profile_id + proofing_started_at redo_document_capture resolution_successful selfie_check_performed @@ -205,6 +206,10 @@ def add_failed_phone_step_number(phone) failed_phone_step_numbers << phone_e164 if !failed_phone_step_numbers.include?(phone_e164) end + def proofing_workflow_time_in_seconds + Time.zone.now - Time.zone.parse(proofing_started_at) if proofing_started_at.present? + end + def pii_from_user_in_flow_session user_session.dig('idv/in_person', :pii_from_user) end diff --git a/app/services/proofing/resolution/result.rb b/app/services/proofing/resolution/result.rb index f8d07ea6398..0978ce995bb 100644 --- a/app/services/proofing/resolution/result.rb +++ b/app/services/proofing/resolution/result.rb @@ -61,6 +61,7 @@ def to_h attributes_requiring_additional_verification, vendor_name: vendor_name, vendor_workflow: vendor_workflow, + verified_attributes: verified_attributes, } end diff --git a/app/views/users/service_provider_revoke/show.html.erb b/app/views/users/service_provider_revoke/show.html.erb index b68e5f2c448..ddd83eef0ce 100644 --- a/app/views/users/service_provider_revoke/show.html.erb +++ b/app/views/users/service_provider_revoke/show.html.erb @@ -11,8 +11,6 @@ <%= button_to(service_provider_revoke_url(@service_provider.id), method: 'delete', class: 'usa-button usa-button--wide usa-button--big margin-top-4') { t('forms.buttons.continue') } %> -
-
- <%= link_to(t('links.cancel'), account_url) %> -
-
+<%= render PageFooterComponent.new do %> + <%= link_to(t('links.cancel'), account_connected_accounts_path) %> +<% end %> diff --git a/config/application.yml.default b/config/application.yml.default index 039fd0d3300..f2993cb3feb 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -143,6 +143,7 @@ idv_max_attempts: 5 idv_min_age_years: 13 idv_send_link_attempt_window_in_minutes: 10 idv_send_link_max_attempts: 5 +idv_socure_shadow_mode_enabled: false idv_sp_required: false in_person_completion_survey_url: 'https://login.gov' in_person_doc_auth_button_enabled: true @@ -332,11 +333,15 @@ short_term_phone_otp_max_attempt_window_in_seconds: 10 short_term_phone_otp_max_attempts: 2 show_unsupported_passkey_platform_authentication_setup: false show_user_attribute_deprecation_warnings: false +sign_in_recaptcha_percent_tested: 0 sign_in_recaptcha_score_threshold: 0.0 sign_in_user_id_per_ip_attempt_window_exponential_factor: 1.1 sign_in_user_id_per_ip_attempt_window_in_minutes: 720 sign_in_user_id_per_ip_attempt_window_max_minutes: 43_200 sign_in_user_id_per_ip_max_attempts: 50 +socure_idplus_api_key: '' +socure_idplus_base_url: '' +socure_idplus_timeout_in_seconds: 5 socure_webhook_enabled: false socure_webhook_secret_key: '' socure_webhook_secret_key_queue: '[]' @@ -428,10 +433,10 @@ development: secret_key_base: development_secret_key_base session_encryption_key: 27bad3c25711099429c1afdfd1890910f3b59f5a4faec1c85e945cb8b02b02f261ba501d99cfbb4fab394e0102de6fecf8ffe260f322f610db3e96b2a775c120 show_unsupported_passkey_platform_authentication_setup: true + sign_in_recaptcha_percent_tested: 100 sign_in_recaptcha_score_threshold: 0.3 skip_encryption_allowed_list: '["urn:gov:gsa:SAML:2.0.profiles:sp:sso:localhost"]' - socure_webhook_secret_key: 'secret-key' - socure_webhook_secret_key_queue: '["old-key-one", "old-key-two"]' + socure_idplus_base_url: 'https://sandbox.socure.us' state_tracking_enabled: true telephony_adapter: test use_dashboard_service_providers: true diff --git a/config/initializers/ab_tests.rb b/config/initializers/ab_tests.rb index 70fdddd95c2..7150e1d1e14 100644 --- a/config/initializers/ab_tests.rb +++ b/config/initializers/ab_tests.rb @@ -62,4 +62,21 @@ def self.all ) do |service_provider:, session:, user:, user_session:, **| document_capture_session_uuid_discriminator(service_provider:, session:, user:, user_session:) end.freeze + + RECAPTCHA_SIGN_IN = AbTest.new( + experiment_name: 'reCAPTCHA at Sign-In', + should_log: [ + 'Email and Password Authentication', + 'IdV: doc auth verify proofing results', + 'reCAPTCHA verify result received', + :idv_enter_password_submitted, + ].to_set, + buckets: { sign_in_recaptcha: IdentityConfig.store.sign_in_recaptcha_percent_tested }, + ) do |user:, user_session:, **| + if user_session&.[](:captcha_validation_performed_at_sign_in) == false + nil + else + user&.uuid + end + end.freeze end diff --git a/lib/ab_test.rb b/lib/ab_test.rb index 840cd1cc828..1cc266c215f 100644 --- a/lib/ab_test.rb +++ b/lib/ab_test.rb @@ -60,6 +60,8 @@ def bucket(request:, service_provider:, session:, user:, user_session:) def include_in_analytics_event?(event_name) if should_log.is_a?(Regexp) should_log.match?(event_name) + elsif should_log.respond_to?(:include?) + should_log.include?(event_name) elsif !should_log.nil? raise 'Unexpected value used for should_log' else diff --git a/lib/identity_config.rb b/lib/identity_config.rb index cfa409bbd2f..d4af025ba85 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -165,6 +165,7 @@ def self.store config.add(:idv_min_age_years, type: :integer) config.add(:idv_send_link_attempt_window_in_minutes, type: :integer) config.add(:idv_send_link_max_attempts, type: :integer) + config.add(:idv_socure_shadow_mode_enabled, 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) @@ -360,6 +361,9 @@ def self.store config.add(:s3_reports_enabled, type: :boolean) config.add(:saml_endpoint_configs, type: :json, options: { symbolize_names: true }) config.add(:saml_secret_rotation_enabled, type: :boolean) + config.add(:socure_idplus_api_key, type: :string) + config.add(:socure_idplus_base_url, type: :string) + config.add(:socure_idplus_timeout_in_seconds, type: :integer) config.add(:scrypt_cost, type: :string) config.add(:second_mfa_reminder_account_age_in_days, type: :integer) config.add(:second_mfa_reminder_sign_in_count, type: :integer) @@ -382,6 +386,7 @@ def self.store config.add(:sign_in_user_id_per_ip_attempt_window_in_minutes, type: :integer) config.add(:sign_in_user_id_per_ip_attempt_window_max_minutes, type: :integer) config.add(:sign_in_user_id_per_ip_max_attempts, type: :integer) + config.add(:sign_in_recaptcha_percent_tested, type: :integer) config.add(:sign_in_recaptcha_score_threshold, type: :float) config.add(:skip_encryption_allowed_list, type: :json) config.add(:socure_webhook_enabled, type: :boolean) diff --git a/lib/reporting/fraud_metrics_lg99_report.rb b/lib/reporting/fraud_metrics_lg99_report.rb index b3c26f5d4ae..fbcad448f23 100644 --- a/lib/reporting/fraud_metrics_lg99_report.rb +++ b/lib/reporting/fraud_metrics_lg99_report.rb @@ -51,22 +51,30 @@ def progress? end def as_emailable_reports - Reporting::EmailableReport.new( - title: 'LG-99 Metrics', - table: lg99_metrics_table, - filename: 'lg99_metrics', - ) + [ + Reporting::EmailableReport.new( + title: "Monthly LG-99 Metrics #{stats_month}", + table: lg99_metrics_table, + filename: 'lg99_metrics', + ), + Reporting::EmailableReport.new( + title: "Monthly Suspended User Metrics #{stats_month}", + table: suspended_metrics_table, + filename: 'suspended_metrics', + ), + Reporting::EmailableReport.new( + title: "Monthly Reinstated User Metrics #{stats_month}", + table: reinstated_metrics_table, + filename: 'reinstated_metrics', + ), + ] end def lg99_metrics_table [ - ['Metric', 'Total'], - ['Unique users seeing LG-99', lg99_unique_users_count.to_s], - ['Unique users suspended', unique_suspended_users_count.to_s], - ['Average Days Creation to Suspension', user_days_to_suspension_avg.to_s], - ['Average Days Proofed to Suspension', user_days_proofed_to_suspension_avg.to_s], - ['Unique users reinstated', unique_reinstated_users_count.to_s], - ['Average Days to Reinstatement', user_days_to_reinstatement_avg.to_s], + ['Metric', 'Total', 'Range Start', 'Range End'], + ['Unique users seeing LG-99', lg99_unique_users_count.to_s, time_range.begin.to_s, + time_range.end.to_s], ] rescue Aws::CloudWatchLogs::Errors::ThrottlingException => err [ @@ -75,12 +83,50 @@ def lg99_metrics_table ] end - def to_csv - CSV.generate do |csv| - lg99_metrics_table.each do |row| - csv << row - end - end + def suspended_metrics_table + [ + ['Metric', 'Total', 'Range Start', 'Range End'], + [ + 'Unique users suspended', + unique_suspended_users_count.to_s, + time_range.begin.to_s, + time_range.end.to_s, + ], + [ + 'Average Days Creation to Suspension', + user_days_to_suspension_avg.to_s, + time_range.begin.to_s, + time_range.end.to_s, + ], + [ + 'Average Days Proofed to Suspension', + user_days_proofed_to_suspension_avg.to_s, + time_range.begin.to_s, + time_range.end.to_s, + ], + ] + end + + def reinstated_metrics_table + [ + ['Metric', 'Total', 'Range Start', 'Range End'], + [ + 'Unique users reinstated', + unique_reinstated_users_count.to_s, + time_range.begin.to_s, + time_range.end.to_s, + ], + [ + 'Average Days to Reinstatement', + user_days_to_reinstatement_avg.to_s, + time_range.begin.to_s, + time_range.end.to_s, + ], + ] + end + + def stats_month + time_range.begin.strftime('%b-%Y') end # event name => set(user ids) diff --git a/package.json b/package.json index 521c35d6c8d..a95a3c5ac8a 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "cleave.js": "^1.6.0", "concurrently": "^8.2.2", "core-js": "^3.21.1", - "fast-glob": "^3.2.7", + "fast-glob": "^3.3.2", "foundation-emails": "^2.3.1", "intl-tel-input": "^17.0.19", "react": "^17.0.2", diff --git a/spec/config/initializers/ab_tests_spec.rb b/spec/config/initializers/ab_tests_spec.rb index f858344346d..975e12b1c37 100644 --- a/spec/config/initializers/ab_tests_spec.rb +++ b/spec/config/initializers/ab_tests_spec.rb @@ -1,6 +1,8 @@ require 'rails_helper' RSpec.describe AbTests do + include AbTestsHelper + describe '#all' do it 'returns all registered A/B tests' do expect(AbTests.all.values).to all(be_kind_of(AbTest)) @@ -157,10 +159,64 @@ it_behaves_like 'an A/B test that uses document_capture_session_uuid as a discriminator' end - def reload_ab_tests - AbTests.all.each do |(name, _)| - AbTests.send(:remove_const, name) + describe 'RECAPTCHA_SIGN_IN' do + let(:user) { nil } + let(:user_session) { {} } + + subject(:bucket) do + AbTests::RECAPTCHA_SIGN_IN.bucket( + request: nil, + service_provider: nil, + session: nil, + user:, + user_session:, + ) + end + + context 'when A/B test is disabled' do + before do + allow(IdentityConfig.store).to receive(:sign_in_recaptcha_percent_tested).and_return(0) + reload_ab_tests + end + + context 'when it would otherwise assign a bucket' do + let(:user) { build(:user) } + + it 'does not return a bucket' do + expect(bucket).to be_nil + end + end + end + + context 'when A/B test is enabled' do + before do + allow(IdentityConfig.store).to receive(:sign_in_recaptcha_percent_tested).and_return(100) + reload_ab_tests + end + + context 'with no associated user' do + let(:user) { nil } + + it 'does not return a bucket' do + expect(bucket).to be_nil + end + end + + context 'with an associated user' do + let(:user) { build(:user) } + + it 'returns a bucket' do + expect(bucket).not_to be_nil + end + + context 'with user session indicating recaptcha was not performed at sign-in' do + let(:user_session) { { captcha_validation_performed_at_sign_in: false } } + + it 'does not return a bucket' do + expect(bucket).to be_nil + end + end + end end - load('config/initializers/ab_tests.rb') end end diff --git a/spec/controllers/concerns/ab_testing_concern_spec.rb b/spec/controllers/concerns/ab_testing_concern_spec.rb index 94898cd827e..cb5e546a064 100644 --- a/spec/controllers/concerns/ab_testing_concern_spec.rb +++ b/spec/controllers/concerns/ab_testing_concern_spec.rb @@ -68,5 +68,21 @@ end.to raise_error RuntimeError, 'Unknown A/B test: NOT_A_REAL_TEST' end end + + context 'with user keyword argument' do + let(:other_user) { build(:user) } + + it 'returns bucket determined using given user' do + expect(ab_test).to receive(:bucket).with( + user: other_user, + request:, + service_provider: service_provider.issuer, + session:, + user_session:, + ).and_call_original + + subject.ab_test_bucket(:TEST_TEST, user: other_user) + end + end end end diff --git a/spec/controllers/idv/enter_password_controller_spec.rb b/spec/controllers/idv/enter_password_controller_spec.rb index e4cf07dc023..a8e760ce11e 100644 --- a/spec/controllers/idv/enter_password_controller_spec.rb +++ b/spec/controllers/idv/enter_password_controller_spec.rb @@ -27,6 +27,7 @@ allow(IdentityConfig.store).to receive(:usps_mock_fallback).and_return(false) allow(subject).to receive(:ab_test_analytics_buckets).and_return(ab_test_args) subject.idv_session.welcome_visited = true + subject.idv_session.proofing_started_at = 5.minutes.ago.iso8601 subject.idv_session.idv_consent_given = true subject.idv_session.flow_path = 'standard' subject.idv_session.pii_from_doc = Pii::StateId.new(**Idp::Constants::MOCK_IDV_APPLICANT) @@ -283,7 +284,7 @@ def show end end - it 'redirects to personal key path' do + it 'redirects to personal key path', :freeze_time do put :create, params: { user: { password: ControllerHelper::VALID_PASSWORD } } expect(@analytics).to have_logged_event( @@ -294,6 +295,7 @@ def show fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: false, + proofing_workflow_time_in_seconds: 5.minutes.to_i, **ab_test_args, ), ) diff --git a/spec/controllers/idv/welcome_controller_spec.rb b/spec/controllers/idv/welcome_controller_spec.rb index bd32fefbc61..269632a3177 100644 --- a/spec/controllers/idv/welcome_controller_spec.rb +++ b/spec/controllers/idv/welcome_controller_spec.rb @@ -64,15 +64,30 @@ ) end + it 'sets the proofing started timestamp', :freeze_time do + get :show + + expect(subject.idv_session.proofing_started_at).to eq(Time.zone.now.iso8601) + end + context 'welcome already visited' do - it 'does not redirect to agreement' do + before do subject.idv_session.welcome_visited = true + subject.idv_session.proofing_started_at = 5.minutes.ago.iso8601 + end + it 'does not redirect to agreement' do get :show expect(response).to render_template('idv/welcome/show') end + it 'does not overwrite the proofing started timestamp' do + get :show + + expect(subject.idv_session.proofing_started_at).to eq(5.minutes.ago.iso8601) + end + context 'and verify info already completed' do before do subject.idv_session.flow_path = 'standard' diff --git a/spec/controllers/users/sessions_controller_spec.rb b/spec/controllers/users/sessions_controller_spec.rb index 387df9187b0..9d9a42732f2 100644 --- a/spec/controllers/users/sessions_controller_spec.rb +++ b/spec/controllers/users/sessions_controller_spec.rb @@ -3,6 +3,7 @@ RSpec.describe Users::SessionsController, devise: true do include ActionView::Helpers::DateHelper include ActionView::Helpers::UrlHelper + include AbTestsHelper let(:mock_valid_site) { 'http://example.com' } @@ -55,6 +56,7 @@ user_locked_out: false, rate_limited: false, valid_captcha_result: true, + captcha_validation_performed: false, bad_password_count: 0, sp_request_url_present: false, remember_device: false, @@ -128,6 +130,7 @@ user_locked_out: false, rate_limited: false, valid_captcha_result: true, + captcha_validation_performed: false, bad_password_count: 0, sp_request_url_present: false, remember_device: false, @@ -211,6 +214,7 @@ user_locked_out: false, rate_limited: true, valid_captcha_result: true, + captcha_validation_performed: false, bad_password_count: 8, sp_request_url_present: false, remember_device: false, @@ -233,6 +237,7 @@ user_locked_out: false, rate_limited: false, valid_captcha_result: true, + captcha_validation_performed: false, bad_password_count: 1, sp_request_url_present: false, remember_device: false, @@ -253,6 +258,7 @@ user_locked_out: false, rate_limited: false, valid_captcha_result: true, + captcha_validation_performed: false, bad_password_count: 1, sp_request_url_present: false, remember_device: false, @@ -277,45 +283,50 @@ user_locked_out: true, rate_limited: false, valid_captcha_result: true, + captcha_validation_performed: false, bad_password_count: 0, sp_request_url_present: false, remember_device: false, ) end - it 'tracks unsuccessful authentication for failed reCAPTCHA' do - user = create(:user, :fully_registered) + context 'with reCAPTCHA validation enabled' do + before do + allow(FeatureManagement).to receive(:sign_in_recaptcha_enabled?).and_return(true) + allow(IdentityConfig.store).to receive(:recaptcha_mock_validator).and_return(true) + allow(IdentityConfig.store).to receive(:sign_in_recaptcha_score_threshold).and_return(0.2) + allow(controller).to receive(:ab_test_bucket).with(:RECAPTCHA_SIGN_IN, kind_of(Hash)). + and_return(:sign_in_recaptcha) + end - allow(FeatureManagement).to receive(:sign_in_recaptcha_enabled?).and_return(true) - allow(IdentityConfig.store).to receive(:recaptcha_mock_validator).and_return(true) - allow(IdentityConfig.store).to receive(:sign_in_recaptcha_score_threshold).and_return(0.2) - stub_analytics + it 'tracks unsuccessful authentication for failed reCAPTCHA' do + user = create(:user, :fully_registered) - post :create, params: { user: { email: user.email, password: user.password, score: 0.1 } } + stub_analytics - expect(@analytics).to have_logged_event( - 'Email and Password Authentication', - success: false, - user_id: user.uuid, - user_locked_out: false, - rate_limited: false, - valid_captcha_result: false, - bad_password_count: 0, - remember_device: false, - sp_request_url_present: false, - ) - end + post :create, params: { user: { email: user.email, password: user.password, score: 0.1 } } - it 'redirects unsuccessful authentication for failed reCAPTCHA to failed page' do - user = create(:user, :fully_registered) + expect(@analytics).to have_logged_event( + 'Email and Password Authentication', + success: false, + user_id: user.uuid, + user_locked_out: false, + rate_limited: false, + valid_captcha_result: false, + captcha_validation_performed: true, + bad_password_count: 0, + remember_device: false, + sp_request_url_present: false, + ) + end - allow(FeatureManagement).to receive(:sign_in_recaptcha_enabled?).and_return(true) - allow(IdentityConfig.store).to receive(:recaptcha_mock_validator).and_return(true) - allow(IdentityConfig.store).to receive(:sign_in_recaptcha_score_threshold).and_return(0.2) + it 'redirects unsuccessful authentication for failed reCAPTCHA to failed page' do + user = create(:user, :fully_registered) - post :create, params: { user: { email: user.email, password: user.password, score: 0.1 } } + post :create, params: { user: { email: user.email, password: user.password, score: 0.1 } } - expect(response).to redirect_to sign_in_security_check_failed_url + expect(response).to redirect_to sign_in_security_check_failed_url + end end it 'tracks count of multiple unsuccessful authentication attempts' do @@ -335,6 +346,7 @@ user_locked_out: false, rate_limited: false, valid_captcha_result: true, + captcha_validation_performed: false, bad_password_count: 2, sp_request_url_present: false, remember_device: false, @@ -354,6 +366,7 @@ user_locked_out: false, rate_limited: false, valid_captcha_result: true, + captcha_validation_performed: false, bad_password_count: 1, sp_request_url_present: true, remember_device: false, @@ -525,6 +538,7 @@ user_locked_out: false, rate_limited: false, valid_captcha_result: true, + captcha_validation_performed: false, bad_password_count: 0, sp_request_url_present: false, remember_device: false, @@ -650,6 +664,7 @@ user_locked_out: false, rate_limited: false, valid_captcha_result: true, + captcha_validation_performed: false, bad_password_count: 0, sp_request_url_present: false, remember_device: true, @@ -677,6 +692,7 @@ user_locked_out: false, rate_limited: false, valid_captcha_result: true, + captcha_validation_performed: false, bad_password_count: 0, sp_request_url_present: false, remember_device: true, diff --git a/spec/features/account_connected_apps_spec.rb b/spec/features/account_connected_apps_spec.rb index 7588b9dac78..0674a4c3ac8 100644 --- a/spec/features/account_connected_apps_spec.rb +++ b/spec/features/account_connected_apps_spec.rb @@ -1,6 +1,8 @@ require 'rails_helper' RSpec.describe 'Account connected applications' do + include NavigationHelper + let(:user) { create(:user, :fully_registered, created_at: Time.zone.now - 100.days) } let(:identity_with_link) do create( @@ -30,13 +32,15 @@ before do sign_in_and_2fa_user(user) build_account_connected_apps - visit account_connected_accounts_path + within_sidenav { click_on t('account.navigation.connected_accounts') } end scenario 'viewing account connected applications' do expect(page).to have_content(t('headings.account.connected_accounts')) - visit account_history_path + expect(identity_without_link_timestamp).to appear_before(identity_with_link_timestamp) + + within_sidenav { click_on t('account.navigation.history') } expect(page).to have_content( t('event_types.authenticated_at', service_provider: identity_without_link.display_name), ) @@ -51,31 +55,29 @@ expect(page).to have_link( identity_with_link.display_name, href: 'http://localhost:3000' ) - - visit account_connected_accounts_path - expect(identity_without_link_timestamp).to appear_before(identity_with_link_timestamp) end scenario 'revoking consent from an SP' do identity_to_revoke = identity_with_link - visit account_history_path - expect(page).to have_content( - t('event_types.authenticated_at', service_provider: identity_to_revoke.display_name), - ) - - visit account_connected_accounts_path - within(find('.profile-info-box')) do - within(find('.grid-row', text: identity_to_revoke.service_provider_record.friendly_name)) do - click_link(t('account.revoke_consent.link_title')) - end + within('.profile-info-box .grid-row', text: identity_to_revoke.display_name) do + click_link(t('account.revoke_consent.link_title')) end - expect(page).to have_content(identity_to_revoke.service_provider_record.friendly_name) + expect(page).to have_content(identity_to_revoke.display_name) + + # Canceling should return to the Connected Accounts page + click_on t('links.cancel') + expect(page).to have_current_path(account_connected_accounts_path) + + # Revoke again and confirm revocation + within('.profile-info-box .grid-row', text: identity_to_revoke.display_name) do + click_link(t('account.revoke_consent.link_title')) + end click_on t('forms.buttons.continue') # Accounts page should no longer list this app in the applications section - expect(page).to_not have_content(identity_to_revoke.service_provider_record.friendly_name) + expect(page).to_not have_content(identity_to_revoke.display_name) end def build_account_connected_apps diff --git a/spec/features/idv/analytics_spec.rb b/spec/features/idv/analytics_spec.rb index 2e722d7f654..ac095c00bd5 100644 --- a/spec/features/idv/analytics_spec.rb +++ b/spec/features/idv/analytics_spec.rb @@ -71,7 +71,8 @@ can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [], vendor_name: 'ResolutionMock', - vendor_workflow: nil } + vendor_workflow: nil, + verified_attributes: nil } end let(:base_proofing_results) do @@ -95,7 +96,8 @@ timed_out: false, transaction_id: '', vendor_name: 'ResidentialAddressNotRequired', - vendor_workflow: nil }, + vendor_workflow: nil, + verified_attributes: nil }, state_id: state_id_resolution, threatmetrix: threatmetrix_response, }, @@ -124,7 +126,8 @@ can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [], vendor_name: 'ResolutionMock', - vendor_workflow: nil }, + vendor_workflow: nil, + verified_attributes: nil }, state_id: state_id_resolution, threatmetrix: threatmetrix_response, }, @@ -226,12 +229,12 @@ proofing_components: lexis_nexis_address_proofing_components, }, :idv_enter_password_submitted => { - success: true, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: false, + success: true, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: false, proofing_workflow_time_in_seconds: 0.0, active_profile_idv_level: 'legacy_unsupervised', proofing_components: lexis_nexis_address_proofing_components }, 'IdV: final resolution' => { - success: true, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: false, + success: true, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: false, proofing_workflow_time_in_seconds: 0.0, active_profile_idv_level: 'legacy_unsupervised', profile_history: match_array(kind_of(Idv::ProfileLogging)), proofing_components: lexis_nexis_address_proofing_components @@ -341,12 +344,12 @@ proofing_components: lexis_nexis_address_proofing_components, }, :idv_enter_password_submitted => { - success: true, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: false, + success: true, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: false, proofing_workflow_time_in_seconds: 0.0, active_profile_idv_level: 'legacy_unsupervised', proofing_components: lexis_nexis_address_proofing_components }, 'IdV: final resolution' => { - success: true, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: false, + success: true, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: false, proofing_workflow_time_in_seconds: 0.0, active_profile_idv_level: 'legacy_unsupervised', profile_history: match_array(kind_of(Idv::ProfileLogging)), proofing_components: lexis_nexis_address_proofing_components @@ -442,11 +445,11 @@ proofing_components: gpo_letter_proofing_components }, :idv_enter_password_submitted => { - success: true, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: true, in_person_verification_pending: false, + success: true, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: true, in_person_verification_pending: false, proofing_workflow_time_in_seconds: 0.0, proofing_components: gpo_letter_proofing_components }, 'IdV: final resolution' => { - success: true, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: true, in_person_verification_pending: false, + success: true, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: true, in_person_verification_pending: false, proofing_workflow_time_in_seconds: 0.0, # NOTE: pending_profile_idv_level should be set here, a nil value is cached for current_user.pending_profile. profile_history: match_array(kind_of(Idv::ProfileLogging)), proofing_components: gpo_letter_proofing_components @@ -558,11 +561,11 @@ proofing_components: { document_check: 'usps', source_check: 'aamva', resolution_check: 'lexis_nexis', threatmetrix: threatmetrix, threatmetrix_review_status: 'pass', address_check: 'lexis_nexis_address' }, }, :idv_enter_password_submitted => { - success: true, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: true, + success: true, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: true, proofing_workflow_time_in_seconds: 0.0, proofing_components: { document_check: 'usps', source_check: 'aamva', resolution_check: 'lexis_nexis', threatmetrix: threatmetrix, threatmetrix_review_status: 'pass', address_check: 'lexis_nexis_address' } }, 'IdV: final resolution' => { - success: true, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: true, + success: true, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: true, proofing_workflow_time_in_seconds: 0.0, # NOTE: pending_profile_idv_level should be set here, a nil value is cached for current_user.pending_profile. profile_history: match_array(kind_of(Idv::ProfileLogging)), proofing_components: { document_check: 'usps', source_check: 'aamva', resolution_check: 'lexis_nexis', threatmetrix: threatmetrix, threatmetrix_review_status: 'pass', address_check: 'lexis_nexis_address' } @@ -680,12 +683,12 @@ proofing_components: lexis_nexis_address_proofing_components, }, :idv_enter_password_submitted => { - success: true, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: false, + success: true, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: false, proofing_workflow_time_in_seconds: 0.0, active_profile_idv_level: 'unsupervised_with_selfie', proofing_components: lexis_nexis_address_proofing_components }, 'IdV: final resolution' => { - success: true, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: false, + success: true, fraud_review_pending: false, fraud_rejection: false, gpo_verification_pending: false, in_person_verification_pending: false, proofing_workflow_time_in_seconds: 0.0, active_profile_idv_level: 'unsupervised_with_selfie', profile_history: match_array(kind_of(Idv::ProfileLogging)), proofing_components: lexis_nexis_address_proofing_components diff --git a/spec/features/users/sign_in_spec.rb b/spec/features/users/sign_in_spec.rb index 215c19fe083..0cd487c1a84 100644 --- a/spec/features/users/sign_in_spec.rb +++ b/spec/features/users/sign_in_spec.rb @@ -9,6 +9,7 @@ include SpAuthHelper include IdvHelper include DocAuthHelper + include AbTestsHelper context 'service provider is on the ialmax allow list' do before do @@ -885,6 +886,12 @@ allow(FeatureManagement).to receive(:sign_in_recaptcha_enabled?).and_return(true) allow(IdentityConfig.store).to receive(:recaptcha_mock_validator).and_return(true) allow(IdentityConfig.store).to receive(:sign_in_recaptcha_score_threshold).and_return(0.2) + allow(IdentityConfig.store).to receive(:sign_in_recaptcha_percent_tested).and_return(100) + reload_ab_tests + end + + after do + reload_ab_tests end it 'redirects user to security check failed page' do diff --git a/spec/forms/sign_in_recaptcha_form_spec.rb b/spec/forms/sign_in_recaptcha_form_spec.rb index 65c7e0328f4..6887ab3b187 100644 --- a/spec/forms/sign_in_recaptcha_form_spec.rb +++ b/spec/forms/sign_in_recaptcha_form_spec.rb @@ -1,15 +1,24 @@ require 'rails_helper' +require 'query_tracker' RSpec.describe SignInRecaptchaForm do let(:user) { create(:user, :with_authenticated_device) } let(:score_threshold_config) { 0.2 } let(:analytics) { FakeAnalytics.new } let(:email) { user.email } + let(:ab_test_bucket) { :sign_in_recaptcha } let(:recaptcha_token) { 'token' } let(:device_cookie) { Random.hex } let(:score) { 1.0 } subject(:form) do - described_class.new(form_class: RecaptchaMockForm, analytics:, score:) + described_class.new( + email:, + device_cookie:, + ab_test_bucket:, + form_class: RecaptchaMockForm, + analytics:, + score:, + ) end before do allow(IdentityConfig.store).to receive(:sign_in_recaptcha_score_threshold). @@ -30,12 +39,15 @@ ). and_return(recaptcha_form) - form.submit(email:, recaptcha_token:, device_cookie:) + form.submit(recaptcha_token:) end context 'with custom recaptcha form class' do subject(:form) do described_class.new( + email:, + device_cookie:, + ab_test_bucket:, analytics:, form_class: RecaptchaForm, ) @@ -49,13 +61,48 @@ expect(RecaptchaForm).to receive(:new).and_return(recaptcha_form) expect(recaptcha_form).to receive(:submit) - form.submit(email:, recaptcha_token:, device_cookie:) + form.submit(recaptcha_token:) + end + end + + describe '#exempt?' do + subject(:exempt?) { form.exempt? } + + it { is_expected.to eq(false) } + + context 'when not part of a/b test' do + let(:ab_test_bucket) { nil } + + it { is_expected.to eq(true) } + + it { expect(queries_database?).to eq(false) } + end + + context 'score threshold configured at zero' do + let(:score_threshold_config) { 0.0 } + + it { is_expected.to eq(true) } + + it { expect(queries_database?).to eq(false) } + end + + context 'existing device for user' do + let(:device_cookie) { user.devices.first.cookie_uuid } + + it { is_expected.to eq(true) } + + it { expect(queries_database?).to eq(true) } + end + + def queries_database? + user + QueryTracker.track { exempt? }.present? end end describe '#submit' do let(:recaptcha_form_success) { false } - subject(:response) { form.submit(email:, recaptcha_token:, device_cookie:) } + subject(:response) { form.submit(recaptcha_token:) } context 'recaptcha form validates as unsuccessful' do let(:score) { 0.0 } @@ -68,6 +115,14 @@ end end + context 'when not part of a/b test' do + let(:ab_test_bucket) { nil } + + it 'is successful' do + expect(response.to_h).to eq(success: true) + end + end + context 'new device for user' do it 'is unsuccessful with errors from recaptcha validation' do expect(response.to_h).to eq( diff --git a/spec/forms/webauthn_setup_form_spec.rb b/spec/forms/webauthn_setup_form_spec.rb index 21aa0aa9c29..64fac942c8f 100644 --- a/spec/forms/webauthn_setup_form_spec.rb +++ b/spec/forms/webauthn_setup_form_spec.rb @@ -7,9 +7,10 @@ let(:user_session) { { webauthn_challenge: webauthn_challenge } } let(:device_name) { 'Chrome 119 on macOS' } let(:domain_name) { 'localhost:3000' } + let(:attestation) { attestation_object } let(:params) do { - attestation_object: attestation_object, + attestation_object: attestation, client_data_json: setup_client_data_json, name: 'mykey', platform_authenticator: false, @@ -26,42 +27,51 @@ describe '#submit' do context 'when the input is valid' do - it 'returns FormResponse with success: true and creates a webauthn configuration' do - extra_attributes = { - enabled_mfa_methods_count: 1, - mfa_method_counts: { webauthn: 1 }, - multi_factor_auth_method: 'webauthn', - authenticator_data_flags: { - up: true, - uv: false, - be: true, - bs: true, - at: false, - ed: true, - }, - pii_like_keypaths: [[:mfa_method_counts, :phone]], - } + context 'security key' do + it 'returns FormResponse with success: true and creates a webauthn configuration' do + extra_attributes = { + enabled_mfa_methods_count: 1, + mfa_method_counts: { webauthn: 1 }, + multi_factor_auth_method: 'webauthn', + authenticator_data_flags: { + up: true, + uv: false, + be: true, + bs: true, + at: false, + ed: true, + }, + pii_like_keypaths: [[:mfa_method_counts, :phone]], + } - expect(subject.submit(params).to_h).to eq( - success: true, - errors: {}, - **extra_attributes, - ) + expect(subject.submit(params).to_h).to eq( + success: true, + errors: {}, + **extra_attributes, + ) - user.reload + user.reload - expect(user.webauthn_configurations.roaming_authenticators.count).to eq(1) - expect(user.webauthn_configurations.roaming_authenticators.first.transports).to eq(['usb']) - end + expect(user.webauthn_configurations.roaming_authenticators.count).to eq(1) + expect(user.webauthn_configurations.roaming_authenticators.first.transports). + to eq(['usb']) + end - it 'sends a recovery information changed event' do - expect(PushNotification::HttpPush).to receive(:deliver). - with(PushNotification::RecoveryInformationChangedEvent.new(user: user)) + it 'sends a recovery information changed event' do + expect(PushNotification::HttpPush).to receive(:deliver). + with(PushNotification::RecoveryInformationChangedEvent.new(user: user)) - subject.submit(params) + subject.submit(params) + end + + it 'does not contains uuid' do + result = subject.submit(params) + expect(result.extra[:aaguid]).to eq nil + end end context 'with platform authenticator' do + let(:attestation) { platform_auth_attestation_object } let(:params) do super().merge(platform_authenticator: true, transports: 'internal,hybrid') end @@ -114,6 +124,11 @@ expect(result.to_h[:authenticator_data_flags]).to be_nil end end + + it 'contains uuid' do + result = subject.submit(params) + expect(result.extra[:aaguid]).to eq aaguid + end end context 'with invalid transports' do diff --git a/spec/forms/webauthn_verification_form_spec.rb b/spec/forms/webauthn_verification_form_spec.rb index d01339b6544..1f298720bc6 100644 --- a/spec/forms/webauthn_verification_form_spec.rb +++ b/spec/forms/webauthn_verification_form_spec.rb @@ -10,6 +10,7 @@ let(:screen_lock_error) { nil } let(:platform_authenticator) { false } let(:client_data_json) { verification_client_data_json } + let(:webauthn_aaguid) { nil } let!(:webauthn_configuration) do return if !user create( @@ -18,6 +19,7 @@ credential_id: credential_id, credential_public_key: credential_public_key, platform_authenticator: platform_authenticator, + aaguid: webauthn_aaguid, ) end @@ -45,20 +47,24 @@ subject(:result) { form.submit } context 'when the input is valid' do - it 'returns successful result' do - expect(result.to_h).to eq( - success: true, - webauthn_configuration_id: webauthn_configuration.id, - ) + context 'security key' do + it 'returns successful result' do + expect(result.to_h).to eq( + success: true, + webauthn_configuration_id: webauthn_configuration.id, + ) + end end context 'for platform authenticator' do let(:platform_authenticator) { true } + let(:webauthn_aaguid) { aaguid } it 'returns successful result' do expect(result.to_h).to eq( success: true, webauthn_configuration_id: webauthn_configuration.id, + webauthn_aaguid: aaguid, ) end end diff --git a/spec/jobs/reports/fraud_metrics_report_spec.rb b/spec/jobs/reports/fraud_metrics_report_spec.rb index 23c108d41aa..0c7cc22e981 100644 --- a/spec/jobs/reports/fraud_metrics_report_spec.rb +++ b/spec/jobs/reports/fraud_metrics_report_spec.rb @@ -2,6 +2,7 @@ RSpec.describe Reports::FraudMetricsReport do let(:report_date) { Date.new(2021, 3, 2).in_time_zone('UTC').end_of_day } + let(:time_range) { report_date.all_month } subject(:report) { Reports::FraudMetricsReport.new(report_date) } let(:name) { 'fraud-metrics-report' } @@ -13,6 +14,8 @@ let(:expected_s3_paths) do [ "#{report_folder}/lg99_metrics.csv", + "#{report_folder}/suspended_metrics.csv", + "#{report_folder}/reinstated_metrics.csv", ] end @@ -26,8 +29,29 @@ let(:mock_identity_verification_lg99_data) do [ - ['Metric', 'Total'], - ['Unique users seeing LG-99', 5], + ['Metric', 'Total', 'Range Start', 'Range End'], + ['Unique users seeing LG-99', 5, time_range.begin.to_s, + time_range.end.to_s], + ] + end + let(:mock_suspended_metrics_table) do + [ + ['Metric', 'Total', 'Range Start', 'Range End'], + ['Unique users suspended', 2, time_range.begin.to_s, + time_range.end.to_s], + ['Average Days Creation to Suspension', 1.5, time_range.begin.to_s, + time_range.end.to_s], + ['Average Days Proofed to Suspension', 2.0, time_range.begin.to_s, + time_range.end.to_s], + ] + end + let(:mock_reinstated_metrics_table) do + [ + ['Metric', 'Total', 'Range Start', 'Range End'], + ['Unique users reinstated', 1, time_range.begin.to_s, + time_range.end.to_s], + ['Average Days to Reinstatement', 3.0, time_range.begin.to_s, + time_range.end.to_s], ] end @@ -54,6 +78,12 @@ allow(report.fraud_metrics_lg99_report).to receive(:lg99_metrics_table). and_return(mock_identity_verification_lg99_data) + + allow(report.fraud_metrics_lg99_report).to receive(:suspended_metrics_table). + and_return(mock_suspended_metrics_table) + + allow(report.fraud_metrics_lg99_report).to receive(:reinstated_metrics_table). + and_return(mock_reinstated_metrics_table) end it 'sends out a report to just to team agnes' do diff --git a/spec/jobs/resolution_proofing_job_spec.rb b/spec/jobs/resolution_proofing_job_spec.rb index d4c2db323c2..bb5a31a8779 100644 --- a/spec/jobs/resolution_proofing_job_spec.rb +++ b/spec/jobs/resolution_proofing_job_spec.rb @@ -525,6 +525,68 @@ end end + context 'socure shadow mode' do + context 'turned on' do + before do + allow(IdentityConfig.store).to receive(:idv_socure_shadow_mode_enabled).and_return(true) + 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( + { + first_name: 'FAKEY', + middle_name: nil, + last_name: 'MCFAKERSON', + 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', + 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 + end + + context 'turned off' do + it 'does not schedule a SocureShadowModeProofingJob' do + stub_vendor_requests + + expect(SocureShadowModeProofingJob).not_to receive(:perform_later) + + perform + end + end + end + it 'determines the UUID and UUID prefix and passes it to the downstream proofing vendors' do uuid_info = { uuid_prefix: service_provider.app_id, diff --git a/spec/jobs/socure_shadow_mode_proofing_job_spec.rb b/spec/jobs/socure_shadow_mode_proofing_job_spec.rb new file mode 100644 index 00000000000..3353e0e427f --- /dev/null +++ b/spec/jobs/socure_shadow_mode_proofing_job_spec.rb @@ -0,0 +1,381 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SocureShadowModeProofingJob do + let(:job) do + described_class.new + end + + let(:document_capture_session) do + DocumentCaptureSession.create(user:).tap do |dcs| + dcs.create_proofing_session + end + end + + let(:document_capture_session_result_id) do + document_capture_session.result_id + end + + let(:applicant_pii) do + Idp::Constants::MOCK_IDV_APPLICANT_WITH_PHONE + end + + let(:encrypted_arguments) do + Encryption::Encryptors::BackgroundProofingArgEncryptor.new.encrypt( + JSON.generate({ applicant_pii: }), + ) + end + + let(:user) { create(:user) } + + let(:user_uuid) { user.uuid } + + let(:user_email) { user.email } + + let(:proofing_result) do + FormResponse.new( + success: true, + errors: {}, + extra: { + exception: nil, + timed_out: false, + threatmetrix_review_status: 'pass', + context: { + device_profiling_adjudication_reason: 'device_profiling_result_pass', + resolution_adjudication_reason: 'pass_resolution_and_state_id', + should_proof_state_id: true, + stages: { + resolution: { + success: true, + errors: {}, + exception: nil, + timed_out: false, + transaction_id: 'resolution-mock-transaction-id-123', + reference: 'aaa-bbb-ccc', + can_pass_with_additional_verification: false, + attributes_requiring_additional_verification: [], + vendor_name: 'ResolutionMock', + vendor_workflow: nil, + verified_attributes: nil, + }, + residential_address: { + success: true, + errors: {}, + exception: nil, + timed_out: false, + transaction_id: '', + reference: '', + can_pass_with_additional_verification: false, + attributes_requiring_additional_verification: [], + vendor_name: 'ResidentialAddressNotRequired', + vendor_workflow: nil, + verified_attributes: nil, + }, + state_id: { + success: true, + errors: {}, + exception: nil, + mva_exception: nil, + requested_attributes: {}, + timed_out: false, + transaction_id: 'state-id-mock-transaction-id-456', + vendor_name: 'StateIdMock', + verified_attributes: [], + }, + threatmetrix: { + client: nil, + success: true, + errors: {}, + exception: nil, + timed_out: false, + transaction_id: 'ddp-mock-transaction-id-123', + review_status: 'pass', + response_body: { + "fraudpoint.score": '500', + request_id: '1234', + request_result: 'success', + review_status: 'pass', + risk_rating: 'trusted', + summary_risk_score: '-6', + tmx_risk_rating: 'neutral', + tmx_summary_reason_code: ['Identity_Negative_History'], + first_name: '[redacted]', + }, + }, + }, + }, + ssn_is_unique: true, + }, + ) + end + + let(:socure_idplus_base_url) { 'https://example.org' } + + before do + document_capture_session.store_proofing_result(proofing_result.to_h) + + allow(IdentityConfig.store).to receive(:socure_idplus_base_url). + and_return(socure_idplus_base_url) + end + + describe '#perform' do + subject(:perform) do + allow(job).to receive(:create_analytics).and_return(analytics) + + job.perform( + document_capture_session_result_id:, + encrypted_arguments:, + service_provider_issuer: nil, + user_email:, + user_uuid:, + ) + end + + let(:analytics) do + FakeAnalytics.new + end + + let(:socure_response_body) do + { + referenceId: 'a1234b56-e789-0123-4fga-56b7c890d123', + kyc: { + reasonCodes: [ + 'I919', + 'I914', + 'I905', + ], + fieldValidations: { + firstName: 0.99, + surName: 0.99, + streetAddress: 0.99, + city: 0.99, + state: 0.99, + zip: 0.99, + mobileNumber: 0.99, + dob: 0.99, + ssn: 0.99, + }, + }, + customerProfile: { + customerUserId: '129', + userId: 'u8JpWn4QsF3R7tA2', + }, + } + end + + before do + stub_request(:post, 'https://example.org/api/3.0/EmailAuthScore'). + to_return( + headers: { + 'Content-Type' => 'application/json', + }, + body: JSON.generate(socure_response_body), + ) + end + + context 'when document capture session result is present in redis' do + it 'makes a proofing call' do + expect(job.proofer).to receive(:proof).and_call_original + perform + end + + it 'does not log an idv_socure_shadow_mode_proofing_result_missing event' do + perform + expect(analytics).not_to have_logged_event(:idv_socure_shadow_mode_proofing_result_missing) + end + + it 'logs an event' do + perform + expect(analytics).to have_logged_event( + :idv_socure_shadow_mode_proofing_result, + user_id: user.uuid, + resolution_result: { + success: true, + errors: {}, + context: { + device_profiling_adjudication_reason: 'device_profiling_result_pass', + resolution_adjudication_reason: 'pass_resolution_and_state_id', + should_proof_state_id: true, + stages: { + residential_address: { + attributes_requiring_additional_verification: [], + can_pass_with_additional_verification: false, + errors: {}, + exception: nil, + reference: '', + success: true, + timed_out: false, + transaction_id: '', + vendor_name: 'ResidentialAddressNotRequired', + vendor_workflow: nil, + verified_attributes: nil, + }, + resolution: { + attributes_requiring_additional_verification: [], + can_pass_with_additional_verification: false, + errors: {}, + exception: nil, + reference: 'aaa-bbb-ccc', + success: true, + timed_out: false, + transaction_id: 'resolution-mock-transaction-id-123', + vendor_name: 'ResolutionMock', + vendor_workflow: nil, + verified_attributes: nil, + }, + state_id: { + errors: {}, + exception: nil, + mva_exception: nil, + requested_attributes: {}, + success: true, + timed_out: false, + transaction_id: 'state-id-mock-transaction-id-456', + vendor_name: 'StateIdMock', + verified_attributes: [], + }, + threatmetrix: { + client: nil, + errors: {}, + exception: nil, + review_status: 'pass', + success: true, + timed_out: false, + transaction_id: 'ddp-mock-transaction-id-123', + }, + }, + }, + exception: nil, + ssn_is_unique: true, + threatmetrix_review_status: 'pass', + timed_out: false, + }, + socure_result: { + attributes_requiring_additional_verification: [], + can_pass_with_additional_verification: false, + errors: { reason_codes: ['I905', 'I914', 'I919'] }, + exception: nil, + reference: '', + success: true, + timed_out: false, + transaction_id: 'a1234b56-e789-0123-4fga-56b7c890d123', + vendor_name: 'socure_kyc', + vendor_workflow: nil, + verified_attributes: %i[address first_name last_name phone ssn dob].to_set, + }, + ) + end + + context 'when socure proofer raises an error' do + before do + allow(job.proofer).to receive(:proof).and_raise + end + + it 'does not squash the error' do + # If the Proofer encounters an error while _making_ a request, that + # will be returned as a Result with the `exception` property set. + # Other errors will be raised as normal. + expect { perform }.to raise_error + end + end + end + + context 'when document capture session result is not present in redis' do + let(:document_capture_session_result_id) { 'some-id-that-is-not-valid' } + + it 'logs an idv_socure_shadow_mode_proofing_result_missing event' do + perform + + expect(analytics).to have_logged_event( + :idv_socure_shadow_mode_proofing_result_missing, + ) + end + end + + context 'when job is stale' do + before do + allow(job).to receive(:stale_job?).and_return(true) + end + it 'raises StaleJobError' do + expect { perform }.to raise_error(JobHelpers::StaleJobHelper::StaleJobError) + end + end + + context 'when user is not found' do + let(:user_uuid) { 'some-user-id-that-does-not-exist' } + it 'raises an error' do + expect do + perform + end.to raise_error(RuntimeError, 'User not found: some-user-id-that-does-not-exist') + end + end + + context 'when encrypted_arguments cannot be decrypted' do + let(:encrypted_arguments) { 'bG9sIHRoaXMgaXMgbm90IGV2ZW4gZW5jcnlwdGVk' } + it 'raises an error' do + expect { perform }.to raise_error(Encryption::EncryptionError) + end + end + + context 'when encrypted_arguments contains invalid JSON' do + let(:encrypted_arguments) do + Encryption::Encryptors::BackgroundProofingArgEncryptor.new.encrypt( + 'this is not valid JSON', + ) + end + it 'raises an error' do + expect { perform }.to raise_error(JSON::ParserError) + end + end + end + + describe '#build_applicant' do + subject(:build_applicant) do + job.build_applicant(encrypted_arguments:, user_email:) + end + + it 'builds an applicant structure that looks right' do + expect(build_applicant).to eql( + { + first_name: 'FAKEY', + last_name: 'MCFAKERSON', + address1: '1 FAKE RD', + address2: nil, + city: 'GREAT FALLS', + state: 'MT', + zipcode: '59010-1234', + phone: '12025551212', + dob: '1938-10-06', + ssn: '900-66-1234', + email: user.email, + }, + ) + end + end + + describe '#create_analytics' do + it 'creates an Analytics instance with user and sp configured' do + analytics = job.create_analytics( + user:, + service_provider_issuer: 'some-issuer', + ) + expect(analytics.sp).to eql('some-issuer') + expect(analytics.user).to eql(user) + end + end + + describe '#proofer' do + it 'returns a configured proofer' do + allow(IdentityConfig.store).to receive(:socure_idplus_api_key).and_return('an-api-key') + allow(IdentityConfig.store).to receive(:socure_idplus_base_url).and_return('https://example.org') + allow(IdentityConfig.store).to receive(:socure_idplus_timeout_in_seconds).and_return(6) + + expect(job.proofer.config.to_h).to eql( + api_key: 'an-api-key', + base_url: 'https://example.org', + timeout: 6, + ) + end + end +end diff --git a/spec/lib/ab_test_spec.rb b/spec/lib/ab_test_spec.rb index 7226db1b171..b6df14a9009 100644 --- a/spec/lib/ab_test_spec.rb +++ b/spec/lib/ab_test_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' RSpec.describe AbTest do - subject do + subject(:ab_test) do AbTest.new( experiment_name: 'test', buckets:, @@ -33,7 +33,7 @@ let(:user_session) { {} } let(:bucket) do - subject.bucket( + ab_test.bucket( request:, service_provider:, session:, @@ -46,7 +46,7 @@ it 'divides random uuids into the buckets with no automatic default' do results = {} 1000.times do - b = subject.bucket( + b = ab_test.bucket( request:, service_provider:, session:, @@ -86,7 +86,7 @@ build(:user, uuid: 'some-random-uuid') end it 'uses uuid as discriminator' do - expect(subject).to receive(:percent).with('some-random-uuid').once.and_call_original + expect(ab_test).to receive(:percent).with('some-random-uuid').once.and_call_original expect(bucket).to eql(:foo) end end @@ -141,7 +141,7 @@ let(:buckets) { { foo: 'foo', bar: 'bar' } } it 'raises a RuntimeError' do - expect { subject }.to raise_error(RuntimeError, 'invalid bucket data structure') + expect { ab_test }.to raise_error(RuntimeError, 'invalid bucket data structure') end end @@ -149,7 +149,7 @@ let(:buckets) { { foo: 60, bar: 60 } } it 'raises a RuntimeError' do - expect { subject }.to raise_error(RuntimeError, 'bucket percentages exceed 100') + expect { ab_test }.to raise_error(RuntimeError, 'bucket percentages exceed 100') end end @@ -157,7 +157,7 @@ let(:buckets) { [[:foo, 10], [:bar, 20]] } it 'raises a RuntimeError' do - expect { subject }.to raise_error(RuntimeError, 'invalid bucket data structure') + expect { ab_test }.to raise_error(RuntimeError, 'invalid bucket data structure') end end end @@ -165,7 +165,7 @@ describe '#include_in_analytics_event?' do let(:event_name) { 'My cool event' } - let(:return_value) { subject.include_in_analytics_event?(event_name) } + subject(:return_value) { ab_test.include_in_analytics_event?(event_name) } context 'when should_log is nil' do it 'returns true' do @@ -188,6 +188,32 @@ end end + context 'when object responding to `include?` is used' do + context 'and it matches' do + let(:should_log) do + Class.new do + def include?(event_name) + event_name == 'My cool event' + end + end.new + end + + it { is_expected.to eq(true) } + end + + context 'and it does not match' do + let(:should_log) do + Class.new do + def include?(event_name) + event_name == 'My not cool event' + end + end.new + end + + it { is_expected.to eq(false) } + end + end + context 'when true is used' do let(:should_log) { true } it 'raises' do diff --git a/spec/lib/reporting/fraud_metrics_lg99_report_spec.rb b/spec/lib/reporting/fraud_metrics_lg99_report_spec.rb index c211a9ec828..61a85cc53aa 100644 --- a/spec/lib/reporting/fraud_metrics_lg99_report_spec.rb +++ b/spec/lib/reporting/fraud_metrics_lg99_report_spec.rb @@ -5,13 +5,24 @@ let(:time_range) { Date.new(2022, 1, 1).in_time_zone('UTC').all_month } let(:expected_lg99_metrics_table) do [ - ['Metric', 'Total'], - ['Unique users seeing LG-99', '5'], - ['Unique users suspended', '2'], - ['Average Days Creation to Suspension', '1.5'], - ['Average Days Proofed to Suspension', '2.0'], - ['Unique users reinstated', '1'], - ['Average Days to Reinstatement', '3.0'], + ['Metric', 'Total', 'Range Start', 'Range End'], + ['Unique users seeing LG-99', '5', time_range.begin.to_s, + time_range.end.to_s], + ] + end + let(:expected_suspended_metrics_table) do + [ + ['Metric', 'Total', 'Range Start', 'Range End'], + ['Unique users suspended', '2', time_range.begin.to_s, time_range.end.to_s], + ['Average Days Creation to Suspension', '1.5', time_range.begin.to_s, time_range.end.to_s], + ['Average Days Proofed to Suspension', '2.0', time_range.begin.to_s, time_range.end.to_s], + ] + end + let(:expected_reinstated_metrics_table) do + [ + ['Metric', 'Total', 'Range Start', 'Range End'], + ['Unique users reinstated', '1', time_range.begin.to_s, time_range.end.to_s], + ['Average Days to Reinstatement', '3.0', time_range.begin.to_s, time_range.end.to_s], ] end let!(:user6) do @@ -65,6 +76,28 @@ end end + describe '#suspended_metrics_table' do + it 'renders a suspended metrics table' do + aggregate_failures do + report.suspended_metrics_table.zip(expected_suspended_metrics_table). + each do |actual, expected| + expect(actual).to eq(expected) + end + end + end + end + + describe '#reinstated_metrics_table' do + it 'renders a reinstated metrics table' do + aggregate_failures do + report.reinstated_metrics_table.zip(expected_reinstated_metrics_table). + each do |actual, expected| + expect(actual).to eq(expected) + end + end + end + end + describe '#user_days_to_suspension_avg' do context 'when there are suspended users' do it 'returns average time to suspension' do @@ -117,25 +150,27 @@ end describe '#as_emailable_reports' do - let(:expected_report) do - Reporting::EmailableReport.new( - title: 'LG-99 Metrics', - filename: 'lg99_metrics', - table: expected_lg99_metrics_table, - ) + let(:expected_reports) do + [ + Reporting::EmailableReport.new( + title: 'Monthly LG-99 Metrics Jan-2022', + filename: 'lg99_metrics', + table: expected_lg99_metrics_table, + ), + Reporting::EmailableReport.new( + title: 'Monthly Suspended User Metrics Jan-2022', + filename: 'suspended_metrics', + table: expected_suspended_metrics_table, + ), + Reporting::EmailableReport.new( + title: 'Monthly Reinstated User Metrics Jan-2022', + filename: 'reinstated_metrics', + table: expected_reinstated_metrics_table, + ), + ] end it 'return expected table for email' do - expect(report.as_emailable_reports).to eq expected_report - end - end - - describe '#to_csv' do - it 'renders a csv report' do - aggregate_failures do - report.lg99_metrics_table.zip(expected_lg99_metrics_table).each do |actual, expected| - expect(actual).to eq(expected) - end - end + expect(report.as_emailable_reports).to eq expected_reports end end diff --git a/spec/services/encryption/contextless_kms_client_spec.rb b/spec/services/encryption/contextless_kms_client_spec.rb index d2c1e65cc38..1cc5858418b 100644 --- a/spec/services/encryption/contextless_kms_client_spec.rb +++ b/spec/services/encryption/contextless_kms_client_spec.rb @@ -150,10 +150,11 @@ it 'logs the encryption' do expect(Encryption::KmsLogger).to receive(:log).with( :encrypt, + log_context: { context: 'abc' }, key_id: IdentityConfig.store.aws_kms_key_id, ) - subject.encrypt(long_kms_plaintext) + subject.encrypt(long_kms_plaintext, log_context: { context: 'abc' }) end end @@ -185,10 +186,11 @@ it 'logs the decryption' do expect(Encryption::KmsLogger).to receive(:log).with( :decrypt, + log_context: { context: 'abc' }, key_id: IdentityConfig.store.aws_kms_key_id, ) - subject.decrypt('KMSx' + kms_ciphertext) + subject.decrypt('KMSx' + kms_ciphertext, log_context: { context: 'abc' }) end end diff --git a/spec/services/encryption/kms_client_spec.rb b/spec/services/encryption/kms_client_spec.rb index 9e9575c2d44..4c31b398eee 100644 --- a/spec/services/encryption/kms_client_spec.rb +++ b/spec/services/encryption/kms_client_spec.rb @@ -141,10 +141,10 @@ before do contextless_client = Encryption::ContextlessKmsClient.new allow(contextless_client).to receive(:decrypt). - with('KMSx123abc'). + with('KMSx123abc', log_context: encryption_context). and_return('plaintext') allow(contextless_client).to receive(:decrypt). - with('123abc'). + with('123abc', log_context: encryption_context). and_return('plaintext') allow(Encryption::ContextlessKmsClient).to receive(:new).and_return(contextless_client) end diff --git a/spec/services/encryption/kms_logger_spec.rb b/spec/services/encryption/kms_logger_spec.rb index 9b3dd45cb1d..bf1acaf12f1 100644 --- a/spec/services/encryption/kms_logger_spec.rb +++ b/spec/services/encryption/kms_logger_spec.rb @@ -8,6 +8,7 @@ kms: { action: 'encrypt', encryption_context: { context: 'pii-encryption', user_uuid: '1234-abc' }, + log_context: 'log_context', key_id: 'super-duper-aws-kms-key-id', }, log_filename: Idp::Constants::KMS_LOG_FILENAME, @@ -18,6 +19,7 @@ described_class.log( :encrypt, context: { context: 'pii-encryption', user_uuid: '1234-abc' }, + log_context: 'log_context', key_id: 'super-duper-aws-kms-key-id', ) end @@ -29,6 +31,7 @@ kms: { action: 'decrypt', encryption_context: nil, + log_context: nil, key_id: 'super-duper-aws-kms-key-id', }, log_filename: Idp::Constants::KMS_LOG_FILENAME, diff --git a/spec/services/proofing/mock/resolution_mock_client_spec.rb b/spec/services/proofing/mock/resolution_mock_client_spec.rb index 5c8b2f9933f..61f23ddb71c 100644 --- a/spec/services/proofing/mock/resolution_mock_client_spec.rb +++ b/spec/services/proofing/mock/resolution_mock_client_spec.rb @@ -28,6 +28,7 @@ can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [], vendor_workflow: nil, + verified_attributes: nil, ) end end @@ -51,6 +52,7 @@ can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [], vendor_workflow: nil, + verified_attributes: nil, ) end end @@ -74,6 +76,7 @@ can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [], vendor_workflow: nil, + verified_attributes: nil, ) end end @@ -97,6 +100,7 @@ can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [], vendor_workflow: nil, + verified_attributes: nil, ) end end @@ -120,6 +124,7 @@ can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [], vendor_workflow: nil, + verified_attributes: nil, ) end end @@ -143,6 +148,7 @@ can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [], vendor_workflow: nil, + verified_attributes: nil, ) end end @@ -166,6 +172,7 @@ can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [], vendor_workflow: nil, + verified_attributes: nil, ) end end @@ -191,6 +198,7 @@ can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [], vendor_workflow: nil, + verified_attributes: nil, ) end end diff --git a/spec/support/ab_tests_helper.rb b/spec/support/ab_tests_helper.rb new file mode 100644 index 00000000000..8f8f4742a14 --- /dev/null +++ b/spec/support/ab_tests_helper.rb @@ -0,0 +1,8 @@ +module AbTestsHelper + def reload_ab_tests + AbTests.all.each do |(name, _)| + AbTests.send(:remove_const, name) + end + load('config/initializers/ab_tests.rb') + end +end diff --git a/spec/support/features/navigation_helper.rb b/spec/support/features/navigation_helper.rb index f56528c777c..90c7872afcb 100644 --- a/spec/support/features/navigation_helper.rb +++ b/spec/support/features/navigation_helper.rb @@ -5,10 +5,14 @@ module NavigationHelper # sidenav links. def find_sidenav_delete_account_link - find('.sidenav').find_link(t('account.links.delete_account'), href: account_delete_path) + within_sidenav { find_link(t('account.links.delete_account'), href: account_delete_path) } end def find_sidenav_forget_browsers_link - find('.sidenav').find_link(t('account.navigation.forget_browsers')) + within_sidenav { find_link(t('account.navigation.forget_browsers')) } + end + + def within_sidenav(&block) + within('.sidenav', &block) end end diff --git a/spec/support/features/webauthn_helper.rb b/spec/support/features/webauthn_helper.rb index 45278d60a9f..19735b95f2c 100644 --- a/spec/support/features/webauthn_helper.rb +++ b/spec/support/features/webauthn_helper.rb @@ -172,6 +172,15 @@ def attestation_object HEREDOC end + def platform_auth_attestation_object + <<~HEREDOC.chomp + o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVikSZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzz + uoMdl2NFAAAAAK3OAAI1vMYKZIsLJfHwVQMAIOa31Ugh6EPoj4z6b+ibq6rVF1CZ9ygzSNvMrFmY + aPLtpQECAyYgASFYIO6a1uIfDkbqg/pm7bHZG0oRGyCEuWZrCWd2v/2IqXCaIlggKQEHbAiyBZxS + 1HSBwwdjNCE4prYoHdzJWQILvDrIySo= + HEREDOC + end + def setup_client_data_json <<~HEREDOC.chomp eyJjaGFsbGVuZ2UiOiJncjEycndSVVVIWnFvNkZFSV9ZbEFnIiwibmV3X2tleXNfbWF5X2JlX2 @@ -198,6 +207,10 @@ def authenticator_data 'SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2MBAAAAcQ==' end + def aaguid + 'adce0002-35bc-c60a-648b-0b25f1f05503' + end + def signature <<~HEREDOC.chomp MEYCIQC7VHQpZasv8URBC/VYKWcuv4MrmV82UfsESKTGgV3r+QIhAO8iAduYC7XDHJjpKkrSKb diff --git a/spec/support/have_logged_event_matcher.rb b/spec/support/have_logged_event_matcher.rb index 86d09a48164..52e06b2ca2b 100644 --- a/spec/support/have_logged_event_matcher.rb +++ b/spec/support/have_logged_event_matcher.rb @@ -22,6 +22,15 @@ def failure_message end end + def failure_message_when_negated + adjective = attributes_matcher_description(expected_attributes) ? 'matching ' : '' + [ + "Expected that FakeAnalytics would not have received #{adjective}event", + expected_event_name.inspect, + has_expected_count? ? count_failure_reason('it was received').strip : nil, + ].compact.join(' ') + end + def matches?(actual) @actual = actual diff --git a/yarn.lock b/yarn.lock index 1a803270cc0..692899c6446 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3612,7 +3612,7 @@ fast-diff@^1.1.2: resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03" integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w== -fast-glob@^3.2.7, fast-glob@^3.2.9, fast-glob@^3.3.0, fast-glob@^3.3.2: +fast-glob@^3.2.9, fast-glob@^3.3.0, fast-glob@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129" integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow== @@ -4951,9 +4951,9 @@ methods@~1.1.2: integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5: - version "4.0.7" - resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.7.tgz#33e8190d9fe474a9895525f5618eee136d46c2e5" - integrity sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q== + version "4.0.8" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" + integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== dependencies: braces "^3.0.3" picomatch "^2.3.1"