diff --git a/.codeclimate.yml b/.codeclimate.yml index 6180be2db5a..502509ab8e9 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -10,7 +10,7 @@ checks: enabled: false method-complexity: config: - threshold: 5 + threshold: 15 method-count: enabled: false method-lines: diff --git a/.reek b/.reek deleted file mode 100644 index 1c72440ceb5..00000000000 --- a/.reek +++ /dev/null @@ -1,215 +0,0 @@ -Attribute: - enabled: false -ControlParameter: - exclude: - - CustomDeviseFailureApp#i18n_message - - NoRetryJobs#call - - PhoneFormatter#self.format - - Users::TwoFactorAuthenticationController#invalid_phone_number -DuplicateMethodCall: - exclude: - - ApplicationController#disable_caching - - IdvFailureConcern#render_failure - - ServiceProviderSessionDecorator#registration_heading - - MfaConfirmationController#handle_invalid_password - - needs_to_confirm_email_change? - - WorkerHealthChecker#status - - UserFlowExporter#self.massage_assets - - BasicAuthUrl#build - - fallback_to_english - - Upaya::RandomTools#self.random_weighted_sample - - SmsController#authenticate -FeatureEnvy: - exclude: - - ActiveJob::Logging::LogSubscriber#json_for - - Ahoy::Store#track_event - - Aws::SES::Base#deliver - - CustomDeviseFailureApp#build_options - - CustomDeviseFailureApp#keys - - track_registration - - append_info_to_payload - - generate_slo_request - - reauthn? - - mark_profile_inactive - - EncryptedSidekiqRedis#zrem - - Pii::Attributes#[]= - - OpenidConnectLogoutForm#load_identity - - Idv::ProfileMaker#pii_from_applicant - - Idv::Step#vendor_validator_result - - IdvSession#vendor_result_timed_out? - - ServiceProviderSeeder#run - - OtpDeliverySelectionForm#unsupported_phone? - - fallback_to_english - - UserEncryptedAttributeOverrides#find_with_email - - Utf8Sanitizer#event_attributes - - Utf8Sanitizer#remote_ip - - TwoFactorAuthenticationController#capture_analytics_for_exception - - UspsConfirmationExporter#make_entry_row -InstanceVariableAssumption: - exclude: - - User - - JWT -IrresponsibleModule: - enabled: false -ManualDispatch: - exclude: - - EncryptedSidekiqRedis#respond_to_missing? - - CloudhsmKeyGenerator#initialize_settings -NestedIterators: - exclude: - - UserFlowExporter#self.massage_html - - TwilioService::Utils#sanitize_phone_number - - ServiceProviderSeeder#run - - UspsConfirmationUploader#upload_export -NilCheck: - enabled: false -LongParameterList: - max_params: 4 - exclude: - - IdentityLinker#optional_attributes - - Idv::ProoferJob#perform - - Idv::VendorResult#initialize - - JWT - - SmsOtpSenderJob#perform -RepeatedConditional: - exclude: - - Users::ResetPasswordsController - - IdvController - - Idv::Base - - Rack::Attack -TooManyConstants: - exclude: - - Analytics -TooManyInstanceVariables: - exclude: - - OpenidConnectAuthorizeForm - - Idv::VendorResult - - CloudhsmKeyGenerator - - CloudhsmKeySharer - - WebauthnSetupForm -TooManyStatements: - max_statements: 6 - exclude: - - IdvFailureConcern#render_failure - - OpenidConnect::AuthorizationController#index - - OpenidConnect::AuthorizationController#store_request - - SamlIdpAuthConcern#store_saml_request - - Users::PhoneConfirmationController - - UserFlowExporter#self.massage_assets - - UserFlowExporter#self.massage_html - - UserFlowExporter#self.run - - Idv::Agent#proof - - Idv::VendorResult#initialize - - SamlIdpController#auth - - Upaya::QueueConfig#self.choose_queue_adapter - - Upaya::RandomTools#self.random_weighted_sample - - UserFlowFormatter#stop - - Upaya::QueueConfig#self.choose_queue_adapter - - Users::TwoFactorAuthenticationController#send_code -TooManyMethods: - exclude: - - Users::ConfirmationsController - - ApplicationController - - OpenidConnectAuthorizeForm - - OpenidConnect::AuthorizationController - - Idv::Session - - User - - Idv::SessionsController - - ServiceProviderSessionDecorator - - SessionDecorator - - HolidayService - - PhoneDeliveryPresenter - - CloudhsmKeyGenerator -UncommunicativeMethodName: - exclude: - - PhoneConfirmationFlow - - render_401 - - SessionDecorator#registration_bullet_1 - - ServiceProviderSessionDecorator#registration_bullet_1 -UncommunicativeModuleName: - exclude: - - X509 - - X509::Attribute - - X509::Attributes - - X509::SessionStore -UnusedParameters: - exclude: - - SmsOtpSenderJob#perform - - VoiceOtpSenderJob#perform -UnusedPrivateMethod: - exclude: - - ApplicationController - - ActiveJob::Logging::LogSubscriber - - SamlIdpController - - Users::PhoneConfirmationController - - ssn_is_unique -UtilityFunction: - public_methods_only: true - exclude: - - AnalyticsEventJob#perform - - ApplicationController#default_url_options - - ApplicationHelper#step_class - - NullTwilioClient#http_client - - PersonalKeyFormatter#regexp - - SessionTimeoutWarningHelper#frequency - - SessionTimeoutWarningHelper#start - - SessionTimeoutWarningHelper#warning - - SessionDecorator - - WorkerHealthChecker::Middleware#call - - UserEncryptedAttributeOverrides#create_fingerprint - - LocaleHelper#locale_url_param - - IdvSession#timed_out_vendor_error - - JWT::Signature#sign - - SmsAccountResetCancellationNotifierJob#perform -'app/controllers': - InstanceVariableAssumption: - enabled: false -'spec': - BooleanParameter: - exclude: - - SamlAuthHelper#generate_saml_response - ControlParameter: - exclude: - - complete_idv_session - - SamlAuthHelper#link_user_to_identity - - visit_idp_from_sp_with_loa1 - - visit_idp_from_sp_with_loa3 - DuplicateMethodCall: - enabled: false - FeatureEnvy: - enabled: false - NestedIterators: - exclude: - - complete_idv_questions_fail - - complete_idv_questions_ok - - create_sidekiq_queues - NilCheck: - exclude: - - complete_idv_questions_fail - - complete_idv_questions_ok - TooManyInstanceVariables: - enabled: false - TooManyMethods: - enabled: false - TooManyStatements: - enabled: false - UncommunicativeMethodName: - exclude: - - visit_idp_from_sp_with_loa1 - - visit_idp_from_sp_with_loa3 - - visit_idp_from_mobile_app_with_loa1 - - visit_idp_from_oidc_sp_with_loa1 - - visit_idp_from_oidc_sp_with_loa3 - UncommunicativeParameterName: - exclude: - - begin_sign_up_with_sp_and_loa - UncommunicativeVariableName: - exclude: - - complete_idv_questions_fail - - complete_idv_questions_ok - UtilityFunction: - enabled: false -exclude_paths: - - db/migrate - - spec - - lib/tasks/ diff --git a/.reek.yml b/.reek.yml index 0af0017f933..4fdebd00edf 100644 --- a/.reek.yml +++ b/.reek.yml @@ -15,7 +15,6 @@ detectors: - ServiceProviderSessionDecorator#registration_heading - MfaConfirmationController#handle_invalid_password - needs_to_confirm_email_change? - - WorkerHealthChecker#status - UserFlowExporter#self.massage_assets - BasicAuthUrl#build - fallback_to_english @@ -33,8 +32,6 @@ detectors: - generate_slo_request - reauthn? - mark_profile_inactive - - EncryptedSidekiqRedis#zrem - - UserDecorator#should_acknowledge_personal_key? - Pii::Attributes#[]= - OpenidConnectLogoutForm#load_identity - Idv::ProfileMaker#pii_from_applicant @@ -43,6 +40,7 @@ detectors: - ServiceProviderSeeder#run - OtpDeliverySelectionForm#unsupported_phone? - fallback_to_english + - TwilioService::Utils#request_data - UserEncryptedAttributeOverrides#find_with_email - Utf8Sanitizer#event_attributes - Utf8Sanitizer#remote_ip @@ -56,7 +54,6 @@ detectors: enabled: false ManualDispatch: exclude: - - EncryptedSidekiqRedis#respond_to_missing? - CloudhsmKeyGenerator#initialize_settings NestedIterators: exclude: @@ -85,12 +82,14 @@ detectors: - Analytics TooManyInstanceVariables: exclude: + - BaseFlow - OpenidConnectAuthorizeForm - OpenidConnectRedirector - Idv::VendorResult - CloudhsmKeyGenerator - CloudhsmKeySharer - WebauthnSetupForm + - WebauthnVerificationForm TooManyStatements: max_statements: 6 exclude: @@ -105,6 +104,7 @@ detectors: - Idv::Agent#proof - Idv::VendorResult#initialize - SamlIdpController#auth + - TwilioService::Utils#sanitize_errors - Upaya::QueueConfig#self.choose_queue_adapter - Upaya::RandomTools#self.random_weighted_sample - UserFlowFormatter#stop @@ -159,7 +159,6 @@ detectors: - SessionTimeoutWarningHelper#start - SessionTimeoutWarningHelper#warning - SessionDecorator - - WorkerHealthChecker::Middleware#call - UserEncryptedAttributeOverrides#create_fingerprint - LocaleHelper#locale_url_param - IdvSession#timed_out_vendor_error @@ -187,7 +186,6 @@ directories: exclude: - complete_idv_questions_fail - complete_idv_questions_ok - - create_sidekiq_queues NilCheck: exclude: - complete_idv_questions_fail diff --git a/.rubocop.yml b/.rubocop.yml index 3d06815482e..43b9c52497c 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -61,6 +61,7 @@ Metrics/ClassLength: - app/controllers/users/two_factor_authentication_controller.rb - app/decorators/service_provider_session_decorator.rb - app/decorators/user_decorator.rb + - app/models/user.rb - app/services/analytics.rb - app/services/idv/session.rb - app/presenters/two_factor_auth_code/phone_delivery_presenter.rb diff --git a/Gemfile b/Gemfile index d70e7d25a9c..b113cf1ea60 100644 --- a/Gemfile +++ b/Gemfile @@ -32,7 +32,7 @@ gem 'pg' gem 'phonelib' gem 'pkcs11' gem 'premailer-rails' -gem 'proofer', github: '18F/identity-proofer-gem', tag: 'v2.6.1' +gem 'proofer', github: '18F/identity-proofer-gem', tag: 'v2.7.0' gem 'rack-attack' gem 'rack-cors', require: 'rack/cors' gem 'rack-headers_filter' @@ -49,7 +49,6 @@ gem 'sass-rails', '~> 5.0' gem 'savon' gem 'scrypt' gem 'secure_headers', '~> 6.0' -gem 'sidekiq' gem 'simple_form' gem 'sinatra', require: false gem 'slim-rails' @@ -87,6 +86,7 @@ group :development, :test do gem 'i18n-tasks' gem 'knapsack' gem 'pry-byebug' + gem 'puma' gem 'rspec-rails', '~> 3.7' gem 'slim_lint' gem 'thin' @@ -94,7 +94,7 @@ end group :test do gem 'axe-matchers', '~> 1.3.4' - gem 'capybara-screenshot', github: 'mattheworiordan/capybara-screenshot' + gem 'capybara-screenshot' gem 'capybara-selenium' gem 'chromedriver-helper' gem 'codeclimate-test-reporter', require: false @@ -113,6 +113,6 @@ group :test do end group :production do - gem 'aamva', git: 'git@github.com:18F/identity-aamva-api-client-gem', tag: 'v3.1.0' - gem 'lexisnexis', git: 'git@github.com:18F/identity-lexisnexis-api-client-gem', tag: 'v1.1.0' + gem 'aamva', git: 'git@github.com:18F/identity-aamva-api-client-gem', tag: 'v3.2.1' + gem 'lexisnexis', git: 'git@github.com:18F/identity-lexisnexis-api-client-gem', tag: 'v1.2.0' end diff --git a/Gemfile.lock b/Gemfile.lock index a809613d807..0fc9ea0a6ef 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,20 +1,20 @@ GIT remote: git@github.com:18F/identity-aamva-api-client-gem - revision: f69b0295933809057292736ed173a5a5e11b668c - tag: v3.1.0 + revision: 03fc2f8c9bfb3218da2a7cb7c309b0650a5bebe5 + tag: v3.2.1 specs: - aamva (3.1.0) + aamva (3.2.1) dotenv hashie - httpi + typhoeus xmldsig GIT remote: git@github.com:18F/identity-lexisnexis-api-client-gem - revision: d17049ab1a03d50c0cc8a272d86cf2144192fab5 - tag: v1.1.0 + revision: 29f554ed2ea237c59a20fdbe4a675508b9c8539d + tag: v1.2.0 specs: - lexisnexis (1.1.0) + lexisnexis (1.2.0) dotenv typhoeus @@ -28,10 +28,10 @@ GIT GIT remote: https://github.com/18F/identity-proofer-gem.git - revision: 875246d603bbd9b29cbc82493513f948d4e8689b - tag: v2.6.1 + revision: f48ecd14ef602d574119022c0fc15350cbd7baa0 + tag: v2.7.0 specs: - proofer (2.6.1) + proofer (2.7.0) GIT remote: https://github.com/18F/redis-session-store.git @@ -55,14 +55,6 @@ GIT pkcs11 uuid -GIT - remote: https://github.com/mattheworiordan/capybara-screenshot.git - revision: fe47a6816677d573fd5428dae60a31bc8898f623 - specs: - capybara-screenshot (1.0.14) - capybara (>= 1.0, < 3) - launchy - GEM remote: https://rubygems.org/ specs: @@ -168,14 +160,17 @@ GEM bummr (0.3.2) rainbow thor - byebug (10.0.0) - capybara (2.17.0) + byebug (10.0.2) + capybara (3.7.2) addressable mini_mime (>= 0.1.3) - nokogiri (>= 1.3.3) - rack (>= 1.0.0) - rack-test (>= 0.5.4) - xpath (>= 2.0, < 4.0) + nokogiri (~> 1.8) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + xpath (~> 3.1) + capybara-screenshot (1.0.21) + capybara (>= 1.0, < 4) + launchy capybara-selenium (0.0.6) capybara selenium-webdriver @@ -226,7 +221,7 @@ GEM warden (~> 1.2.3) diff-lcs (1.3) docile (1.1.5) - dotenv (2.4.0) + dotenv (2.5.0) dotiw (4.0.1) actionpack (>= 4) i18n @@ -348,7 +343,7 @@ GEM mini_mime (>= 0.1.1) memory_profiler (0.9.11) method_source (0.9.0) - mini_mime (1.0.0) + mini_mime (1.0.1) mini_portile2 (2.3.0) minitest (5.11.3) multi_xml (0.6.0) @@ -358,7 +353,7 @@ GEM net-sftp (2.1.2) net-ssh (>= 2.6.5) net-ssh (4.1.0) - newrelic_rpm (5.2.0.345) + newrelic_rpm (5.4.0.347) nio4r (2.3.1) nokogiri (1.8.4) mini_portile2 (~> 2.3.0) @@ -390,7 +385,8 @@ GEM pry-byebug (3.6.0) byebug (~> 10.0) pry (~> 0.10) - public_suffix (3.0.2) + public_suffix (3.0.3) + puma (3.12.0) rack (2.0.5) rack-attack (5.4.0) rack (>= 1.0, < 3) @@ -536,10 +532,6 @@ GEM shellany (0.0.1) shoulda-matchers (3.1.2) activesupport (>= 4.0.0) - sidekiq (5.2.1) - connection_pool (~> 2.2, >= 2.2.2) - rack-protection (>= 1.5.0) - redis (>= 3.3.5, < 5) simple_form (4.0.1) actionpack (>= 5.0) activemodel (>= 5.0) @@ -646,7 +638,7 @@ GEM xmlmapper (>= 0.7.3) xmlmapper (0.7.3) nokogiri (~> 1.5) - xpath (3.0.0) + xpath (3.1.0) nokogiri (~> 1.8) zonebie (0.6.1) zxcvbn-js (4.4.3) @@ -668,7 +660,7 @@ DEPENDENCIES brakeman bullet bummr - capybara-screenshot! + capybara-screenshot capybara-selenium chromedriver-helper codeclimate-test-reporter @@ -709,6 +701,7 @@ DEPENDENCIES premailer-rails proofer! pry-byebug + puma rack-attack rack-cors rack-headers_filter @@ -735,7 +728,6 @@ DEPENDENCIES scrypt secure_headers (~> 6.0) shoulda-matchers (~> 3.0) - sidekiq simple_form sinatra slim-rails @@ -761,4 +753,4 @@ RUBY VERSION ruby 2.5.1p57 BUNDLED WITH - 1.16.4 + 1.16.5 diff --git a/Guardfile b/Guardfile new file mode 100644 index 00000000000..8e882c9ac0a --- /dev/null +++ b/Guardfile @@ -0,0 +1,63 @@ +# A sample Guardfile +# More info at https://github.com/guard/guard#readme + +## Uncomment and set this to only include directories you want to watch +# directories %w(app lib config test spec features) \ +# .select{|d| Dir.exists?(d) ? d : UI.warning("Directory #{d} does not exist")} + +## Note: if you are using the `directories` clause above and you are not +## watching the project directory ('.'), then you will want to move +## the Guardfile to a watched dir and symlink it back, e.g. +# +# $ mkdir config +# $ mv Guardfile config/ +# $ ln -s config/Guardfile . +# +# and, you'll have to watch "config/Guardfile" instead of "Guardfile" + +# Note: The cmd option is now required due to the increasing number of ways +# rspec may be run, below are examples of the most common uses. +# * bundler: 'bundle exec rspec' +# * bundler binstubs: 'bin/rspec' +# * spring: 'bin/rspec' (This will use spring if running and you have +# installed the spring binstubs per the docs) +# * zeus: 'zeus rspec' (requires the server to be started separately) +# * 'just' rspec: 'rspec' +guard :rspec, cmd: ENV['GUARD_RSPEC_CMD'] || 'bundle exec rspec' do + require 'guard/rspec/dsl' + dsl = Guard::RSpec::Dsl.new(self) + + # Feel free to open issues for suggestions and improvements + + # RSpec files + rspec = dsl.rspec + watch(rspec.spec_helper) { rspec.spec_dir } + watch(rspec.spec_support) { rspec.spec_dir } + watch(rspec.spec_files) + + # Ruby files + ruby = dsl.ruby + dsl.watch_spec_files_for(ruby.lib_files) + + # Rails files + rails = dsl.rails(view_extensions: %w[erb haml slim]) + dsl.watch_spec_files_for(rails.app_files) + dsl.watch_spec_files_for(rails.views) + + watch(rails.controllers) do |m| + [ + rspec.spec.call("routing/#{m[1]}_routing"), + rspec.spec.call("controllers/#{m[1]}_controller"), + rspec.spec.call("acceptance/#{m[1]}"), + ] + end + + # Rails config changes + watch(rails.spec_helper) { rspec.spec_dir } + watch(rails.routes) { "#{rspec.spec_dir}/routing" } + watch(rails.app_controller) { "#{rspec.spec_dir}/controllers" } + + # Capybara features specs + watch(rails.view_dirs) { |m| rspec.spec.call("features/#{m[1]}") } + watch(rails.layouts) { |m| rspec.spec.call("features/#{m[1]}") } +end diff --git a/Procfile b/Procfile index 6ccc9da9196..fda38a5ebc1 100644 --- a/Procfile +++ b/Procfile @@ -1,3 +1,2 @@ web: bundle exec rackup config.ru --port ${PORT:-3000} -worker: bundle exec sidekiq --config config/sidekiq.yml mailcatcher: mailcatcher -f diff --git a/Procfile_dev b/Procfile_dev index 304aa900a6a..0b4f88ff8e2 100644 --- a/Procfile_dev +++ b/Procfile_dev @@ -1,3 +1,2 @@ web: bin/rails s -b 127.0.0.1 -p ${PORT:-3000} -worker: bundle exec sidekiq --config config/sidekiq.yml mailcatcher: mailcatcher -f diff --git a/README.md b/README.md index 2b09568866a..4c4d5c45666 100644 --- a/README.md +++ b/README.md @@ -239,23 +239,37 @@ To run a subset of tests excluding slow tests (such as accessibility specs): $ make fast_test ``` +#### Speeding up local development and testing +To automatically run the test that corresponds to the file you are editing, +run `bundle exec guard` with the env var `GUARD_RSPEC_CMD` set to your preferred +command for running `rspec`. For example, if you use [Zeus](https://github.com/burke/zeus), +you would set the env var to `zeus rspec`: +```console +GUARD_RSPEC_CMD="zeus rspec" bundle exec guard +``` + +If you don't specify the `GUARD_RSPEC_CMD` env var, it will default to +`bundle exec rspec`. + +We recommend setting up a shell alias for running this command, such as: +```console +alias idpguard='GUARD_RSPEC_CMD="zeus rspec" bundle exec guard' +``` + +#### Troubleshooting If you are on a mac, if you receive the following prompt the first time you run the test suite, enter `sekret` as the passphrase: ![alt text][mac-test-passphrase-prompt] -See RSpec [docs](https://relishapp.com/rspec/rspec-core/docs/command-line) for -more information. +#### Documentation for the testing tools we use +[RSpec](https://relishapp.com/rspec/rspec-core/docs/command-line) + +[Guard](https://github.com/guard/guard-rspec) JavaScript unit tests run using the mocha test runner. Check out the [mocha documentation](https://mochajs.org/) for more details. -Run security scanner - -``` -$ make brakeman -``` - -#### User flows +### User flows We have an automated tool for generating user flows using real views generated from the application. These specs are excluded from our typical spec run because of the overhead of generating screenshots for each view. diff --git a/app/assets/images/personal-key/lock-check.svg b/app/assets/images/personal-key/lock-check.svg new file mode 100644 index 00000000000..1b2e50bb1e4 --- /dev/null +++ b/app/assets/images/personal-key/lock-check.svg @@ -0,0 +1 @@ +lock-check-4x \ No newline at end of file diff --git a/app/assets/images/personal-key/pkey-block.svg b/app/assets/images/personal-key/pkey-block.svg new file mode 100644 index 00000000000..f640cd2e347 --- /dev/null +++ b/app/assets/images/personal-key/pkey-block.svg @@ -0,0 +1 @@ +pkey-block \ No newline at end of file diff --git a/app/assets/images/personal-key/shield.svg b/app/assets/images/personal-key/shield.svg new file mode 100644 index 00000000000..edc8cff37f4 --- /dev/null +++ b/app/assets/images/personal-key/shield.svg @@ -0,0 +1 @@ +shield \ No newline at end of file diff --git a/app/assets/images/personal-key/warning.svg b/app/assets/images/personal-key/warning.svg new file mode 100644 index 00000000000..00872b93f10 --- /dev/null +++ b/app/assets/images/personal-key/warning.svg @@ -0,0 +1 @@ +warning-4x \ No newline at end of file diff --git a/app/assets/images/sp-logos/homes_mil.png b/app/assets/images/sp-logos/homes_mil.png new file mode 100755 index 00000000000..e3c3c8fe1c2 Binary files /dev/null and b/app/assets/images/sp-logos/homes_mil.png differ diff --git a/app/assets/javascripts/i18n-strings.js.erb b/app/assets/javascripts/i18n-strings.js.erb index 503a5eed7e0..e96923d8398 100644 --- a/app/assets/javascripts/i18n-strings.js.erb +++ b/app/assets/javascripts/i18n-strings.js.erb @@ -1,14 +1,15 @@ window.LoginGov = window.LoginGov || {}; <% keys = [ - 'devise.two_factor_authentication.otp_delivery_preference.instruction', - 'devise.two_factor_authentication.otp_delivery_preference.phone_unsupported', + 'two_factor_authentication.otp_delivery_preference.instruction', + 'two_factor_authentication.otp_delivery_preference.phone_unsupported', 'errors.messages.format_mismatch', 'errors.messages.missing_field', 'forms.passwords.show', 'idv.errors.pattern_mismatch.dob', 'idv.errors.pattern_mismatch.personal_key', 'idv.errors.pattern_mismatch.ssn', + 'idv.errors.pattern_mismatch.state_id_number', 'idv.errors.pattern_mismatch.zipcode', 'idv.failure.button.warning', 'instructions.password.strength.i', diff --git a/app/assets/stylesheets/components/_list.scss b/app/assets/stylesheets/components/_list.scss index e5bbb61e3a3..85cd6b8ed67 100644 --- a/app/assets/stylesheets/components/_list.scss +++ b/app/assets/stylesheets/components/_list.scss @@ -53,3 +53,7 @@ .yellow-dots { @include color-list($yellow); } + +.account-list-item { + padding-left: 3rem; +} diff --git a/app/assets/stylesheets/components/_personal-key.scss b/app/assets/stylesheets/components/_personal-key.scss index 2add0346dcc..0572eb1cece 100644 --- a/app/assets/stylesheets/components/_personal-key.scss +++ b/app/assets/stylesheets/components/_personal-key.scss @@ -30,7 +30,31 @@ @media #{$breakpoint-sm} { .separator-text > div { &::after { + color: #000; padding: 0 .5rem; } } } + +.bg-pk-box { + background-color: $blue-lightest; + background-image: image-url('personal-key/pkey-block.svg'); + background-position: center; + background-repeat: no-repeat; +} + +.bg-personal-key { + background-color: $blue-lightest; + background-image: image-url('personal-key/shield.svg'); + background-position: center; + background-repeat: no-repeat; + background-size: 244px 136px; + border-bottom: 1px solid $border-color; +} + +.full-width-box { + background-color: $blue-lightest; + border-top: 1px solid $border-color; + margin-left: -80px; + width: 135%; +} diff --git a/app/controllers/account_reset/cancel_controller.rb b/app/controllers/account_reset/cancel_controller.rb index 44f66f8cb90..0729f3cf7b6 100644 --- a/app/controllers/account_reset/cancel_controller.rb +++ b/app/controllers/account_reset/cancel_controller.rb @@ -1,9 +1,24 @@ module AccountReset class CancelController < ApplicationController + before_action :check_feature_enabled + + def show + return render :show unless token + + result = AccountReset::ValidateCancelToken.new(token).call + track_event(result) + + if result.success? + handle_valid_token + else + handle_invalid_token(result) + end + end + def create - result = AccountReset::Cancel.new(params[:token]).call + result = AccountReset::Cancel.new(session[:cancel_token]).call - analytics.track_event(Analytics::ACCOUNT_RESET, result.to_h) + track_event(result) handle_success if result.success? @@ -12,9 +27,31 @@ def create private + def check_feature_enabled + redirect_to root_url unless FeatureManagement.account_reset_enabled? + end + + def track_event(result) + analytics.track_event(Analytics::ACCOUNT_RESET, result.to_h) + end + + def handle_valid_token + session[:cancel_token] = token + redirect_to url_for + end + + def handle_invalid_token(result) + flash[:error] = result.errors[:token].first + redirect_to root_url + end + def handle_success sign_out if current_user - flash[:success] = t('devise.two_factor_authentication.account_reset.successful_cancel') + flash[:success] = t('two_factor_authentication.account_reset.successful_cancel') + end + + def token + params[:token] end end end diff --git a/app/controllers/account_reset/confirm_request_controller.rb b/app/controllers/account_reset/confirm_request_controller.rb index f3c554062ae..fef469212f9 100644 --- a/app/controllers/account_reset/confirm_request_controller.rb +++ b/app/controllers/account_reset/confirm_request_controller.rb @@ -6,7 +6,8 @@ def show redirect_to root_url else render :show, locals: { - email: email, sms_phone: SmsLoginOptionPolicy.new(current_user).configured? + email: email, + sms_phone: TwoFactorAuthentication::PhonePolicy.new(current_user).configured?, } sign_out end diff --git a/app/controllers/account_reset/report_fraud_controller.rb b/app/controllers/account_reset/report_fraud_controller.rb deleted file mode 100644 index f3763b29d18..00000000000 --- a/app/controllers/account_reset/report_fraud_controller.rb +++ /dev/null @@ -1,25 +0,0 @@ -module AccountReset - class ReportFraudController < ApplicationController - def update - if AccountResetService.report_fraud(params[:token]) - handle_success - else - handle_failure - end - redirect_to root_url - end - - private - - def handle_success - analytics.track_event(Analytics::ACCOUNT_RESET, event: :fraud, token_valid: true) - sign_out if current_user - flash[:success] = t('devise.two_factor_authentication.account_reset.successful_cancel') - end - - def handle_failure - return if params[:token].blank? - analytics.track_event(Analytics::ACCOUNT_RESET, event: :fraud, token_valid: false) - end - end -end diff --git a/app/controllers/account_reset/request_controller.rb b/app/controllers/account_reset/request_controller.rb index b6698c03ca7..27fe71cbf56 100644 --- a/app/controllers/account_reset/request_controller.rb +++ b/app/controllers/account_reset/request_controller.rb @@ -24,7 +24,7 @@ def check_account_reset_enabled end def confirm_two_factor_enabled - return if current_user.two_factor_enabled? + return if MfaPolicy.new(current_user).two_factor_enabled? redirect_to two_factor_options_url end @@ -37,9 +37,9 @@ def confirm_user_not_verified def analytics_attributes { event: 'request', - sms_phone: SmsLoginOptionPolicy.new(current_user).configured?, - totp: AuthAppLoginOptionPolicy.new(current_user).configured?, - piv_cac: PivCacLoginOptionPolicy.new(current_user).configured?, + sms_phone: TwoFactorAuthentication::PhonePolicy.new(current_user).configured?, + totp: TwoFactorAuthentication::AuthAppPolicy.new(current_user).configured?, + piv_cac: TwoFactorAuthentication::PivCacPolicy.new(current_user).configured?, } end end diff --git a/app/controllers/account_reset/send_notifications_controller.rb b/app/controllers/account_reset/send_notifications_controller.rb index 5626674c33e..313ff0ae168 100644 --- a/app/controllers/account_reset/send_notifications_controller.rb +++ b/app/controllers/account_reset/send_notifications_controller.rb @@ -1,10 +1,13 @@ +# This controller is not user-facing. It is only accessed by an AWS Lambda that +# is triggered by CloudWatch to run on a recurring basis. The Lambda is defined +# in lib/lambdas/account_reset_lambda.rb module AccountReset class SendNotificationsController < ApplicationController skip_before_action :verify_authenticity_token before_action :authorize def update - count = AccountResetService.grant_tokens_and_send_notifications + count = AccountReset::GrantRequestsAndSendEmails.new.call analytics.track_event(Analytics::ACCOUNT_RESET, event: :notifications, count: count) render plain: 'ok' end diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 0216afbab51..7187d91f68a 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -11,7 +11,8 @@ def show @view_model = AccountShow.new( decrypted_pii: cacher.fetch, personal_key: flash[:personal_key], - decorated_user: current_user.decorate + decorated_user: current_user.decorate, + sp: current_sp ) end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 9943a413fc3..462c8f3e7df 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -149,7 +149,8 @@ def invalid_auth_token(_exception) end def user_fully_authenticated? - !reauthn? && user_signed_in? && current_user.two_factor_enabled? && is_fully_authenticated? + !reauthn? && user_signed_in? && + MfaPolicy.new(current_user).two_factor_enabled? && is_fully_authenticated? end def reauthn? @@ -162,7 +163,7 @@ def confirm_two_factor_authenticated return if user_fully_authenticated? - return prompt_to_set_up_2fa unless current_user.two_factor_enabled? + return prompt_to_set_up_2fa unless MfaPolicy.new(current_user).two_factor_enabled? prompt_to_enter_otp end diff --git a/app/controllers/concerns/account_recoverable.rb b/app/controllers/concerns/account_recoverable.rb index aabeb95017e..cc3a3e1a46c 100644 --- a/app/controllers/concerns/account_recoverable.rb +++ b/app/controllers/concerns/account_recoverable.rb @@ -1,5 +1,9 @@ module AccountRecoverable + # :reek:FeatureEnvy def piv_cac_enabled_but_not_phone_enabled? - current_user.piv_cac_enabled? && current_user.phone_configurations.none?(&:mfa_enabled?) + # we need to change this so it's about having multiple mfa methods defined rather than + # piv/cac + phone. Leaving as-is for now. + TwoFactorAuthentication::PivCacPolicy.new(current_user).enabled? && + !TwoFactorAuthentication::PhonePolicy.new(current_user).enabled? end end diff --git a/app/controllers/concerns/authorizable.rb b/app/controllers/concerns/authorizable.rb index 8a1cffe732b..5722ae99fe4 100644 --- a/app/controllers/concerns/authorizable.rb +++ b/app/controllers/concerns/authorizable.rb @@ -1,10 +1,10 @@ module Authorizable def authorize_user - return unless current_user.phone_configurations.any?(&:mfa_enabled?) + return unless TwoFactorAuthentication::PhonePolicy.new(current_user).enabled? if user_fully_authenticated? redirect_to account_url - elsif current_user.two_factor_enabled? + elsif MfaPolicy.new(current_user).two_factor_enabled? redirect_to user_two_factor_authentication_url end end diff --git a/app/controllers/concerns/fully_authenticatable.rb b/app/controllers/concerns/fully_authenticatable.rb index a0326a0ff11..c89e80a6d34 100644 --- a/app/controllers/concerns/fully_authenticatable.rb +++ b/app/controllers/concerns/fully_authenticatable.rb @@ -2,7 +2,7 @@ module FullyAuthenticatable def confirm_two_factor_authenticated(id = nil) return redirect_to sign_up_start_url(request_id: id) unless user_signed_in? - return prompt_to_set_up_2fa unless current_user.two_factor_enabled? + return prompt_to_set_up_2fa unless MfaPolicy.new(current_user).two_factor_enabled? prompt_to_enter_otp end diff --git a/app/controllers/concerns/idv_failure_concern.rb b/app/controllers/concerns/idv_failure_concern.rb index aedc9ff3dc3..7b9ceb8868f 100644 --- a/app/controllers/concerns/idv_failure_concern.rb +++ b/app/controllers/concerns/idv_failure_concern.rb @@ -1,13 +1,6 @@ module IdvFailureConcern extend ActiveSupport::Concern - def idv_step_failure_reason - return :fail if fail? - return :jobfail if jobfail? - return :timeout if timeout? - return :warning if warning? - end - def render_idv_step_failure(step, reason) return render_failure('shared/_failure', failure_presenter(step)) if reason == :fail render_failure('idv/shared/verification_failure', warning_presenter(step, reason)) @@ -19,22 +12,6 @@ def render_failure(template, presenter) private - def fail? - idv_attempter.exceeded? || step_attempts_exceeded? - end - - def jobfail? - step.vendor_validator_job_failed? - end - - def timeout? - !step.vendor_validation_passed? && step.vendor_validation_timed_out? - end - - def warning? - !step.vendor_validation_passed? && !step.vendor_validation_timed_out? - end - def failure_presenter(step) Idv::MaxAttemptsFailurePresenter.new( decorated_session: decorated_session, diff --git a/app/controllers/concerns/idv_session.rb b/app/controllers/concerns/idv_session.rb index 883f2c840bd..c16d376596b 100644 --- a/app/controllers/concerns/idv_session.rb +++ b/app/controllers/concerns/idv_session.rb @@ -3,7 +3,7 @@ module IdvSession def confirm_idv_session_started return if current_user.decorate.pending_profile_requires_verification? - redirect_to idv_session_url if idv_session.params.blank? + redirect_to idv_session_url if idv_session.applicant.blank? end def confirm_idv_attempts_allowed @@ -35,32 +35,4 @@ def idv_session def idv_attempter @_idv_attempter ||= Idv::Attempter.new(current_user) end - - def vendor_validator_result - return timed_out_vendor_error if vendor_result_timed_out? - - VendorValidatorResultStorage.new.load(idv_session.async_result_id) - end - - def vendor_result_timed_out? - started_at = idv_session.async_result_started_at - return false if started_at.blank? - - expiration = started_at + Figaro.env.async_job_refresh_max_wait_seconds.to_i - Time.zone.now.to_i >= expiration - end - - def timed_out_vendor_error - Idv::VendorResult.new( - success: false, - errors: { timed_out: ['Timed out waiting for vendor response'] }, - timed_out: true - ) - end - - def refresh_if_not_ready - return if vendor_validator_result.present? - - render 'shared/refresh' - end end diff --git a/app/controllers/concerns/idv_step_concern.rb b/app/controllers/concerns/idv_step_concern.rb index 7a4a0231794..a16b12ee25b 100644 --- a/app/controllers/concerns/idv_step_concern.rb +++ b/app/controllers/concerns/idv_step_concern.rb @@ -11,10 +11,6 @@ module IdvStepConcern private - def increment_step_attempts - idv_session.step_attempts[step_name] += 1 - end - def remaining_step_attempts Idv::Attempter.idv_max_attempts - idv_session.step_attempts[step_name] end diff --git a/app/controllers/concerns/phone_confirmation.rb b/app/controllers/concerns/phone_confirmation.rb index 907dd65e29e..ebf8df79f07 100644 --- a/app/controllers/concerns/phone_confirmation.rb +++ b/app/controllers/concerns/phone_confirmation.rb @@ -15,7 +15,7 @@ def prompt_to_confirm_phone(phone:, selected_delivery_method: nil) def otp_delivery_method(phone, selected_delivery_method) return :sms if PhoneNumberCapabilities.new(phone).sms_only? return selected_delivery_method if selected_delivery_method.present? - current_user.phone_configurations.first&.delivery_preference || + MfaContext.new(current_user).phone_configurations.first&.delivery_preference || current_user.otp_delivery_preference end end diff --git a/app/controllers/concerns/two_factor_authenticatable.rb b/app/controllers/concerns/two_factor_authenticatable.rb index 82a1cd0994f..56c6b95c917 100644 --- a/app/controllers/concerns/two_factor_authenticatable.rb +++ b/app/controllers/concerns/two_factor_authenticatable.rb @@ -87,11 +87,11 @@ def two_factor_authentication_method # Method will be renamed in the next refactor. # You can pass in any "type" with a corresponding I18n key in - # devise.two_factor_authentication.invalid_#{type} + # two_factor_authentication.invalid_#{type} def handle_invalid_otp(type: 'otp') update_invalid_user - flash.now[:error] = t("devise.two_factor_authentication.invalid_#{type}") + flash.now[:error] = t("two_factor_authentication.invalid_#{type}") if decorated_user.locked_out? handle_second_factor_locked_user(type) @@ -140,7 +140,7 @@ def assign_phone end def old_phone - current_user.phone_configurations.first&.phone + MfaContext.new(current_user).phone_configurations.first&.phone end def phone_changed @@ -221,7 +221,6 @@ def phone_view_data voice_otp_delivery_unsupported: voice_otp_delivery_unsupported?, reenter_phone_number_path: reenter_phone_number_path, unconfirmed_phone: unconfirmed_phone?, - totp_enabled: current_user.totp_enabled?, remember_device_available: true, account_reset_token: account_reset_token, }.merge(generic_data) @@ -237,14 +236,12 @@ def authenticator_view_data two_factor_authentication_method: two_factor_authentication_method, user_email: current_user.email, remember_device_available: false, - phone_enabled: current_user.phone_configurations.any?(&:mfa_enabled?), }.merge(generic_data) end def generic_data { personal_key_unavailable: personal_key_unavailable?, - has_piv_cac_configured: current_user.piv_cac_enabled?, reauthn: reauthn?, } end @@ -259,7 +256,7 @@ def display_phone_to_deliver_to def voice_otp_delivery_unsupported? phone_number = if authentication_context? - current_user.phone_configurations.first&.phone + MfaContext.new(current_user).phone_configurations.first&.phone else user_session[:unconfirmed_phone] end @@ -272,7 +269,7 @@ def decorated_user def reenter_phone_number_path locale = LinkLocaleResolver.locale - if current_user.phone_configurations.any? + if MfaContext.new(current_user).phone_configurations.any? manage_phone_path(locale: locale) else phone_setup_path(locale: locale) @@ -280,7 +277,7 @@ def reenter_phone_number_path end def confirmation_for_phone_change? - confirmation_context? && current_user.phone_configurations.any? + confirmation_context? && MfaContext.new(current_user).phone_configurations.any? end def presenter_for_two_factor_authentication_method diff --git a/app/controllers/concerns/unconfirmed_user_concern.rb b/app/controllers/concerns/unconfirmed_user_concern.rb index 432c1ee418e..86cc1d47b6c 100644 --- a/app/controllers/concerns/unconfirmed_user_concern.rb +++ b/app/controllers/concerns/unconfirmed_user_concern.rb @@ -40,7 +40,7 @@ def process_confirmed_user def after_confirmation_url_for(user) if !user_signed_in? new_user_session_url - elsif user.two_factor_enabled? + elsif MfaPolicy.new(user).two_factor_enabled? account_url else two_factor_options_url diff --git a/app/controllers/health/health_controller.rb b/app/controllers/health/health_controller.rb index d9fd4f5fec4..99927729aca 100644 --- a/app/controllers/health/health_controller.rb +++ b/app/controllers/health/health_controller.rb @@ -5,15 +5,8 @@ class HealthController < AbstractHealthController def health_checker checkers = { database: DatabaseHealthChecker, - workers: WorkerHealthChecker, account_reset: AccountResetHealthChecker, } - # Don't run worker health checks if we're not using workers (i.e. if the - # queue adapter is inline or async) - case Rails.application.config.active_job.queue_adapter - when :async, :inline - checkers.delete(:workers) - end MultiHealthChecker.new(**checkers) end end diff --git a/app/controllers/health/workers_controller.rb b/app/controllers/health/workers_controller.rb deleted file mode 100644 index 43a172ae705..00000000000 --- a/app/controllers/health/workers_controller.rb +++ /dev/null @@ -1,9 +0,0 @@ -module Health - class WorkersController < AbstractHealthController - private - - def health_checker - WorkerHealthChecker - end - end -end diff --git a/app/controllers/idv/confirmations_controller.rb b/app/controllers/idv/confirmations_controller.rb index e5a210c8579..e81a6e053c4 100644 --- a/app/controllers/idv/confirmations_controller.rb +++ b/app/controllers/idv/confirmations_controller.rb @@ -33,11 +33,10 @@ def confirm_profile_has_been_created end def track_final_idv_event + configured_phones = MfaContext.new(current_user).phone_configurations.map(&:phone) result = { success: true, - new_phone_added: !current_user.phone_configurations.map(&:phone).include?( - idv_session.params['phone'] - ), + new_phone_added: !configured_phones.include?(idv_session.applicant['phone']), } analytics.track_event(Analytics::IDV_FINAL, result) end diff --git a/app/controllers/idv/doc_auth_controller.rb b/app/controllers/idv/doc_auth_controller.rb new file mode 100644 index 00000000000..c2da610a29e --- /dev/null +++ b/app/controllers/idv/doc_auth_controller.rb @@ -0,0 +1,15 @@ +module Idv + class DocAuthController < ApplicationController + include IdvSession # remove if we retire the non docauth LOA3 flow + include Flow::FlowStateMachine + + FSM_SETTINGS = { + step_url: :idv_doc_auth_step_url, + final_url: :idv_review_url, + flow: Idv::Flows::DocAuthFlow, + analytics_id: Analytics::DOC_AUTH, + }.freeze + + before_action :confirm_two_factor_authenticated + end +end diff --git a/app/controllers/idv/jurisdiction_controller.rb b/app/controllers/idv/jurisdiction_controller.rb index 880f68aa2ec..34350a3e67b 100644 --- a/app/controllers/idv/jurisdiction_controller.rb +++ b/app/controllers/idv/jurisdiction_controller.rb @@ -5,6 +5,7 @@ class JurisdictionController < ApplicationController before_action :confirm_two_factor_authenticated before_action :confirm_idv_attempts_allowed before_action :confirm_idv_needed + before_action :confirm_step_needed, only: %i[new create] before_action :set_jurisdiction_form, except: [:failure] def new @@ -14,7 +15,7 @@ def new def create result = @jurisdiction_form.submit(jurisdiction_params) analytics.track_event(Analytics::IDV_JURISDICTION_FORM, result.to_h) - user_session[:idv_jurisdiction] = @jurisdiction_form.state + idv_session.selected_jurisdiction = @jurisdiction_form.state if result.success? redirect_to idv_session_url @@ -29,7 +30,7 @@ def create def failure presenter = Idv::JurisdictionFailurePresenter.new( reason: params[:reason], - jurisdiction: user_session[:idv_jurisdiction], + jurisdiction: idv_session.selected_jurisdiction, view_context: view_context ) render_full_width('shared/_failure', locals: { presenter: presenter }) @@ -45,6 +46,11 @@ def set_jurisdiction_form @jurisdiction_form ||= Idv::JurisdictionForm.new end + def confirm_step_needed + return if idv_session.selected_jurisdiction.nil? + redirect_to idv_session_url + end + def failure_url(reason) idv_jurisdiction_failure_url(reason) end diff --git a/app/controllers/idv/otp_delivery_method_controller.rb b/app/controllers/idv/otp_delivery_method_controller.rb index 89d2dd5da56..b9f2812f2b6 100644 --- a/app/controllers/idv/otp_delivery_method_controller.rb +++ b/app/controllers/idv/otp_delivery_method_controller.rb @@ -16,7 +16,7 @@ def new def create result = otp_delivery_selection_form.submit(otp_delivery_selection_params) analytics.track_event(Analytics::IDV_PHONE_OTP_DELIVERY_SELECTION_SUBMITTED, result.to_h) - return render(:new) unless result.success? + return render_new_with_error_message unless result.success? send_phone_confirmation_otp_and_handle_result rescue Twilio::REST::RestError, PhoneVerification::VerifyError => exception invalid_phone_number(exception) @@ -34,13 +34,16 @@ def confirm_step_needed end def set_idv_phone - @idv_phone = PhoneFormatter.format(idv_session.params[:phone]) + @idv_phone = PhoneFormatter.format(idv_session.applicant[:phone]) end def otp_delivery_selection_params - params.require(:otp_delivery_selection_form).permit( - :otp_delivery_preference - ) + params.permit(:otp_delivery_preference) + end + + def render_new_with_error_message + flash[:error] = t('idv.errors.unsupported_otp_delivery_method') + render :new end def send_phone_confirmation_otp_and_handle_result diff --git a/app/controllers/idv/otp_verification_controller.rb b/app/controllers/idv/otp_verification_controller.rb index 3ee9294bdb1..75707fcb9d7 100644 --- a/app/controllers/idv/otp_verification_controller.rb +++ b/app/controllers/idv/otp_verification_controller.rb @@ -52,7 +52,7 @@ def handle_otp_confirmation_failure if decorated_user.locked_out? handle_too_many_otp_attempts else - flash.now[:error] = t('devise.two_factor_authentication.invalid_otp') + flash.now[:error] = t('two_factor_authentication.invalid_otp') render :show end end diff --git a/app/controllers/idv/phone_controller.rb b/app/controllers/idv/phone_controller.rb index 1a6810dbf86..6353664d7df 100644 --- a/app/controllers/idv/phone_controller.rb +++ b/app/controllers/idv/phone_controller.rb @@ -7,8 +7,8 @@ class PhoneController < ApplicationController before_action :confirm_step_needed before_action :confirm_step_allowed, except: [:failure] - before_action :refresh_if_not_ready, only: [:show] before_action :set_idv_form, except: [:failure] + before_action :ensure_profile_verification_not_pending, only: :new def new analytics.track_event(Analytics::IDV_PHONE_RECORD_VISIT) @@ -17,22 +17,8 @@ def new def create result = idv_form.submit(step_params) analytics.track_event(Analytics::IDV_PHONE_CONFIRMATION_FORM, result.to_h) - - if result.success? - Idv::Job.submit(idv_session, [:address]) - redirect_to idv_phone_result_url - else - render :new - end - end - - def show - result = step.submit - analytics.track_event(Analytics::IDV_PHONE_CONFIRMATION_VENDOR, result.to_h) - increment_step_attempts - - redirect_to_next_step and return if result.success? - redirect_to idv_phone_failure_url(idv_step_failure_reason) + return render(:new) unless result.success? + submit_proofing_attempt end def failure @@ -53,16 +39,24 @@ def phone_confirmation_required? idv_session.user_phone_confirmation != true end + def submit_proofing_attempt + idv_result = step.submit(step_params.to_h) + analytics.track_event(Analytics::IDV_PHONE_CONFIRMATION_VENDOR, idv_result.to_h) + redirect_to_next_step and return if idv_result.success? + handle_proofing_failure + end + + def handle_proofing_failure + idv_session.previous_phone_step_params = step_params.to_h + redirect_to failure_url(step.failure_reason) + end + def step_name :phone end def step - @_step ||= Idv::PhoneStep.new( - idv_session: idv_session, - idv_form_params: idv_form.idv_params, - vendor_validator_result: vendor_validator_result - ) + @_step ||= Idv::PhoneStep.new(idv_session: idv_session) end def step_params @@ -74,11 +68,27 @@ def confirm_step_needed end def set_idv_form - @idv_form ||= Idv::PhoneForm.new(idv_session.params, current_user) + @idv_form ||= Idv::PhoneForm.new( + user: current_user, + previous_params: idv_session.previous_phone_step_params + ) end def failure_url(reason) idv_phone_failure_url(reason) end + + def ensure_profile_verification_not_pending + verification_pending_profiles.each do |profile| + profile.update!(deactivation_reason: :verification_cancelled) + end + end + + def verification_pending_profiles + Profile.where( + user_id: current_user.id, + deactivation_reason: :verification_pending + ) + end end end diff --git a/app/controllers/idv/resend_otp_controller.rb b/app/controllers/idv/resend_otp_controller.rb index 83482415e4b..f4983a9c12b 100644 --- a/app/controllers/idv/resend_otp_controller.rb +++ b/app/controllers/idv/resend_otp_controller.rb @@ -28,7 +28,7 @@ def confirm_user_phone_confirmation_needed end def confirm_otp_delivery_preference_selected - return if idv_session.params[:phone].present? && + return if idv_session.applicant[:phone].present? && idv_session.phone_confirmation_otp_delivery_method.present? redirect_to idv_otp_delivery_method_url diff --git a/app/controllers/idv/review_controller.rb b/app/controllers/idv/review_controller.rb index 88bd1dda2d3..1f443ce8908 100644 --- a/app/controllers/idv/review_controller.rb +++ b/app/controllers/idv/review_controller.rb @@ -26,7 +26,7 @@ def confirm_current_password end def new - @idv_params = idv_params + @applicant = idv_session.applicant analytics.track_event(Analytics::IDV_REVIEW_VISIT) usps_mail_service = Idv::UspsMail.new(current_user) @@ -74,10 +74,6 @@ def init_profile idv_session.complete_session end - def idv_params - idv_session.params - end - def valid_password? current_user.valid_password?(password) end diff --git a/app/controllers/idv/sessions_controller.rb b/app/controllers/idv/sessions_controller.rb index d33df561000..06fb643cbb2 100644 --- a/app/controllers/idv/sessions_controller.rb +++ b/app/controllers/idv/sessions_controller.rb @@ -10,36 +10,21 @@ class SessionsController < ApplicationController before_action :confirm_idv_attempts_allowed, except: %i[success failure] before_action :confirm_idv_needed before_action :confirm_step_needed, except: [:success] - before_action :initialize_idv_session, only: [:create] - before_action :refresh_if_not_ready, only: [:show] delegate :attempts_exceeded?, to: :step, prefix: true def new analytics.track_event(Analytics::IDV_BASIC_INFO_VISIT) set_idv_form - @selected_state = user_session[:idv_jurisdiction] + @selected_state = idv_session.selected_jurisdiction end def create set_idv_form - result = idv_form.submit(profile_params) - analytics.track_event(Analytics::IDV_BASIC_INFO_SUBMITTED_FORM, result.to_h) - - if result.success? - Idv::Job.submit(idv_session, %i[resolution state_id]) - redirect_to idv_session_result_url - else - process_form_failure - end - end - - def show - result = step.submit - analytics.track_event(Analytics::IDV_BASIC_INFO_SUBMITTED_VENDOR, result.to_h) - - redirect_to idv_session_success_url and return if result.success? - redirect_to idv_session_failure_url(idv_step_failure_reason) + form_result = idv_form.submit(profile_params) + analytics.track_event(Analytics::IDV_BASIC_INFO_SUBMITTED_FORM, form_result.to_h) + return process_form_failure unless form_result.success? + submit_proofing_attempt end def failure @@ -57,11 +42,7 @@ def confirm_step_needed end def step - @_step ||= Idv::ProfileStep.new( - idv_form_params: idv_session.params, - idv_session: idv_session, - vendor_validator_result: vendor_validator_result - ) + @_step ||= Idv::ProfileStep.new(idv_session: idv_session) end def process_form_failure @@ -77,6 +58,18 @@ def render_dupe_ssn_failure render_failure('shared/_failure', presenter) end + def submit_proofing_attempt + idv_result = step.submit(profile_params.to_h) + analytics.track_event(Analytics::IDV_BASIC_INFO_SUBMITTED_VENDOR, idv_result.to_h) + redirect_to idv_session_success_url and return if idv_result.success? + handle_proofing_failure + end + + def handle_proofing_failure + idv_session.previous_profile_step_params = profile_params.to_h + redirect_to idv_session_failure_url(step.failure_reason) + end + def step_name :sessions end @@ -86,13 +79,10 @@ def remaining_step_attempts end def set_idv_form - @idv_form ||= Idv::ProfileForm.new((idv_session.params || {}), current_user) - end - - def initialize_idv_session - idv_session.params = profile_params.to_h - idv_session.params[:state_id_jurisdiction] = profile_params[:state] - idv_session.applicant = idv_session.vendor_params + @idv_form ||= Idv::ProfileForm.new( + user: current_user, + previous_params: idv_session.previous_profile_step_params + ) end def profile_params diff --git a/app/controllers/idv_controller.rb b/app/controllers/idv_controller.rb index 6e90c85ebe0..9093459931b 100644 --- a/app/controllers/idv_controller.rb +++ b/app/controllers/idv_controller.rb @@ -11,6 +11,8 @@ def index redirect_to idv_activated_url elsif idv_attempter.exceeded? redirect_to idv_fail_url + elsif doc_auth_enabled_and_exclusive? + redirect_to idv_doc_auth_url else analytics.track_event(Analytics::IDV_INTRO_VISIT) redirect_to idv_jurisdiction_url @@ -42,4 +44,11 @@ def profile_needs_reactivation? def active_profile? current_user.active_profile.present? end + + def doc_auth_enabled_and_exclusive? + # exclusive mode replaces the existing LOA3 flow with the doc auth flow + # non-exclusive mode allows both flows to co-exist + # in non-exclusive mode you enter the /verify/doc_auth path in the browser + FeatureManagement.doc_auth_enabled? && FeatureManagement.doc_auth_exclusive? + end end diff --git a/app/controllers/test/piv_cac_authentication_test_subject_controller.rb b/app/controllers/test/piv_cac_authentication_test_subject_controller.rb index 60fa7523534..833dfae05ef 100644 --- a/app/controllers/test/piv_cac_authentication_test_subject_controller.rb +++ b/app/controllers/test/piv_cac_authentication_test_subject_controller.rb @@ -32,7 +32,7 @@ def referrer_uri end def must_be_in_development - redirect_to root_url unless FeatureManagement.development_and_piv_cac_entry_enabled? + redirect_to root_url unless FeatureManagement.development_and_identity_pki_disabled? end def token_from_params diff --git a/app/controllers/two_factor_authentication/options_controller.rb b/app/controllers/two_factor_authentication/options_controller.rb index 87bf581bdc7..60a5c9080c9 100644 --- a/app/controllers/two_factor_authentication/options_controller.rb +++ b/app/controllers/two_factor_authentication/options_controller.rb @@ -2,6 +2,24 @@ module TwoFactorAuthentication class OptionsController < ApplicationController include TwoFactorAuthenticatable + FACTOR_TO_URL_METHOD = { + 'voice' => :otp_send_url, + 'sms' => :otp_send_url, + 'auth_app' => :login_two_factor_authenticator_url, + 'piv_cac' => :login_two_factor_piv_cac_url, + 'webauthn' => :login_two_factor_webauthn_url, + 'personal_key' => :login_two_factor_personal_key_url, + }.freeze + + EXTRA_URL_OPTIONS = { + 'voice' => { + otp_delivery_selection_form: { otp_delivery_preference: 'voice' }, + }, + 'sms' => { + otp_delivery_selection_form: { otp_delivery_preference: 'sms' }, + }, + }.freeze + def index @two_factor_options_form = TwoFactorLoginOptionsForm.new(current_user) @presenter = two_factor_options_presenter @@ -28,15 +46,19 @@ def two_factor_options_presenter end def process_valid_form - factor_to_url = { - 'voice' => otp_send_url(otp_delivery_selection_form: { otp_delivery_preference: 'voice' }), - 'personal_key' => login_two_factor_personal_key_url, - 'sms' => otp_send_url(otp_delivery_selection_form: { otp_delivery_preference: 'sms' }), - 'auth_app' => login_two_factor_authenticator_url, - 'piv_cac' => login_two_factor_piv_cac_url, - } - url = factor_to_url[@two_factor_options_form.selection] - redirect_to url if url + url = mfa_redirect_url + redirect_to url if url.present? + end + + def mfa_redirect_url + selection = @two_factor_options_form.selection + options = EXTRA_URL_OPTIONS[selection] || {} + + configuration_id = @two_factor_options_form.configuration_id + options[:id] = configuration_id if configuration_id.present? + + method = FACTOR_TO_URL_METHOD[selection] + public_send(method, options) if method.present? end def two_factor_options_form_params diff --git a/app/controllers/two_factor_authentication/otp_verification_controller.rb b/app/controllers/two_factor_authentication/otp_verification_controller.rb index f955715df68..0de0813df51 100644 --- a/app/controllers/two_factor_authentication/otp_verification_controller.rb +++ b/app/controllers/two_factor_authentication/otp_verification_controller.rb @@ -28,15 +28,19 @@ def create def confirm_two_factor_enabled return if confirmation_context? || phone_enabled? - if current_user.two_factor_enabled? && !phone_enabled? && user_signed_in? + if two_factor_enabled? && !phone_enabled? && user_signed_in? return redirect_to user_two_factor_authentication_url end redirect_to phone_setup_url end + def two_factor_enabled? + MfaPolicy.new(current_user).two_factor_enabled? + end + def phone_enabled? - current_user.phone_configurations.any?(&:mfa_enabled?) + TwoFactorAuthentication::PhonePolicy.new(current_user).enabled? end def confirm_voice_capability @@ -47,14 +51,15 @@ def confirm_voice_capability return unless capabilities.sms_only? flash[:error] = t( - 'devise.two_factor_authentication.otp_delivery_preference.phone_unsupported', + 'two_factor_authentication.otp_delivery_preference.phone_unsupported', location: capabilities.unsupported_location ) redirect_to login_two_factor_url(otp_delivery_preference: 'sms', reauthn: reauthn?) end def phone - current_user&.phone_configurations&.first&.phone || user_session[:unconfirmed_phone] + MfaContext.new(current_user).phone_configurations.first&.phone || + user_session[:unconfirmed_phone] end def form_params diff --git a/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb b/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb index 821c27be3e8..dac5f4ac600 100644 --- a/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb +++ b/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb @@ -39,7 +39,7 @@ def handle_valid_piv_cac end def next_step - if current_user.phone_configurations.any?(&:mfa_enabled?) + if TwoFactorAuthentication::PhonePolicy.new(current_user).enabled? after_otp_verification_confirmation_url else account_recovery_setup_url @@ -65,9 +65,6 @@ def piv_cac_view_data two_factor_authentication_method: two_factor_authentication_method, user_email: current_user.email, remember_device_available: false, - totp_enabled: current_user.totp_enabled?, - phone_enabled: current_user.phone_configurations.any?(&:mfa_enabled?), - piv_cac_nonce: piv_cac_nonce, }.merge(generic_data) end @@ -80,7 +77,7 @@ def piv_cac_verfication_form end def confirm_piv_cac_enabled - return if current_user.piv_cac_enabled? + return if TwoFactorAuthentication::PivCacPolicy.new(current_user).enabled? redirect_to user_two_factor_authentication_url end diff --git a/app/controllers/two_factor_authentication/webauthn_verification_controller.rb b/app/controllers/two_factor_authentication/webauthn_verification_controller.rb new file mode 100644 index 00000000000..c024719700d --- /dev/null +++ b/app/controllers/two_factor_authentication/webauthn_verification_controller.rb @@ -0,0 +1,69 @@ +module TwoFactorAuthentication + # The WebauthnVerificationController class is responsible webauthn verification at sign in + class WebauthnVerificationController < ApplicationController + include TwoFactorAuthenticatable + + before_action :confirm_webauthn_enabled, only: :show + + def show + save_challenge_in_session + @presenter = presenter_for_two_factor_authentication_method + end + + def confirm + result = form.submit(request.protocol, params) + analytics.track_event(Analytics::MULTI_FACTOR_AUTH, result.to_h.merge(analytics_properties)) + if result.success? + handle_valid_webauthn + else + handle_invalid_webauthn + end + end + + private + + def handle_valid_webauthn + handle_valid_otp_for_authentication_context + redirect_to after_otp_verification_confirmation_url + reset_otp_session_data + end + + def handle_invalid_webauthn + flash[:error] = t('errors.invalid_authenticity_token') + redirect_to login_two_factor_webauthn_url + end + + def confirm_webauthn_enabled + return if TwoFactorAuthentication::WebauthnPolicy.new(current_user, current_sp).enabled? + + redirect_to user_two_factor_authentication_url + end + + def presenter_for_two_factor_authentication_method + TwoFactorAuthCode::WebauthnAuthenticationPresenter.new( + view: view_context, + data: { credential_ids: credential_ids } + ) + end + + def save_challenge_in_session + credential_creation_options = ::WebAuthn.credential_request_options + user_session[:webauthn_challenge] = credential_creation_options[:challenge].bytes.to_a + end + + def credential_ids + MfaContext.new(current_user).webauthn_configurations.map(&:credential_id).join(',') + end + + def analytics_properties + { + context: context, + multi_factor_auth_method: 'webauthn', + } + end + + def form + WebauthnVerificationForm.new(current_user, user_session) + end + end +end diff --git a/app/controllers/users/personal_keys_controller.rb b/app/controllers/users/personal_keys_controller.rb index 0f903186138..72f2b13d462 100644 --- a/app/controllers/users/personal_keys_controller.rb +++ b/app/controllers/users/personal_keys_controller.rb @@ -27,7 +27,6 @@ def create user_session[:personal_key] = create_new_code analytics.track_event(Analytics::PROFILE_PERSONAL_KEY_CREATE) Event.create(user_id: current_user.id, event_type: :new_personal_key) - flash[:success] = t('notices.send_code.personal_key') if params[:resend].present? redirect_to manage_personal_key_url end diff --git a/app/controllers/users/phone_setup_controller.rb b/app/controllers/users/phone_setup_controller.rb index 7f035e4d117..fdd95c94adf 100644 --- a/app/controllers/users/phone_setup_controller.rb +++ b/app/controllers/users/phone_setup_controller.rb @@ -9,13 +9,13 @@ class PhoneSetupController < ApplicationController before_action :confirm_two_factor_authenticated, if: :two_factor_enabled? def index - @user_phone_form = UserPhoneForm.new(current_user) + @user_phone_form = UserPhoneForm.new(current_user, nil) @presenter = PhoneSetupPresenter.new(delivery_preference) analytics.track_event(Analytics::USER_REGISTRATION_PHONE_SETUP_VISIT) end def create - @user_phone_form = UserPhoneForm.new(current_user) + @user_phone_form = UserPhoneForm.new(current_user, nil) @presenter = PhoneSetupPresenter.new(delivery_preference) result = @user_phone_form.submit(user_phone_form_params) analytics.track_event(Analytics::MULTI_FACTOR_AUTH_PHONE_SETUP, result.to_h) @@ -30,12 +30,12 @@ def create private def delivery_preference - current_user.phone_configurations.first&.delivery_preference || + MfaContext.new(current_user).phone_configurations.first&.delivery_preference || current_user.otp_delivery_preference end def two_factor_enabled? - current_user.two_factor_enabled? + MfaPolicy.new(current_user).two_factor_enabled? end def user_phone_form_params diff --git a/app/controllers/users/phones_controller.rb b/app/controllers/users/phones_controller.rb index 708af96a429..34cc0f314ae 100644 --- a/app/controllers/users/phones_controller.rb +++ b/app/controllers/users/phones_controller.rb @@ -5,12 +5,12 @@ class PhonesController < ReauthnRequiredController before_action :confirm_two_factor_authenticated def edit - @user_phone_form = UserPhoneForm.new(current_user) + @user_phone_form = UserPhoneForm.new(current_user, phone_configuration) @presenter = PhoneSetupPresenter.new(delivery_preference) end def update - @user_phone_form = UserPhoneForm.new(current_user) + @user_phone_form = UserPhoneForm.new(current_user, phone_configuration) @presenter = PhoneSetupPresenter.new(delivery_preference) if @user_phone_form.submit(user_params).success? process_updates @@ -22,13 +22,19 @@ def update private + # we only allow editing of the first configuration since we'll eventually be + # doing away with this controller. Once we move to multiple phones, we'll allow + # adding and deleting, but not editing. + def phone_configuration + MfaContext.new(current_user).phone_configurations.first + end + def user_params params.require(:user_phone_form).permit(:phone, :international_code, :otp_delivery_preference) end def delivery_preference - current_user.phone_configurations.first&.delivery_preference || - current_user.otp_delivery_preference + phone_configuration&.delivery_preference || current_user.otp_delivery_preference end def process_updates diff --git a/app/controllers/users/piv_cac_authentication_setup_controller.rb b/app/controllers/users/piv_cac_authentication_setup_controller.rb index 3e10f52b4d3..31395b8713c 100644 --- a/app/controllers/users/piv_cac_authentication_setup_controller.rb +++ b/app/controllers/users/piv_cac_authentication_setup_controller.rb @@ -48,7 +48,7 @@ def render_error end def two_factor_enabled? - current_user.two_factor_enabled? + MfaPolicy.new(current_user).two_factor_enabled? end def process_piv_cac_setup @@ -79,8 +79,15 @@ def process_valid_submission end def next_step - return account_url if current_user.phone_configurations.any?(&:mfa_enabled?) - account_recovery_setup_url + if TwoFactorAuthentication::PhonePolicy.new(current_user).enabled? + account_url + else + account_recovery_setup_url + end + end + + def piv_cac_enabled? + TwoFactorAuthentication::PivCacPolicy.new(current_user).enabled? end def process_invalid_submission @@ -90,11 +97,11 @@ def process_invalid_submission end def authorize_piv_cac_disable - redirect_to account_url unless current_user.piv_cac_enabled? + redirect_to account_url unless piv_cac_enabled? end def authorize_piv_cac_setup - redirect_to account_url if current_user.piv_cac_enabled? + redirect_to account_url if piv_cac_enabled? end end end diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index 5b57540e8d7..f7013b02f69 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -140,7 +140,7 @@ def user_locked_out?(user) end def store_sp_metadata_in_session - return if sp_session[:issuer] || request_id.empty? + return if sp_session[:issuer] || request_id.blank? StoreSpMetadataInSession.new(session: session, request_id: request_id).call end diff --git a/app/controllers/users/totp_setup_controller.rb b/app/controllers/users/totp_setup_controller.rb index 6b0dac80dfc..cd6218f4a95 100644 --- a/app/controllers/users/totp_setup_controller.rb +++ b/app/controllers/users/totp_setup_controller.rb @@ -38,11 +38,11 @@ def disable private def two_factor_enabled? - current_user.two_factor_enabled? + MfaPolicy.new(current_user).two_factor_enabled? end def track_event - properties = { user_signed_up: current_user.two_factor_enabled? } + properties = { user_signed_up: MfaPolicy.new(current_user).two_factor_enabled? } analytics.track_event(Analytics::TOTP_SETUP_VISIT, properties) end @@ -75,7 +75,7 @@ def url_after_entering_valid_code end def user_already_has_a_personal_key? - PersonalKeyLoginOptionPolicy.new(current_user).configured? + TwoFactorAuthentication::PersonalKeyPolicy.new(current_user).configured? end def process_invalid_code diff --git a/app/controllers/users/two_factor_authentication_controller.rb b/app/controllers/users/two_factor_authentication_controller.rb index d87c2a8c74b..b4e40dc2626 100644 --- a/app/controllers/users/two_factor_authentication_controller.rb +++ b/app/controllers/users/two_factor_authentication_controller.rb @@ -4,21 +4,11 @@ class TwoFactorAuthenticationController < ApplicationController before_action :check_remember_device_preference - # rubocop:disable Metrics/MethodLength def show - if current_user.piv_cac_enabled? - redirect_to login_two_factor_piv_cac_url - elsif current_user.totp_enabled? - redirect_to login_two_factor_authenticator_url - elsif phone_enabled? - validate_otp_delivery_preference_and_send_code - else - redirect_to two_factor_options_url - end + redirect_on_non_phone || redirect_on_phone || redirect_on_nothing_enabled rescue Twilio::REST::RestError, PhoneVerification::VerifyError => exception invalid_phone_number(exception, action: 'show') end - # rubocop:enable Metrics/MethodLength def send_code result = otp_delivery_selection_form.submit(delivery_params) @@ -41,7 +31,7 @@ def phone_enabled? end def phone_configuration - current_user.phone_configurations.first + MfaContext.new(current_user).phone_configurations.first end def validate_otp_delivery_preference_and_send_code @@ -165,7 +155,7 @@ def job_params end def sms_message - if SmsLoginOptionPolicy.new(current_user).configured? + if TwoFactorAuthentication::PhonePolicy.new(current_user).configured? 'jobs.sms_otp_sender_job.login_message' else 'jobs.sms_otp_sender_job.verify_message' @@ -194,5 +184,30 @@ def user_locale def otp_rate_limiter @_otp_rate_limited ||= OtpRateLimiter.new(phone: phone_to_deliver_to, user: current_user) end + + def redirect_on_nothing_enabled + redirect_to two_factor_options_url + end + + def redirect_on_phone + return unless phone_enabled? + validate_otp_delivery_preference_and_send_code + true + end + + def redirect_url + if TwoFactorAuthentication::PivCacPolicy.new(current_user).enabled? + login_two_factor_piv_cac_url + elsif TwoFactorAuthentication::WebauthnPolicy.new(current_user, current_sp).enabled? + login_two_factor_webauthn_url + elsif TwoFactorAuthentication::AuthAppPolicy.new(current_user).enabled? + login_two_factor_authenticator_url + end + end + + def redirect_on_non_phone + url = redirect_url + redirect_to url if url.present? + end end end diff --git a/app/controllers/users/two_factor_authentication_setup_controller.rb b/app/controllers/users/two_factor_authentication_setup_controller.rb index 1ffec9ad70a..27ef357bfbb 100644 --- a/app/controllers/users/two_factor_authentication_setup_controller.rb +++ b/app/controllers/users/two_factor_authentication_setup_controller.rb @@ -39,6 +39,8 @@ def process_valid_form redirect_to authenticator_setup_url when 'piv_cac' redirect_to setup_piv_cac_url + when 'webauthn' + redirect_to webauthn_setup_url end end diff --git a/app/controllers/users/webauthn_setup_controller.rb b/app/controllers/users/webauthn_setup_controller.rb index f2a771d4068..e87525e78a2 100644 --- a/app/controllers/users/webauthn_setup_controller.rb +++ b/app/controllers/users/webauthn_setup_controller.rb @@ -20,7 +20,7 @@ def confirm end def delete - if current_user.total_mfa_options_enabled > 1 + if MfaPolicy.new(current_user).multiple_factors_enabled? handle_successful_delete else handle_failed_delete @@ -45,7 +45,7 @@ def track_delete(success) analytics.track_event( Analytics::WEBAUTHN_DELETED, success: success, - mfa_options_enabled: current_user.total_mfa_options_enabled + mfa_options_enabled: MfaContext.new(current_user).enabled_two_factor_configurations_count ) end @@ -55,13 +55,26 @@ def save_challenge_in_session end def two_factor_enabled? - current_user.two_factor_enabled? + MfaPolicy.new(current_user).two_factor_enabled? end def process_valid_webauthn(attestation_response) + mark_user_as_fully_authenticated create_webauthn_configuration(attestation_response) flash[:success] = t('notices.webauthn_added') - redirect_to account_url + redirect_to url_after_successful_webauthn_setup + end + + def url_after_successful_webauthn_setup + return account_url if user_already_has_a_personal_key? + + policy = PersonalKeyForNewUserPolicy.new(user: current_user, session: session) + + if policy.show_personal_key_after_initial_2fa_setup? + sign_up_personal_key_url + else + idv_jurisdiction_url + end end def process_invalid_webauthn(form) @@ -74,14 +87,23 @@ def process_invalid_webauthn(form) end end + def mark_user_as_fully_authenticated + user_session[TwoFactorAuthentication::NEED_AUTHENTICATION] = false + user_session[:authn_at] = Time.zone.now + end + def create_webauthn_configuration(attestation_response) credential = attestation_response.credential - public_key = Base64.encode64(credential.public_key) - id = Base64.encode64(credential.id) + public_key = Base64.strict_encode64(credential.public_key) + id = Base64.strict_encode64(credential.id) WebauthnConfiguration.create(user_id: current_user.id, credential_public_key: public_key, credential_id: id, name: params[:name]) end + + def user_already_has_a_personal_key? + TwoFactorAuthentication::PersonalKeyPolicy.new(current_user).configured? + end end end diff --git a/app/decorators/mfa_context.rb b/app/decorators/mfa_context.rb new file mode 100644 index 00000000000..065fed35200 --- /dev/null +++ b/app/decorators/mfa_context.rb @@ -0,0 +1,51 @@ +class MfaContext + attr_reader :user + + EMPTY_WEBAUTHN_ARRAY = begin + array = [] + def array.selection_presenters + [] + end + array.freeze + end + + def initialize(user) + @user = user + end + + def phone_configurations + if user.present? + user.phone_configurations + else + [] + end + end + + def webauthn_configurations + if user.present? + user.webauthn_configurations.extending WebauthnConfigurationsExtension + else + EMPTY_WEBAUTHN_ARRAY + end + end + + def piv_cac_configuration + PivCacConfiguration.new(user) + end + + def auth_app_configuration + AuthAppConfiguration.new(user) + end + + def personal_key_configuration + PersonalKeyConfiguration.new(user) + end + + def two_factor_configurations + phone_configurations + webauthn_configurations + [piv_cac_configuration, auth_app_configuration] + end + + def enabled_two_factor_configurations_count + two_factor_configurations.count(&:mfa_enabled?) + end +end diff --git a/app/decorators/service_provider_session_decorator.rb b/app/decorators/service_provider_session_decorator.rb index 46e2a470c72..87ddd3e2e6b 100644 --- a/app/decorators/service_provider_session_decorator.rb +++ b/app/decorators/service_provider_session_decorator.rb @@ -15,6 +15,14 @@ class ServiceProviderSessionDecorator i18n_name: 'sam', learn_more: 'https://login.gov/help/', }, + 'HOMES.mil - test' => { + i18n_name: 'homes_mil', + learn_more: 'https://login.gov/help/', + }, + 'HOMES.mil' => { + i18n_name: 'homes_mil', + learn_more: 'https://login.gov/help/', + }, }.freeze def initialize(sp:, view_context:, sp_session:, service_provider_request:) diff --git a/app/decorators/user_decorator.rb b/app/decorators/user_decorator.rb index 14f26073fa5..9d6f44f69ae 100644 --- a/app/decorators/user_decorator.rb +++ b/app/decorators/user_decorator.rb @@ -1,6 +1,8 @@ class UserDecorator include ActionView::Helpers::DateHelper + attr_reader :user + MAX_RECENT_EVENTS = 5 DEFAULT_LOCKOUT_PERIOD = 10.minutes @@ -9,7 +11,6 @@ def initialize(user) end delegate :email, to: :user - delegate :totp_enabled?, :piv_cac_enabled?, :piv_cac_available?, to: :user def lockout_time_remaining_in_words current_time = Time.zone.now @@ -36,7 +37,7 @@ def confirmation_period end def masked_two_factor_phone_number - masked_number(user.phone_configurations.first&.phone) + masked_number(MfaContext.new(user).phone_configurations.first&.phone) end def active_identity_for(service_provider) @@ -127,8 +128,6 @@ def delete_account_bullet_key private - attr_reader :user - def masked_number(number) return '' if number.blank? "***-***-#{number[-4..-1]}" diff --git a/app/errors/twilio_errors.rb b/app/errors/twilio_errors.rb index 731552437ef..2df3009b2b0 100644 --- a/app/errors/twilio_errors.rb +++ b/app/errors/twilio_errors.rb @@ -15,5 +15,7 @@ module TwilioErrors 60_082 => I18n.t('errors.messages.invalid_sms_number'), # phone number not provisioned with any carrier 60_083 => I18n.t('errors.messages.invalid_phone_number'), + # Request timed out or connection failed + 4_815_162_342 => I18n.t('errors.messages.twilio_timeout'), }.freeze end diff --git a/app/forms/idv/image_upload_form.rb b/app/forms/idv/image_upload_form.rb new file mode 100644 index 00000000000..74bd6f87b11 --- /dev/null +++ b/app/forms/idv/image_upload_form.rb @@ -0,0 +1,38 @@ +module Idv + class ImageUploadForm + include ActiveModel::Model + + validates :image, presence: true + + ATTRIBUTES = [:image].freeze + + attr_accessor :image + + def self.model_name + ActiveModel::Name.new(self, nil, 'Image') + end + + def initialize(user) + @user = user + end + + def submit(params) + consume_params(params) + + FormResponse.new(success: valid?, errors: errors.messages) + end + + private + + def consume_params(params) + params.each do |key, value| + raise_invalid_image_parameter_error(key) unless ATTRIBUTES.include?(key.to_sym) + send("#{key}=", value) + end + end + + def raise_invalid_image_parameter_error(key) + raise ArgumentError, "#{key} is an invalid image attribute" + end + end +end diff --git a/app/forms/idv/phone_form.rb b/app/forms/idv/phone_form.rb index 2bca7248048..fab8c2a7cef 100644 --- a/app/forms/idv/phone_form.rb +++ b/app/forms/idv/phone_form.rb @@ -2,23 +2,19 @@ module Idv class PhoneForm include ActiveModel::Model - attr_reader :idv_params, :user, :phone - attr_accessor :international_code + attr_reader :user, :phone validate :phone_is_a_valid_us_number - def initialize(idv_params, user) - @idv_params = idv_params + def initialize(user:, previous_params:) + previous_params ||= {} @user = user - self.phone = initial_phone_value(idv_params[:phone] || user.phone_configurations.first&.phone) - self.international_code = PhoneFormatter::DEFAULT_COUNTRY + self.phone = initial_phone_value(previous_params[:phone]) end def submit(params) - formatted_phone = PhoneFormatter.format(params[:phone]) - self.phone = formatted_phone + self.phone = PhoneFormatter.format(params[:phone]) success = valid? - update_idv_params(formatted_phone) if success FormResponse.new(success: success, errors: errors.messages, extra: extra_analytics_attributes) end @@ -27,11 +23,12 @@ def submit(params) attr_writer :phone - def initial_phone_value(phone) - formatted_phone = PhoneFormatter.format(phone) - return unless Phonelib.valid_for_country?(formatted_phone, 'US') + def initial_phone_value(input_phone) + return PhoneFormatter.format(input_phone) if input_phone.present? - self.phone = formatted_phone + user_phone = MfaContext.new(user).phone_configurations.first&.phone + return unless Phonelib.valid_for_country?(user_phone, 'US') + PhoneFormatter.format(user_phone) end def phone_is_a_valid_us_number @@ -40,27 +37,15 @@ def phone_is_a_valid_us_number errors.add(:phone, :must_have_us_country_code) end - def update_idv_params(phone) - normalized_phone = phone.gsub(/\D/, '')[1..-1] - idv_params[:phone] = normalized_phone - - return idv_params[:phone_confirmed_at] = nil unless phone == formatted_user_phone - idv_params[:phone_confirmed_at] = user.phone_configurations.first&.confirmed_at - end - - def formatted_user_phone - Phonelib.parse(user.phone_configurations.first&.phone).international - end - - def parsed_phone - @parsed_phone ||= Phonelib.parse(phone) - end - def extra_analytics_attributes { country_code: parsed_phone.country, area_code: parsed_phone.area_code, } end + + def parsed_phone + @parsed_phone ||= Phonelib.parse(phone) + end end end diff --git a/app/forms/idv/profile_form.rb b/app/forms/idv/profile_form.rb index 634ec0905af..dcfb0878586 100644 --- a/app/forms/idv/profile_form.rb +++ b/app/forms/idv/profile_form.rb @@ -18,9 +18,9 @@ def self.model_name ActiveModel::Name.new(self, nil, 'Profile') end - def initialize(params, user) + def initialize(user:, previous_params:) @user = user - consume_params(params) + consume_params(previous_params) if previous_params.present? end def submit(params) diff --git a/app/forms/idv/ssn_form.rb b/app/forms/idv/ssn_form.rb new file mode 100644 index 00000000000..524df7bd74b --- /dev/null +++ b/app/forms/idv/ssn_form.rb @@ -0,0 +1,37 @@ +module Idv + class SsnForm + include ActiveModel::Model + include FormSsnValidator + + ATTRIBUTES = [:ssn].freeze + + attr_accessor :ssn + + def self.model_name + ActiveModel::Name.new(self, nil, 'Ssn') + end + + def initialize(user) + @user = user + end + + def submit(params) + consume_params(params) + + FormResponse.new(success: valid?, errors: errors.messages) + end + + private + + def consume_params(params) + params.each do |key, value| + raise_invalid_ssn_parameter_error(key) unless ATTRIBUTES.include?(key.to_sym) + send("#{key}=", value) + end + end + + def raise_invalid_ssn_parameter_error(key) + raise ArgumentError, "#{key} is an invalid ssn attribute" + end + end +end diff --git a/app/forms/two_factor_login_options_form.rb b/app/forms/two_factor_login_options_form.rb index b37f8afb9b7..c3d324b404a 100644 --- a/app/forms/two_factor_login_options_form.rb +++ b/app/forms/two_factor_login_options_form.rb @@ -2,15 +2,20 @@ class TwoFactorLoginOptionsForm include ActiveModel::Model attr_reader :selection + attr_reader :configuration_id - validates :selection, inclusion: { in: %w[voice sms auth_app piv_cac personal_key] } + validates :selection, inclusion: { in: %w[voice sms auth_app piv_cac personal_key webauthn] } def initialize(user) self.user = user end def submit(params) - self.selection = params[:selection] + selection = params[:selection] + (selection, configuration_id) = selection.split(':', 2) if selection.present? + + self.selection = selection + self.configuration_id = configuration_id success = valid? @@ -21,6 +26,7 @@ def submit(params) attr_accessor :user attr_writer :selection + attr_writer :configuration_id def extra_analytics_attributes { diff --git a/app/forms/two_factor_options_form.rb b/app/forms/two_factor_options_form.rb index c62048eef4e..07be228f3e0 100644 --- a/app/forms/two_factor_options_form.rb +++ b/app/forms/two_factor_options_form.rb @@ -2,8 +2,9 @@ class TwoFactorOptionsForm include ActiveModel::Model attr_reader :selection + attr_reader :configuration_id - validates :selection, inclusion: { in: %w[voice sms auth_app piv_cac] } + validates :selection, inclusion: { in: %w[voice sms auth_app piv_cac webauthn] } def initialize(user) self.user = user @@ -20,7 +21,7 @@ def submit(params) end def selected?(type) - type == (selection || 'sms') + type.to_s == (selection || 'sms') end private @@ -35,9 +36,9 @@ def extra_analytics_attributes end def user_needs_updating? - return false unless %w[voice sms].include?(selection) - return false if selection == user.phone_configurations.first&.delivery_preference - selection != user.otp_delivery_preference + %w[voice sms].include?(selection) && + selection != MfaContext.new(user).phone_configurations.first&.delivery_preference && + selection != user.otp_delivery_preference end def update_otp_delivery_preference_for_user diff --git a/app/forms/user_phone_form.rb b/app/forms/user_phone_form.rb index aaa26676a22..a7e65b540a6 100644 --- a/app/forms/user_phone_form.rb +++ b/app/forms/user_phone_form.rb @@ -7,9 +7,9 @@ class UserPhoneForm attr_accessor :phone, :international_code, :otp_delivery_preference, :phone_configuration - def initialize(user) + def initialize(user, phone_configuration) self.user = user - self.phone_configuration = user.phone_configurations.first + self.phone_configuration = phone_configuration if phone_configuration.nil? self.otp_delivery_preference = user.otp_delivery_preference else diff --git a/app/forms/user_piv_cac_setup_form.rb b/app/forms/user_piv_cac_setup_form.rb index cad669b7713..52e98dd8d3c 100644 --- a/app/forms/user_piv_cac_setup_form.rb +++ b/app/forms/user_piv_cac_setup_form.rb @@ -69,7 +69,7 @@ def piv_cac_not_already_associated end def user_has_no_piv_cac - if user.piv_cac_enabled? + if TwoFactorAuthentication::PivCacPolicy.new(user).enabled? self.error_type = 'user.piv_cac_associated' false else diff --git a/app/forms/user_piv_cac_verification_form.rb b/app/forms/user_piv_cac_verification_form.rb index bba81df8131..6210b394f7c 100644 --- a/app/forms/user_piv_cac_verification_form.rb +++ b/app/forms/user_piv_cac_verification_form.rb @@ -24,7 +24,7 @@ def valid_token? end def x509_cert_matches - if user.confirm_piv_cac?(x509_dn_uuid) + if MfaContext.new(user).piv_cac_configuration.mfa_confirmed?(x509_dn_uuid) true else self.error_type = 'user.piv_cac_mismatch' @@ -59,7 +59,7 @@ def token_has_correct_nonce end def user_has_piv_cac - if user.piv_cac_enabled? + if TwoFactorAuthentication::PivCacPolicy.new(user).enabled? true else self.error_type = 'user.no_piv_cac_associated' diff --git a/app/forms/webauthn_verification_form.rb b/app/forms/webauthn_verification_form.rb new file mode 100644 index 00000000000..a0bcf6c7158 --- /dev/null +++ b/app/forms/webauthn_verification_form.rb @@ -0,0 +1,65 @@ +# The WebauthnVerificationForm class is responsible for validating webauthn verification input +class WebauthnVerificationForm + include ActiveModel::Model + + validates :user, presence: true + validates :challenge, presence: true + validates :authenticator_data, presence: true + validates :client_data_json, presence: true + validates :signature, presence: true + + def initialize(user, user_session) + @user = user + @challenge = user_session[:webauthn_challenge] + @authenticator_data = nil + @client_data_json = nil + @signature = nil + @credential_id = nil + end + + def submit(protocol, params) + consume_parameters(params) + success = valid? && valid_assertion_response?(protocol) + FormResponse.new(success: success, errors: errors.messages) + end + + # this gives us a hook to override the domain embedded in the attestation test object + def self.domain_name + Figaro.env.domain_name + end + + private + + attr_reader :success, :user, :challenge, :authenticator_data, :client_data_json, :signature + + def consume_parameters(params) + @authenticator_data = params[:authenticator_data] + @client_data_json = params[:client_data_json] + @signature = params[:signature] + @credential_id = params[:credential_id] + end + + def valid_assertion_response?(protocol) + assertion_response = ::WebAuthn::AuthenticatorAssertionResponse.new( + authenticator_data: Base64.decode64(@authenticator_data), + client_data_json: Base64.decode64(@client_data_json), + signature: Base64.decode64(@signature), + credential_id: Base64.decode64(@credential_id) + ) + original_origin = "#{protocol}#{self.class.domain_name}" + assertion_response.valid?(@challenge.pack('c*'), original_origin, + allowed_credential: allowed_credential) + end + + def allowed_credential + { + id: Base64.decode64(@credential_id), + public_key: Base64.decode64(public_key), + } + end + + def public_key + WebauthnConfiguration. + where(user_id: user.id, credential_id: @credential_id).first.credential_public_key + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index ec2df76c6fe..7275b524eee 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -34,7 +34,9 @@ def sp_session end def user_signing_up? - params[:confirmation_token].present? || (current_user && !current_user.two_factor_enabled?) + params[:confirmation_token].present? || ( + current_user && !MfaPolicy.new(current_user).two_factor_enabled? + ) end def session_with_trust? @@ -52,7 +54,7 @@ def loa3_requested? def user_verifying_identity? return unless current_user - sp_session && sp_session[:loa3] && current_user.two_factor_enabled? + sp_session && sp_session[:loa3] && MfaPolicy.new(current_user).two_factor_enabled? end def sign_up_or_idv_no_js_link diff --git a/app/javascript/app/form-validation.js b/app/javascript/app/form-validation.js index 92c9d12abc4..de7f8134527 100644 --- a/app/javascript/app/form-validation.js +++ b/app/javascript/app/form-validation.js @@ -38,7 +38,7 @@ document.addEventListener('DOMContentLoaded', () => { if (inputs.length !== 0) { [].forEach.call(inputs, function(input) { - const types = ['dob', 'personal-key', 'ssn', 'zipcode']; + const types = ['dob', 'personal-key', 'ssn', 'state_id_number', 'zipcode']; addListenerMulti(input, 'input invalid', (e) => { e.target.setCustomValidity(''); diff --git a/app/javascript/app/phone-internationalization.js b/app/javascript/app/phone-internationalization.js index f7054dcbba8..a7458cbf066 100644 --- a/app/javascript/app/phone-internationalization.js +++ b/app/javascript/app/phone-internationalization.js @@ -10,7 +10,7 @@ const selectedInternationCodeOption = () => { const unsupportedInternationalPhoneOTPDeliveryWarningMessage = () => { const selectedOption = selectedInternationCodeOption(); if (selectedOption.dataset.smsOnly === 'true') { - const messageTemplate = I18n.t('devise.two_factor_authentication.otp_delivery_preference.phone_unsupported'); + const messageTemplate = I18n.t('two_factor_authentication.otp_delivery_preference.phone_unsupported'); return messageTemplate.replace('%{location}', selectedOption.dataset.countryName); } return null; @@ -27,7 +27,7 @@ const disablePhoneState = (phoneRadio, phoneLabel, smsRadio, deliveryMethodHint, const enablePhoneState = (phoneRadio, phoneLabel, deliveryMethodHint) => { phoneRadio.disabled = false; phoneLabel.classList.remove('btn-disabled'); - deliveryMethodHint.innerText = I18n.t('devise.two_factor_authentication.otp_delivery_preference.instruction'); + deliveryMethodHint.innerText = I18n.t('two_factor_authentication.otp_delivery_preference.instruction'); }; const updateOTPDeliveryMethods = () => { diff --git a/app/javascript/packs/doc-auth.js b/app/javascript/packs/doc-auth.js new file mode 100644 index 00000000000..556677ac53c --- /dev/null +++ b/app/javascript/packs/doc-auth.js @@ -0,0 +1,56 @@ +function docAuth() { + const player = document.getElementById('player'); + const canvas = document.getElementById('canvas'); + const context = canvas.getContext('2d'); + const captureButton = document.getElementById('capture'); + const input = document.getElementById('_doc_auth_image'); + + const constraints = { + video: true, + }; + + const state = { + video: true, + }; + + function captureImage() { + // Draw the video frame to the canvas. + context.drawImage(player, 0, 0, player.width, player.height); + input.value = canvas.toDataURL('image/png', 1.0); + player.style.display = 'none'; + canvas.style.display = 'inline-block'; + captureButton.innerHTML = 'X'; + player.srcObject.getVideoTracks().forEach(track => track.stop()); + player.srcObject = null; + } + + function startVideo() { + // Attach the video stream to the video element and autoplay. + navigator.mediaDevices.getUserMedia(constraints) + .then((stream) => { + player.srcObject = stream; + }); + } + + function resetImage() { + startVideo(); + canvas.style.display = 'none'; + player.style.display = 'inline-block'; + captureButton.innerHTML = 'Capture'; + input.value = ''; + context.clearRect(0, 0, canvas.width, canvas.height); + } + + captureButton.addEventListener('click', () => { + if (state.video) { + captureImage(); + } else { + resetImage(); + } + state.video = !state.video; + }); + + startVideo(); +} + +document.addEventListener('DOMContentLoaded', docAuth); diff --git a/app/javascript/packs/pw-strength.js b/app/javascript/packs/pw-strength.js index 5689dcc738d..12c05851403 100644 --- a/app/javascript/packs/pw-strength.js +++ b/app/javascript/packs/pw-strength.js @@ -22,7 +22,7 @@ function getStrength(z) { } function getFeedback(z) { - if (!z || z.score > 2) return ''; + if (!z || z.score > 2) return ' '; const { warning, suggestions } = z.feedback; @@ -30,10 +30,10 @@ function getFeedback(z) { return I18n.t(`zxcvbn.feedback.${I18n.key(str)}`); } - if (!warning && !suggestions.length) return ''; + if (!warning && !suggestions.length) return ' '; if (warning) return lookup(warning); - return `${suggestions.map(s => lookup(s)).join('. ')}`; + return `${suggestions.map(s => lookup(s)).join('')}`; } function disableSubmit(submitEl, score = 0) { diff --git a/app/javascript/packs/webauthn-authenticate.js b/app/javascript/packs/webauthn-authenticate.js new file mode 100644 index 00000000000..7be4c2ee672 --- /dev/null +++ b/app/javascript/packs/webauthn-authenticate.js @@ -0,0 +1,47 @@ +function webauthn() { + const base64ToArrayBuffer = function(base64) { + const binaryString = window.atob(base64); + const len = binaryString.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i += 1) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes.buffer; + }; + const arrayBufferToBase64 = function(buffer) { + let binary = ''; + const bytes = new Uint8Array(buffer); + const len = bytes.byteLength; + for (let i = 0; i < len; i += 1) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); + }; + const challengeBytes = new Uint8Array(JSON.parse(document.getElementById('user_challenge').value)); + let credentialIds = document.getElementById('credential_ids').value; + credentialIds = credentialIds.split(','); + const allowCredentials2 = []; + credentialIds.forEach(function(credentialId) { + allowCredentials2.push({ + type: 'public-key', + id: base64ToArrayBuffer(credentialId), + }); + }); + const getOptions = { + publicKey: { + challenge: challengeBytes, + rpId: window.location.hostname, + allowCredentials: allowCredentials2, + timeout: 800000, + }, + }; + const p = navigator.credentials.get(getOptions); + p.then((newCred) => { + document.getElementById('credential_id').value = arrayBufferToBase64(newCred.rawId); + document.getElementById('authenticator_data').value = arrayBufferToBase64(newCred.response.authenticatorData); + document.getElementById('client_data_json').value = arrayBufferToBase64(newCred.response.clientDataJSON); + document.getElementById('signature').value = arrayBufferToBase64(newCred.response.signature); + document.getElementById('webauthn_form').submit(); + }); +} +document.addEventListener('DOMContentLoaded', webauthn); diff --git a/app/jobs/idv/proofer_job.rb b/app/jobs/idv/proofer_job.rb deleted file mode 100644 index d2d51c5fd1d..00000000000 --- a/app/jobs/idv/proofer_job.rb +++ /dev/null @@ -1,40 +0,0 @@ -module Idv - class ProoferJob < ApplicationJob - queue_as :idv - - attr_reader :result_id, :applicant, :stages - - def perform(result_id:, applicant_json:, stages:) - @result_id = result_id - @applicant = from_json(applicant_json) - @stages = from_json(stages).map(&:to_sym) - verify_identity_with_vendor - end - - private - - def from_json(applicant_json) - JSON.parse(applicant_json, symbolize_names: true) - end - - def verify_identity_with_vendor - agent = Idv::Agent.new(applicant) - result = agent.proof(*stages) - store_result(Idv::VendorResult.new(result.to_h)) - rescue StandardError => error - store_failed_job_result(error) - raise - end - - def store_failed_job_result(error) - job_failed_result = Idv::VendorResult.new( - errors: { job_failed: true, message: error.message } - ) - VendorValidatorResultStorage.new.store(result_id: result_id, result: job_failed_result) - end - - def store_result(vendor_result) - VendorValidatorResultStorage.new.store(result_id: result_id, result: vendor_result) - end - end -end diff --git a/app/models/account_reset_request.rb b/app/models/account_reset_request.rb index db304cee1e1..9db7ab75560 100644 --- a/app/models/account_reset_request.rb +++ b/app/models/account_reset_request.rb @@ -1,4 +1,6 @@ class AccountResetRequest < ApplicationRecord + self.ignored_columns = %w[reported_fraud_at] + belongs_to :user def granted_token_valid? diff --git a/app/models/anonymous_user.rb b/app/models/anonymous_user.rb index d5d7125245b..6bd6060d530 100644 --- a/app/models/anonymous_user.rb +++ b/app/models/anonymous_user.rb @@ -7,8 +7,8 @@ def second_factor_locked_at nil end - def phone_configuration - nil + def phone_configurations + [] end def phone diff --git a/app/models/auth_app_configuration.rb b/app/models/auth_app_configuration.rb new file mode 100644 index 00000000000..526ada17635 --- /dev/null +++ b/app/models/auth_app_configuration.rb @@ -0,0 +1,21 @@ +class AuthAppConfiguration + # This is a wrapping class that lets us interface with the auth app configuration in a manner + # consistent with phone and webauthn configurations. + attr_reader :user + + def initialize(user) + @user = user + end + + def mfa_enabled? + user&.otp_secret_key.present? + end + + def selection_presenters + if mfa_enabled? + [TwoFactorAuthentication::AuthAppSelectionPresenter.new(self)] + else + [] + end + end +end diff --git a/app/models/concerns/email_address_callback.rb b/app/models/concerns/email_address_callback.rb new file mode 100644 index 00000000000..80e85d3e47c --- /dev/null +++ b/app/models/concerns/email_address_callback.rb @@ -0,0 +1,38 @@ +module EmailAddressCallback + extend ActiveSupport::Concern + + def self.included(base) + base.send(:after_save, :update_email_address) + end + + def update_email_address + if email_address.present? + update_email_address_record + elsif encrypted_email.present? + create_full_email_address_record + end + end + + private + + def update_email_address_record + email_address.update!( + encrypted_email: encrypted_email, + confirmation_token: confirmation_token, + confirmed_at: confirmed_at, + confirmation_sent_at: confirmation_sent_at, + email_fingerprint: email_fingerprint + ) + end + + def create_full_email_address_record + create_email_address!( + user: self, + encrypted_email: encrypted_email, + confirmation_token: confirmation_token, + confirmed_at: confirmed_at, + confirmation_sent_at: confirmation_sent_at, + email_fingerprint: email_fingerprint + ) + end +end diff --git a/app/models/concerns/webauthn_configurations_extension.rb b/app/models/concerns/webauthn_configurations_extension.rb new file mode 100644 index 00000000000..f2689b9ecf2 --- /dev/null +++ b/app/models/concerns/webauthn_configurations_extension.rb @@ -0,0 +1,12 @@ +module WebauthnConfigurationsExtension + # :reek:FeatureEnvy + def selection_presenters + configuration = proxy_association.target.detect(&:mfa_enabled?) + + if configuration.present? + configuration.selection_presenters + else + [] + end + end +end diff --git a/app/models/doc_auth.rb b/app/models/doc_auth.rb new file mode 100644 index 00000000000..0236e1463b1 --- /dev/null +++ b/app/models/doc_auth.rb @@ -0,0 +1,4 @@ +class DocAuth < ApplicationRecord + belongs_to :user, inverse_of: :doc_auth + validates :user_id, presence: true +end diff --git a/app/models/email_address.rb b/app/models/email_address.rb new file mode 100644 index 00000000000..41b5ccd0817 --- /dev/null +++ b/app/models/email_address.rb @@ -0,0 +1,15 @@ +class EmailAddress < ApplicationRecord + include EncryptableAttribute + + encrypted_attribute_without_setter(name: :email) + + belongs_to :user, inverse_of: :email_address + validates :user_id, presence: true + validates :encrypted_email, presence: true + validates :email_fingerprint, presence: true + + def email=(email) + set_encrypted_attribute(name: :email, value: email) + self.email_fingerprint = email.present? ? encrypted_attributes[:email].fingerprint : '' + end +end diff --git a/app/models/identity.rb b/app/models/identity.rb index b2c9bb1a9c7..7e047aae6e2 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -21,7 +21,7 @@ def agency_name end def piv_cac_enabled? - user&.piv_cac_enabled? + TwoFactorAuthentication::PivCacPolicy.new(user).enabled? end def decorate diff --git a/app/models/personal_key_configuration.rb b/app/models/personal_key_configuration.rb new file mode 100644 index 00000000000..b09571a3402 --- /dev/null +++ b/app/models/personal_key_configuration.rb @@ -0,0 +1,21 @@ +class PersonalKeyConfiguration + # This is a wrapping class that lets us interface with the personal key configuration in a + # manner consistent with phone and webauthn configurations. + attr_reader :user + + def initialize(user) + @user = user + end + + def mfa_enabled? + TwoFactorAuthentication::PersonalKeyPolicy.new(user).configured? + end + + def selection_presenters + if mfa_enabled? + [TwoFactorAuthentication::PersonalKeySelectionPresenter.new(self)] + else + [] + end + end +end diff --git a/app/models/phone_configuration.rb b/app/models/phone_configuration.rb index 7e505ef8e96..10777606485 100644 --- a/app/models/phone_configuration.rb +++ b/app/models/phone_configuration.rb @@ -12,4 +12,12 @@ class PhoneConfiguration < ApplicationRecord def formatted_phone Phonelib.parse(phone).international end + + def selection_presenters + options = [TwoFactorAuthentication::SmsSelectionPresenter.new(self)] + unless PhoneNumberCapabilities.new(phone).sms_only? + options << TwoFactorAuthentication::VoiceSelectionPresenter.new(self) + end + options + end end diff --git a/app/models/piv_cac_configuration.rb b/app/models/piv_cac_configuration.rb new file mode 100644 index 00000000000..dbd73acd762 --- /dev/null +++ b/app/models/piv_cac_configuration.rb @@ -0,0 +1,25 @@ +class PivCacConfiguration + # This is a wrapping class that lets us interface with the piv/cac configuration in a manner + # consistent with phone and webauthn configurations. + attr_reader :user + + def initialize(user) + @user = user + end + + def mfa_enabled? + user&.x509_dn_uuid.present? + end + + def mfa_confirmed?(proposed_uuid) + user && proposed_uuid && user.x509_dn_uuid == proposed_uuid + end + + def selection_presenters + if mfa_enabled? + [TwoFactorAuthentication::PivCacSelectionPresenter.new(self)] + else + [] + end + end +end diff --git a/app/models/profile.rb b/app/models/profile.rb index 5551d5c8eb4..ad4e305a23e 100644 --- a/app/models/profile.rb +++ b/app/models/profile.rb @@ -14,6 +14,7 @@ class Profile < ApplicationRecord password_reset: 1, encryption_error: 2, verification_pending: 3, + verification_cancelled: 4, } attr_reader :personal_key diff --git a/app/models/user.rb b/app/models/user.rb index 3166f42bf4c..fd7a09f4723 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,4 +1,3 @@ -# rubocop:disable Rails/HasManyOrHasOneDependent class User < ApplicationRecord self.ignored_columns = %w[ encrypted_password password_salt password_cost encryption_key @@ -29,6 +28,7 @@ class User < ApplicationRecord # IMPORTANT this comes *after* devise() call. include UserAccessKeyOverrides include UserEncryptedAttributeOverrides + include EmailAddressCallback enum role: { user: 0, tech: 1, admin: 2 } enum otp_delivery_preference: { sms: 0, voice: 1 } @@ -36,13 +36,17 @@ class User < ApplicationRecord has_one_time_password has_many :authorizations, dependent: :destroy + # rubocop:disable Rails/HasManyOrHasOneDependent has_many :identities # identities need to be orphaned to prevent UUID reuse + # rubocop:enable Rails/HasManyOrHasOneDependent has_many :agency_identities, dependent: :destroy has_many :profiles, dependent: :destroy has_many :events, dependent: :destroy has_one :account_reset_request, dependent: :destroy has_many :phone_configurations, dependent: :destroy, inverse_of: :user - has_many :webauthn_configurations, dependent: :destroy + has_one :email_address, dependent: :destroy, inverse_of: :user + has_many :webauthn_configurations, dependent: :destroy, inverse_of: :user + has_one :doc_auth, dependent: :destroy, inverse_of: :user validates :x509_dn_uuid, uniqueness: true, allow_nil: true @@ -52,25 +56,8 @@ def set_default_role self.role ||= :user end - def confirm_piv_cac?(proposed_uuid) - x509_dn_uuid == proposed_uuid if proposed_uuid - end - - def piv_cac_enabled? - PivCacLoginOptionPolicy.new(self).enabled? - end - - def piv_cac_available? - PivCacLoginOptionPolicy.new(self).available? - end - def need_two_factor_authentication?(_request) - two_factor_enabled? - end - - def two_factor_enabled? - phone_configurations.any?(&:mfa_enabled?) || totp_enabled? || piv_cac_enabled? || - webauthn_configurations.any? + MfaPolicy.new(self).two_factor_enabled? end def send_two_factor_authentication_code(_code) @@ -160,14 +147,4 @@ def send_custom_confirmation_instructions(id = nil, instructions = nil) send_devise_notification(:confirmation_instructions, @raw_confirmation_token, opts) end - - def total_mfa_options_enabled - total = [phone_mfa_enabled?, piv_cac_enabled?, totp_enabled?].count { |tf| tf } - total + webauthn_configurations.size - end - - def phone_mfa_enabled? - phone_configurations.any?(&:mfa_enabled?) - end end -# rubocop:enable Rails/HasManyOrHasOneDependent diff --git a/app/models/webauthn_configuration.rb b/app/models/webauthn_configuration.rb index 7f99ec0bc7d..63a0ae13064 100644 --- a/app/models/webauthn_configuration.rb +++ b/app/models/webauthn_configuration.rb @@ -4,4 +4,13 @@ class WebauthnConfiguration < ApplicationRecord validates :name, presence: true validates :credential_id, presence: true validates :credential_public_key, presence: true + + # :reek:UtilityFunction + def mfa_enabled? + FeatureManagement.webauthn_enabled? + end + + def selection_presenters + [TwoFactorAuthentication::WebauthnSelectionPresenter.new(self)] + end end diff --git a/app/policies/auth_app_login_option_policy.rb b/app/policies/auth_app_login_option_policy.rb deleted file mode 100644 index 93a07ee4fd0..00000000000 --- a/app/policies/auth_app_login_option_policy.rb +++ /dev/null @@ -1,13 +0,0 @@ -class AuthAppLoginOptionPolicy - def initialize(user) - @user = user - end - - def configured? - !user.otp_secret_key.nil? - end - - private - - attr_reader :user -end diff --git a/app/policies/mfa_policy.rb b/app/policies/mfa_policy.rb new file mode 100644 index 00000000000..40c8c45699d --- /dev/null +++ b/app/policies/mfa_policy.rb @@ -0,0 +1,17 @@ +class MfaPolicy + def initialize(user) + @mfa_user = MfaContext.new(user) + end + + def two_factor_enabled? + mfa_user.two_factor_configurations.any?(&:mfa_enabled?) + end + + def multiple_factors_enabled? + mfa_user.two_factor_configurations.many?(&:mfa_enabled?) + end + + private + + attr_reader :mfa_user +end diff --git a/app/policies/personal_key_login_option_policy.rb b/app/policies/personal_key_login_option_policy.rb deleted file mode 100644 index 10f97fb277a..00000000000 --- a/app/policies/personal_key_login_option_policy.rb +++ /dev/null @@ -1,13 +0,0 @@ -class PersonalKeyLoginOptionPolicy - def initialize(user) - @user = user - end - - def configured? - user.encrypted_recovery_code_digest.present? - end - - private - - attr_reader :user -end diff --git a/app/policies/piv_cac_login_option_policy.rb b/app/policies/piv_cac_login_option_policy.rb deleted file mode 100644 index d6e06ca2d57..00000000000 --- a/app/policies/piv_cac_login_option_policy.rb +++ /dev/null @@ -1,21 +0,0 @@ -class PivCacLoginOptionPolicy - def initialize(user) - @user = user - end - - def configured? - FeatureManagement.piv_cac_enabled? && user.x509_dn_uuid.present? - end - - def enabled? - configured? - end - - def available? - enabled? || user.identities.any?(&:piv_cac_available?) - end - - private - - attr_reader :user -end diff --git a/app/policies/sms_login_option_policy.rb b/app/policies/sms_login_option_policy.rb deleted file mode 100644 index aee36acdf4c..00000000000 --- a/app/policies/sms_login_option_policy.rb +++ /dev/null @@ -1,14 +0,0 @@ -class SmsLoginOptionPolicy - def initialize(user) - @user = user - end - - def configured? - return false unless user - user.phone_configurations.any? - end - - private - - attr_reader :user -end diff --git a/app/policies/two_factor_authentication/auth_app_policy.rb b/app/policies/two_factor_authentication/auth_app_policy.rb new file mode 100644 index 00000000000..68b1b15754c --- /dev/null +++ b/app/policies/two_factor_authentication/auth_app_policy.rb @@ -0,0 +1,27 @@ +module TwoFactorAuthentication + class AuthAppPolicy + def initialize(user) + @user = user + end + + def configured? + user.otp_secret_key.present? + end + + def available? + !configured? + end + + def enabled? + configured? + end + + def visible? + true + end + + private + + attr_reader :user + end +end diff --git a/app/policies/two_factor_authentication/personal_key_policy.rb b/app/policies/two_factor_authentication/personal_key_policy.rb new file mode 100644 index 00000000000..6e749b0c4de --- /dev/null +++ b/app/policies/two_factor_authentication/personal_key_policy.rb @@ -0,0 +1,23 @@ +module TwoFactorAuthentication + class PersonalKeyPolicy + def initialize(user) + @user = user + end + + def configured? + user&.encrypted_recovery_code_digest.present? + end + + def enabled? + configured? + end + + def visible? + true + end + + private + + attr_reader :user + end +end diff --git a/app/policies/two_factor_authentication/phone_policy.rb b/app/policies/two_factor_authentication/phone_policy.rb new file mode 100644 index 00000000000..d07a35b9e73 --- /dev/null +++ b/app/policies/two_factor_authentication/phone_policy.rb @@ -0,0 +1,27 @@ +module TwoFactorAuthentication + class PhonePolicy + def initialize(user) + @mfa_user = MfaContext.new(user) + end + + def configured? + mfa_user.phone_configurations.any? + end + + def enabled? + mfa_user.phone_configurations.any?(&:mfa_enabled?) + end + + def available? + true + end + + def visible? + true + end + + private + + attr_reader :mfa_user + end +end diff --git a/app/policies/two_factor_authentication/piv_cac_policy.rb b/app/policies/two_factor_authentication/piv_cac_policy.rb new file mode 100644 index 00000000000..281e8242cca --- /dev/null +++ b/app/policies/two_factor_authentication/piv_cac_policy.rb @@ -0,0 +1,27 @@ +module TwoFactorAuthentication + class PivCacPolicy + def initialize(user) + @user = user + end + + def configured? + user&.x509_dn_uuid.present? + end + + def enabled? + configured? + end + + def available? + !enabled? && user.identities.any?(&:piv_cac_available?) + end + + def visible? + enabled? || available? + end + + private + + attr_reader :user + end +end diff --git a/app/policies/two_factor_authentication/webauthn_policy.rb b/app/policies/two_factor_authentication/webauthn_policy.rb new file mode 100644 index 00000000000..7660ef4d52f --- /dev/null +++ b/app/policies/two_factor_authentication/webauthn_policy.rb @@ -0,0 +1,35 @@ +module TwoFactorAuthentication + # The WebauthnPolicy class is responsible for handling the user policy of webauthn + class WebauthnPolicy + def initialize(user, sp) + @mfa_user = MfaContext.new(user) + @sp = sp + end + + def configured? + webauthn_enabled? && mfa_user.webauthn_configurations.any? + end + + def enabled? + configured? + end + + # :reek:UtilityFunction + def available? + webauthn_enabled? + end + + # :reek:UtilityFunction + def visible? + webauthn_enabled? + end + + private + + attr_reader :mfa_user, :sp + + def webauthn_enabled? + sp.nil? && FeatureManagement.webauthn_enabled? + end + end +end diff --git a/app/policies/voice_login_option_policy.rb b/app/policies/voice_login_option_policy.rb deleted file mode 100644 index 24914c4c85b..00000000000 --- a/app/policies/voice_login_option_policy.rb +++ /dev/null @@ -1,19 +0,0 @@ -class VoiceLoginOptionPolicy - def initialize(user) - @user = user - end - - def configured? - user_has_a_phone_number_that_we_can_call? - end - - private - - attr_reader :user - - def user_has_a_phone_number_that_we_can_call? - user.phone_configurations.any? do |phone_configuration| - !PhoneNumberCapabilities.new(phone_configuration.phone).sms_only? - end - end -end diff --git a/app/presenters/account_recovery_options_presenter.rb b/app/presenters/account_recovery_options_presenter.rb index 7c0f450f2aa..c006ae0ffc7 100644 --- a/app/presenters/account_recovery_options_presenter.rb +++ b/app/presenters/account_recovery_options_presenter.rb @@ -23,8 +23,8 @@ def options AVAILABLE_2FA_TYPES.map do |type| OpenStruct.new( type: type, - label: t("devise.two_factor_authentication.two_factor_choice_options.#{type}"), - info: t("devise.two_factor_authentication.two_factor_choice_options.#{type}_info"), + label: t("two_factor_authentication.two_factor_choice_options.#{type}"), + info: t("two_factor_authentication.two_factor_choice_options.#{type}_info"), selected: type == :sms ) end diff --git a/app/presenters/idv/otp_verification_presenter.rb b/app/presenters/idv/otp_verification_presenter.rb index 82b0c50ec9c..d6bb89c84b2 100644 --- a/app/presenters/idv/otp_verification_presenter.rb +++ b/app/presenters/idv/otp_verification_presenter.rb @@ -25,7 +25,7 @@ def update_phone_link private def phone_number - PhoneFormatter.format(idv_session.params[:phone]) + PhoneFormatter.format(idv_session.applicant[:phone]) end def otp_delivery_preference diff --git a/app/presenters/phone_setup_presenter.rb b/app/presenters/phone_setup_presenter.rb index f6b55f8db5b..a12b0dc00fc 100644 --- a/app/presenters/phone_setup_presenter.rb +++ b/app/presenters/phone_setup_presenter.rb @@ -12,11 +12,11 @@ def heading end def label - t("devise.two_factor_authentication.phone_#{otp_delivery_preference}_label") + t("two_factor_authentication.phone_#{otp_delivery_preference}_label") end def info - t("devise.two_factor_authentication.phone_#{otp_delivery_preference}_info_html") + t("two_factor_authentication.phone_#{otp_delivery_preference}_info_html") end def image diff --git a/app/presenters/two_factor_auth_code/authenticator_delivery_presenter.rb b/app/presenters/two_factor_auth_code/authenticator_delivery_presenter.rb index 082527b1dd2..391fb956c69 100644 --- a/app/presenters/two_factor_auth_code/authenticator_delivery_presenter.rb +++ b/app/presenters/two_factor_auth_code/authenticator_delivery_presenter.rb @@ -1,7 +1,7 @@ module TwoFactorAuthCode class AuthenticatorDeliveryPresenter < TwoFactorAuthCode::GenericDeliveryPresenter def header - t('devise.two_factor_authentication.totp_header_text') + t('two_factor_authentication.totp_header_text') end def help_text diff --git a/app/presenters/two_factor_auth_code/generic_delivery_presenter.rb b/app/presenters/two_factor_auth_code/generic_delivery_presenter.rb index 9d8a4d2be61..ffd51f035ee 100644 --- a/app/presenters/two_factor_auth_code/generic_delivery_presenter.rb +++ b/app/presenters/two_factor_auth_code/generic_delivery_presenter.rb @@ -40,24 +40,6 @@ def remember_device_available? private - attr_reader :personal_key_unavailable, :has_piv_cac_configured, :view, :reauthn - - def piv_cac_link - return unless FeatureManagement.piv_cac_enabled? - return unless has_piv_cac_configured - view.link_to( - t('devise.two_factor_authentication.piv_cac_fallback.link'), - login_two_factor_piv_cac_path(locale: LinkLocaleResolver.locale) - ) - end - - def piv_cac_option - return unless FeatureManagement.piv_cac_enabled? - return unless has_piv_cac_configured - t( - 'devise.two_factor_authentication.piv_cac_fallback.text_html', - link: piv_cac_link - ) - end + attr_reader :personal_key_unavailable, :view, :reauthn end end diff --git a/app/presenters/two_factor_auth_code/max_attempts_reached_presenter.rb b/app/presenters/two_factor_auth_code/max_attempts_reached_presenter.rb index e7e8b1cc46d..49643784720 100644 --- a/app/presenters/two_factor_auth_code/max_attempts_reached_presenter.rb +++ b/app/presenters/two_factor_auth_code/max_attempts_reached_presenter.rb @@ -7,7 +7,7 @@ class MaxAttemptsReachedPresenter < FailurePresenter COUNTDOWN_ID = 'countdown'.freeze - T_SCOPE = 'devise.two_factor_authentication'.freeze + T_SCOPE = 'two_factor_authentication'.freeze def initialize(type, decorated_user) super(:locked) diff --git a/app/presenters/two_factor_auth_code/phone_delivery_presenter.rb b/app/presenters/two_factor_auth_code/phone_delivery_presenter.rb index a1106863d9d..ac0e259e7a3 100644 --- a/app/presenters/two_factor_auth_code/phone_delivery_presenter.rb +++ b/app/presenters/two_factor_auth_code/phone_delivery_presenter.rb @@ -5,7 +5,7 @@ class PhoneDeliveryPresenter < TwoFactorAuthCode::GenericDeliveryPresenter ) def header - t('devise.two_factor_authentication.header_text') + t('two_factor_authentication.header_text') end def phone_number_message @@ -41,7 +41,6 @@ def cancel_link private attr_reader( - :totp_enabled, :reenter_phone_number_path, :phone_number, :unconfirmed_phone, diff --git a/app/presenters/two_factor_auth_code/piv_cac_authentication_presenter.rb b/app/presenters/two_factor_auth_code/piv_cac_authentication_presenter.rb index 719781d08ba..97a3bcccd30 100644 --- a/app/presenters/two_factor_auth_code/piv_cac_authentication_presenter.rb +++ b/app/presenters/two_factor_auth_code/piv_cac_authentication_presenter.rb @@ -4,13 +4,11 @@ class PivCacAuthenticationPresenter < TwoFactorAuthCode::GenericDeliveryPresente include ActionView::Helpers::TranslationHelper def header - t('devise.two_factor_authentication.piv_cac_header_text') + t('two_factor_authentication.piv_cac_header_text') end def help_text - t('instructions.mfa.piv_cac.confirm_piv_cac_html', - email: content_tag(:strong, user_email), - app: content_tag(:strong, APP_NAME)) + t('instructions.mfa.piv_cac.confirm_piv_cac_html') end def piv_cac_capture_text @@ -36,6 +34,6 @@ def fallback_question private - attr_reader :user_email, :two_factor_authentication_method, :totp_enabled, :phone_enabled + attr_reader :two_factor_authentication_method end end diff --git a/app/presenters/two_factor_auth_code/webauthn_authentication_presenter.rb b/app/presenters/two_factor_auth_code/webauthn_authentication_presenter.rb new file mode 100644 index 00000000000..52cf7bf0ef1 --- /dev/null +++ b/app/presenters/two_factor_auth_code/webauthn_authentication_presenter.rb @@ -0,0 +1,26 @@ +module TwoFactorAuthCode + # The WebauthnAuthenticationPresenter class is the presenter for webauthn verification + class WebauthnAuthenticationPresenter < TwoFactorAuthCode::GenericDeliveryPresenter + include Rails.application.routes.url_helpers + include ActionView::Helpers::TranslationHelper + + attr_reader :credential_ids + + def help_text + '' + end + + def cancel_link + locale = LinkLocaleResolver.locale + if reauthn + account_path(locale: locale) + else + sign_out_path(locale: locale) + end + end + + def fallback_question + t('two_factor_authentication.webauthn_fallback.question') + end + end +end diff --git a/app/presenters/two_factor_authentication/auth_app_selection_presenter.rb b/app/presenters/two_factor_authentication/auth_app_selection_presenter.rb new file mode 100644 index 00000000000..a714094b2ba --- /dev/null +++ b/app/presenters/two_factor_authentication/auth_app_selection_presenter.rb @@ -0,0 +1,7 @@ +module TwoFactorAuthentication + class AuthAppSelectionPresenter < SelectionPresenter + def method + :auth_app + end + end +end diff --git a/app/presenters/two_factor_authentication/personal_key_selection_presenter.rb b/app/presenters/two_factor_authentication/personal_key_selection_presenter.rb new file mode 100644 index 00000000000..5fb4f9487b2 --- /dev/null +++ b/app/presenters/two_factor_authentication/personal_key_selection_presenter.rb @@ -0,0 +1,7 @@ +module TwoFactorAuthentication + class PersonalKeySelectionPresenter < SelectionPresenter + def method + :personal_key + end + end +end diff --git a/app/presenters/two_factor_authentication/phone_selection_presenter.rb b/app/presenters/two_factor_authentication/phone_selection_presenter.rb new file mode 100644 index 00000000000..d06c676624e --- /dev/null +++ b/app/presenters/two_factor_authentication/phone_selection_presenter.rb @@ -0,0 +1,19 @@ +module TwoFactorAuthentication + class PhoneSelectionPresenter < SelectionPresenter + def type + if MfaContext.new(configuration&.user).phone_configurations.many? + "#{super}:#{configuration.id}" + else + super + end + end + + def info + if configuration.present? + t("two_factor_authentication.login_options.#{method}_info_html", phone: configuration.phone) + else + t("two_factor_authentication.login_options.#{method}_setup_info") + end + end + end +end diff --git a/app/presenters/two_factor_authentication/piv_cac_selection_presenter.rb b/app/presenters/two_factor_authentication/piv_cac_selection_presenter.rb new file mode 100644 index 00000000000..72409d178e9 --- /dev/null +++ b/app/presenters/two_factor_authentication/piv_cac_selection_presenter.rb @@ -0,0 +1,7 @@ +module TwoFactorAuthentication + class PivCacSelectionPresenter < SelectionPresenter + def method + :piv_cac + end + end +end diff --git a/app/presenters/two_factor_authentication/selection_presenter.rb b/app/presenters/two_factor_authentication/selection_presenter.rb new file mode 100644 index 00000000000..f578e2e3013 --- /dev/null +++ b/app/presenters/two_factor_authentication/selection_presenter.rb @@ -0,0 +1,24 @@ +module TwoFactorAuthentication + class SelectionPresenter + include Rails.application.routes.url_helpers + include ActionView::Helpers::TranslationHelper + + attr_reader :configuration + + def initialize(configuration = nil) + @configuration = configuration + end + + def type + method.to_s + end + + def label + t("two_factor_authentication.login_options.#{method}") + end + + def info + t("two_factor_authentication.login_options.#{method}_info") + end + end +end diff --git a/app/presenters/two_factor_authentication/sms_selection_presenter.rb b/app/presenters/two_factor_authentication/sms_selection_presenter.rb new file mode 100644 index 00000000000..41cdc209005 --- /dev/null +++ b/app/presenters/two_factor_authentication/sms_selection_presenter.rb @@ -0,0 +1,7 @@ +module TwoFactorAuthentication + class SmsSelectionPresenter < PhoneSelectionPresenter + def method + :sms + end + end +end diff --git a/app/presenters/two_factor_authentication/voice_selection_presenter.rb b/app/presenters/two_factor_authentication/voice_selection_presenter.rb new file mode 100644 index 00000000000..d4ddb0ec883 --- /dev/null +++ b/app/presenters/two_factor_authentication/voice_selection_presenter.rb @@ -0,0 +1,7 @@ +module TwoFactorAuthentication + class VoiceSelectionPresenter < PhoneSelectionPresenter + def method + :voice + end + end +end diff --git a/app/presenters/two_factor_authentication/webauthn_selection_presenter.rb b/app/presenters/two_factor_authentication/webauthn_selection_presenter.rb new file mode 100644 index 00000000000..53d9ddff59f --- /dev/null +++ b/app/presenters/two_factor_authentication/webauthn_selection_presenter.rb @@ -0,0 +1,7 @@ +module TwoFactorAuthentication + class WebauthnSelectionPresenter < SelectionPresenter + def method + :webauthn + end + end +end diff --git a/app/presenters/two_factor_login_options_presenter.rb b/app/presenters/two_factor_login_options_presenter.rb index 5e2cb40dd78..74587a51d2b 100644 --- a/app/presenters/two_factor_login_options_presenter.rb +++ b/app/presenters/two_factor_login_options_presenter.rb @@ -1,15 +1,6 @@ class TwoFactorLoginOptionsPresenter < TwoFactorAuthCode::GenericDeliveryPresenter include ActionView::Helpers::TranslationHelper - POSSIBLE_OPTIONS = %i[sms voice auth_app piv_cac personal_key].freeze - POLICIES = { - sms: SmsLoginOptionPolicy, - voice: VoiceLoginOptionPolicy, - auth_app: AuthAppLoginOptionPolicy, - piv_cac: PivCacLoginOptionPolicy, - personal_key: PersonalKeyLoginOptionPolicy, - }.freeze - attr_reader :current_user def initialize(current_user, view, service_provider) @@ -34,15 +25,18 @@ def label '' end + # :reek:FeatureEnvy def options - configured_2fa_types.map do |type| - OpenStruct.new( - type: type, - label: t("two_factor_authentication.login_options.#{type}"), - info: t("two_factor_authentication.login_options.#{type}_info"), - selected: type == configured_2fa_types[0] - ) + mfa = MfaContext.new(current_user) + # for now, we include the personal key since that's our current behavior, + # but there are designs to remove personal key from the option list and + # make it a link with some additional text to call it out as a special + # case. + options = mfa.two_factor_configurations + if TwoFactorAuthentication::PersonalKeyPolicy.new(current_user).enabled? + options << mfa.personal_key_configuration end + options.flat_map(&:selection_presenters) end def should_display_account_reset_or_cancel_link? @@ -57,17 +51,17 @@ def account_reset_or_cancel_link private def account_reset_link - t('devise.two_factor_authentication.account_reset.text_html', + t('two_factor_authentication.account_reset.text_html', link: @view.link_to( - t('devise.two_factor_authentication.account_reset.link'), + t('two_factor_authentication.account_reset.link'), account_reset_request_path(locale: LinkLocaleResolver.locale) )) end def account_reset_cancel_link - t('devise.two_factor_authentication.account_reset.pending_html', + t('two_factor_authentication.account_reset.pending_html', cancel_link: @view.link_to( - t('devise.two_factor_authentication.account_reset.cancel_link'), + t('two_factor_authentication.account_reset.cancel_link'), account_reset_cancel_url(token: account_reset_token) )) end @@ -79,10 +73,4 @@ def account_reset_token def account_reset_token_valid? current_user&.account_reset_request&.granted_token_valid? end - - def configured_2fa_types - POSSIBLE_OPTIONS.each_with_object([]) do |option, result| - result << option if POLICIES[option].new(@current_user).configured? - end - end end diff --git a/app/presenters/two_factor_options_presenter.rb b/app/presenters/two_factor_options_presenter.rb index 60aa9f2e35c..bf186a41e89 100644 --- a/app/presenters/two_factor_options_presenter.rb +++ b/app/presenters/two_factor_options_presenter.rb @@ -13,11 +13,11 @@ def title end def heading - t('devise.two_factor_authentication.two_factor_choice') + t('two_factor_authentication.two_factor_choice') end def info - t('devise.two_factor_authentication.two_factor_choice_intro') + t('two_factor_authentication.two_factor_choice_intro') end def label @@ -25,26 +25,42 @@ def label end def options - available_2fa_types.map do |type| - OpenStruct.new( - type: type, - label: t("devise.two_factor_authentication.two_factor_choice_options.#{type}"), - info: t("devise.two_factor_authentication.two_factor_choice_options.#{type}_info"), - selected: type == :sms - ) - end + phone_options + totp_option + webauthn_option + piv_cac_option end private - def available_2fa_types - %w[sms voice auth_app] + piv_cac_if_available + def phone_options + if TwoFactorAuthentication::PhonePolicy.new(current_user).available? + [ + TwoFactorAuthentication::SmsSelectionPresenter.new, + TwoFactorAuthentication::VoiceSelectionPresenter.new, + ] + else + [] + end + end + + def webauthn_option + if TwoFactorAuthentication::WebauthnPolicy.new(current_user, service_provider).available? + [TwoFactorAuthentication::WebauthnSelectionPresenter.new] + else + [] + end + end + + def totp_option + if TwoFactorAuthentication::AuthAppPolicy.new(current_user).available? + [TwoFactorAuthentication::AuthAppSelectionPresenter.new] + else + [] + end end - def piv_cac_if_available - return [] if current_user.piv_cac_enabled? - return [] unless current_user.piv_cac_available? || - service_provider&.piv_cac_available?(current_user) - %w[piv_cac] + def piv_cac_option + policy = TwoFactorAuthentication::PivCacPolicy.new(current_user) + return [] if policy.enabled? + return [] unless policy.available? || service_provider&.piv_cac_available?(current_user) + [TwoFactorAuthentication::PivCacSelectionPresenter.new] end end diff --git a/app/services/account_reset/cancel.rb b/app/services/account_reset/cancel.rb index de85bbc54a9..841fd1fce67 100644 --- a/app/services/account_reset/cancel.rb +++ b/app/services/account_reset/cancel.rb @@ -1,9 +1,7 @@ module AccountReset class Cancel include ActiveModel::Model - - validates :token, presence: { message: I18n.t('errors.account_reset.cancel_token_missing') } - validate :valid_token + include CancelTokenValidator def initialize(token) @token = token @@ -25,12 +23,6 @@ def call attr_reader :success, :token - def valid_token - return if account_reset_request - - errors.add(:token, I18n.t('errors.account_reset.cancel_token_invalid')) if token - end - def notify_user_via_email_of_account_reset_cancellation UserMailer.account_reset_cancel(user.email).deliver_later end @@ -45,16 +37,12 @@ def update_account_reset_request granted_token: nil) end - def account_reset_request - @account_reset_request ||= AccountResetRequest.find_by(request_token: token) - end - def user account_reset_request&.user || AnonymousUser.new end def phone - user.phone_configurations.first&.phone + MfaContext.new(user).phone_configurations.first&.phone end def extra_analytics_attributes diff --git a/app/services/account_reset/create_request.rb b/app/services/account_reset/create_request.rb index 71ced1cd6f6..43a6e4fb7c9 100644 --- a/app/services/account_reset/create_request.rb +++ b/app/services/account_reset/create_request.rb @@ -30,7 +30,7 @@ def notify_user_by_email end def notify_user_by_sms_if_applicable - phone = user.phone_configurations.first&.phone + phone = MfaContext.new(user).phone_configurations.first&.phone return unless phone SmsAccountResetNotifierJob.perform_now( phone: phone, diff --git a/app/services/account_reset/grant_request.rb b/app/services/account_reset/grant_request.rb new file mode 100644 index 00000000000..8d0b33e9f26 --- /dev/null +++ b/app/services/account_reset/grant_request.rb @@ -0,0 +1,24 @@ +module AccountReset + class GrantRequest + def initialize(user) + @user_id = user.id + end + + def call + token = SecureRandom.uuid + arr = AccountResetRequest.find_by(user_id: @user_id) + arr.with_lock do + return false if arr.granted_token_valid? + account_reset_request.update(granted_at: Time.zone.now, + granted_token: token) + end + true + end + + private + + def account_reset_request + AccountResetRequest.find_or_create_by(user_id: @user_id) + end + end +end diff --git a/app/services/account_reset/grant_requests_and_send_emails.rb b/app/services/account_reset/grant_requests_and_send_emails.rb new file mode 100644 index 00000000000..6d21e2b99bb --- /dev/null +++ b/app/services/account_reset/grant_requests_and_send_emails.rb @@ -0,0 +1,33 @@ +module AccountReset + class GrantRequestsAndSendEmails + def call + notifications_sent = 0 + AccountResetRequest.where( + sql_query_for_users_eligible_to_delete_their_accounts, + tvalue: Time.zone.now - Figaro.env.account_reset_wait_period_days.to_i.days + ).order('requested_at ASC').each do |arr| + notifications_sent += 1 if grant_request_and_send_email(arr) + end + notifications_sent + end + + private + + def sql_query_for_users_eligible_to_delete_their_accounts + <<~SQL + cancelled_at IS NULL AND + granted_at IS NULL AND + requested_at < :tvalue AND + request_token IS NOT NULL AND + granted_token IS NULL + SQL + end + + def grant_request_and_send_email(arr) + user = arr.user + return false unless AccountReset::GrantRequest.new(user).call + UserMailer.account_reset_granted(user, arr.reload).deliver_later + true + end + end +end diff --git a/app/services/account_reset/validate_cancel_token.rb b/app/services/account_reset/validate_cancel_token.rb new file mode 100644 index 00000000000..906cfdf9d23 --- /dev/null +++ b/app/services/account_reset/validate_cancel_token.rb @@ -0,0 +1,31 @@ +module AccountReset + class ValidateCancelToken + include ActiveModel::Model + include CancelTokenValidator + + def initialize(token) + @token = token + end + + def call + @success = valid? + + FormResponse.new(success: success, errors: errors.messages, extra: extra_analytics_attributes) + end + + private + + attr_reader :success, :token + + def user + account_reset_request&.user || AnonymousUser.new + end + + def extra_analytics_attributes + { + event: 'visit', + user_id: user.uuid, + } + end + end +end diff --git a/app/services/account_reset_service.rb b/app/services/account_reset_service.rb deleted file mode 100644 index b59d8d57bc3..00000000000 --- a/app/services/account_reset_service.rb +++ /dev/null @@ -1,62 +0,0 @@ -class AccountResetService - def initialize(user) - @user_id = user.id - end - - def self.report_fraud(token) - account_reset = token.blank? ? nil : AccountResetRequest.find_by(request_token: token) - return false unless account_reset - now = Time.zone.now - account_reset.update(cancelled_at: now, - reported_fraud_at: now, - request_token: nil, - granted_token: nil) - end - - def grant_request - token = SecureRandom.uuid - arr = AccountResetRequest.find_by(user_id: @user_id) - arr.with_lock do - return false if arr.granted_token_valid? - account_reset_request.update(granted_at: Time.zone.now, - granted_token: token) - end - true - end - - def self.grant_tokens_and_send_notifications - users_sql = <<~SQL - cancelled_at IS NULL AND - granted_at IS NULL AND - requested_at < :tvalue AND - request_token IS NOT NULL AND - granted_token IS NULL - SQL - send_notifications_with_sql(users_sql) - end - - def self.send_notifications_with_sql(users_sql) - notifications_sent = 0 - AccountResetRequest.where( - users_sql, tvalue: Time.zone.now - Figaro.env.account_reset_wait_period_days.to_i.days - ).order('requested_at ASC').each do |arr| - notifications_sent += 1 if reset_and_notify(arr) - end - notifications_sent - end - private_class_method :send_notifications_with_sql - - def self.reset_and_notify(arr) - user = arr.user - return false unless AccountResetService.new(user).grant_request - UserMailer.account_reset_granted(user, arr.reload).deliver_later - true - end - private_class_method :reset_and_notify - - private - - def account_reset_request - AccountResetRequest.find_or_create_by(user_id: @user_id) - end -end diff --git a/app/services/admin_constraint.rb b/app/services/admin_constraint.rb deleted file mode 100644 index 78facbae08e..00000000000 --- a/app/services/admin_constraint.rb +++ /dev/null @@ -1,15 +0,0 @@ -class AdminConstraint - def matches?(request) - user_is_admin?(request) && user_is_2fa_authenticated?(request) - end - - private - - def user_is_admin?(request) - request.env['warden'].user&.admin? - end - - def user_is_2fa_authenticated?(request) - request.env['warden'].session(:user)['need_two_factor_authentication'] == false - end -end diff --git a/app/services/analytics.rb b/app/services/analytics.rb index 7bd725a87bb..8983a469d6e 100644 --- a/app/services/analytics.rb +++ b/app/services/analytics.rb @@ -58,6 +58,7 @@ def browser ACCOUNT_DELETION = 'Account Deletion Requested'.freeze ACCOUNT_RESET_VISIT = 'Account deletion and reset visited'.freeze ACCOUNT_VISIT = 'Account Page Visited'.freeze + DOC_AUTH = 'Doc Auth'.freeze # visited or submitted is appended EMAIL_AND_PASSWORD_AUTH = 'Email and Password Authentication'.freeze EMAIL_CHANGE_REQUEST = 'Email Change Request'.freeze IDV_BASIC_INFO_VISIT = 'IdV: basic info visited'.freeze diff --git a/app/services/flow/base_flow.rb b/app/services/flow/base_flow.rb new file mode 100644 index 00000000000..c74187c0e57 --- /dev/null +++ b/app/services/flow/base_flow.rb @@ -0,0 +1,43 @@ +module Flow + class BaseFlow + attr_accessor :flow_session + attr_reader :steps, :actions, :current_user, :params + + def initialize(steps, actions, session, current_user) + @current_user = current_user + @steps = steps.with_indifferent_access + @actions = actions.with_indifferent_access + @params = nil + @flow_session = session + end + + def next_step + step, _klass = steps.detect do |_step, klass| + !@flow_session[klass.to_s] + end + step + end + + def handle(step, params) + @flow_session[:error_message] = nil + handler = steps[step] || actions[step] + return failure("Unhandled step #{step}") unless handler + @params = params + wrap_send(handler) + end + + private + + def wrap_send(handler) + obj = handler.new(self) + value = obj.base_call + form_response(obj, value) + end + + def form_response(obj, value) + response = value.class == FormResponse ? value : FormResponse.new(success: true, errors: {}) + obj.mark_step_complete if response.success? + response + end + end +end diff --git a/app/services/flow/base_step.rb b/app/services/flow/base_step.rb new file mode 100644 index 00000000000..7d835a3f744 --- /dev/null +++ b/app/services/flow/base_step.rb @@ -0,0 +1,45 @@ +module Flow + class BaseStep + def initialize(context, name) + @context = context + @form_response = nil + @name = name + end + + def base_call + form_response = form_submit + return form_response unless form_response.success? + call + end + + def mark_step_complete(step = nil) + klass = step.nil? ? self.class : steps[step] + flow_session[klass.to_s] = true + end + + private + + def form_submit + FormResponse.new(success: true, errors: {}) + end + + def failure(message) + flow_session[:error_message] = message + FormResponse.new(success: false, errors: { message: message }) + end + + def flow_params + params[@name] + end + + def permit(*args) + params.require(@name).permit(*args) + end + + def reset + @context.flow_session = {} + end + + delegate :flow_session, :current_user, :params, :steps, to: :@context + end +end diff --git a/app/services/flow/flow_state_machine.rb b/app/services/flow/flow_state_machine.rb new file mode 100644 index 00000000000..4c2e89eda9e --- /dev/null +++ b/app/services/flow/flow_state_machine.rb @@ -0,0 +1,90 @@ +module Flow + module FlowStateMachine + extend ActiveSupport::Concern + + included do + before_action :fsm_initialize + before_action :ensure_correct_step, only: :show + end + + attr_accessor :flow + + def index + redirect_to_step(flow.next_step) + end + + def show + step = params[:step] + analytics.track_event(analytics_visited, step: step) if @analytics_id + render_step(step, flow.flow_session) + end + + def update + step = params[:step] + result = flow.handle(step, params) + analytics.track_event(analytics_submitted, result.to_h.merge(step: step)) if @analytics_id + render_update(step, result) + end + + private + + def fsm_initialize + klass = self.class + @name = klass.name.underscore.gsub('_controller', '') + klass::FSM_SETTINGS.each { |key, value| instance_variable_set("@#{key}", value) } + user_session[@name] ||= {} + @flow = @flow.new(user_session, current_user, @name) + end + + def render_update(step, result) + flow_finish and return unless flow.next_step + move_to_next_step and return if result.success? + flow_session = flow.flow_session + flow_session[:error_message] = result.errors.values.join(' ') + render_step(step, flow_session) + end + + def move_to_next_step + user_session[@name] = flow.flow_session + redirect_to_step(flow.next_step) + end + + def render_step(step, flow_session) + render template: "#{@name}/#{step}", locals: { flow_session: flow_session } + end + + def ensure_correct_step + next_step = flow.next_step + redirect_to_step(next_step) if next_step.to_s != params[:step] + end + + def flow_finish + redirect_to send(@final_url) + end + + def redirect_to_step(step) + redirect_to send(@step_url, step: step) + end + + def analytics_submitted + @analytics_id + ' submitted' + end + + def analytics_visited + @analytics_id + ' visited' + end + end +end + +# sample usage: +# +# class FooController +# include Flow::FlowStateMachine +# +# FSM_SETTINGS = { +# step_url: :foo_step_url, +# final_url: :after_foo_url, +# flow: FooFlow, +# analytics_id: Analytics::FOO, +# }.freeze +# end diff --git a/app/services/idv/actions/reset_action.rb b/app/services/idv/actions/reset_action.rb new file mode 100644 index 00000000000..c91bc7a9662 --- /dev/null +++ b/app/services/idv/actions/reset_action.rb @@ -0,0 +1,9 @@ +module Idv + module Actions + class ResetAction < Idv::Steps::DocAuthBaseStep + def call + reset + end + end + end +end diff --git a/app/services/idv/acuant/assure_id.rb b/app/services/idv/acuant/assure_id.rb new file mode 100644 index 00000000000..f91134f044a --- /dev/null +++ b/app/services/idv/acuant/assure_id.rb @@ -0,0 +1,107 @@ +module Idv + module Acuant + class AssureId + include Idv::Acuant::Http + + base_uri Figaro.env.acuant_assure_id_url + + FRONT = 0 + BACK = 1 + + attr_accessor :instance_id + + def initialize(cfg = default_cfg) + @subscription_id = cfg.fetch(:subscription_id) + @authentication_params = cfg.slice(:username, :password) + @instance_id = nil + end + + def create_document + url = '/AssureIDService/Document/Instance' + + options = default_options.merge( + headers: content_type_json, + body: image_params + ) + + status, @instance_id = post(url, options) { |body| body.delete('"') } + [status, @instance_id] + end + + def post_front_image(image) + post_image(image, FRONT) + end + + def post_back_image(image) + post_image(image, BACK) + end + + def results + url = "/AssureIDService/Document/#{instance_id}" + + options = default_options.merge( + headers: accept_json + ) + + get(url, options, &JSON.method(:parse)) + end + + def face_image + url = "/AssureIDService/Document/#{instance_id}/Field/Image?key=Photo" + + get(url, default_options) + end + + private + + def post_image(image, side) + url = "/AssureIDService/Document/#{instance_id}/Image?side=#{side}&light=0" + + options = default_options.merge( + headers: accept_json, + body: image + ) + + post(url, options) + end + + def image_params + { + AuthenticationSensitivity: 0, # normal + ClassificationMode: 0, # automatic + Device: device_params, + ImageCroppingExpectedSize: '1', # id + ImageCroppingMode: '1', # automatic + ManualDocumentType: nil, + ProcessMode: 0, # default + SubscriptionId: @subscription_id, + }.to_json + end + + def device_params + { + HasContactlessChipReader: false, + HasMagneticStripeReader: false, + SerialNumber: 'xxx', + Type: { + Manufacturer: 'Login.gov', + Model: 'Doc Auth 1.0', + SensorType: '3' # mobile + }, + } + end + + def default_cfg + { + subscription_id: env.acuant_assure_id_subscription_id, + username: env.acuant_assure_id_username, + password: env.acuant_assure_id_password, + } + end + + def default_options + { basic_auth: @authentication_params } + end + end + end +end diff --git a/app/services/idv/acuant/facial_match.rb b/app/services/idv/acuant/facial_match.rb new file mode 100644 index 00000000000..4171a76fdf7 --- /dev/null +++ b/app/services/idv/acuant/facial_match.rb @@ -0,0 +1,38 @@ +module Idv + module Acuant + class FacialMatch + include Idv::Acuant::Http + + base_uri Figaro.env.acuant_facial_match_url + + def initialize(cfg = default_cfg) + @license_key = Base64.encode64(cfg.fetch(:license_key)) + end + + def call(id_image, self_image) + url = '/FacialMatch' + + options = { + headers: headers, + body: { idFaceImage: id_image, selfieImage: self_image }, + } + + post(url, options, &JSON.method(:parse)) + end + + private + + def default_cfg + { license_key: env.acuant_facial_match_license_key } + end + + def headers + accept_json.merge(license_key_auth) + end + + def license_key_auth + { 'Authorization' => "LicenseKey #{@license_key}" } + end + end + end +end diff --git a/app/services/idv/acuant/http.rb b/app/services/idv/acuant/http.rb new file mode 100644 index 00000000000..b23a6d101dc --- /dev/null +++ b/app/services/idv/acuant/http.rb @@ -0,0 +1,48 @@ +module Idv + module Acuant + module Http + extend ActiveSupport::Concern + + included do + include HTTParty + end + + def get(url, options, &block) + handle_response(self.class.get(url, options), block) + end + + def post(url, options, &block) + handle_response(self.class.post(url, options), block) + end + + private + + def handle_response(response, block) + return [false, response.message] unless success?(response) + handle_success(response, block) + end + + def handle_success(response, block) + body = response.body + data = block ? block.call(body) : body + [true, data] + end + + def success?(response) + response.code.between?(200, 299) + end + + def accept_json + { 'Accept' => 'application/json' } + end + + def content_type_json + { 'Content-Type' => 'application/json' } + end + + def env + Figaro.env + end + end + end +end diff --git a/app/services/idv/agent.rb b/app/services/idv/agent.rb index dfc9ac4c2e2..6f8b1fd4038 100644 --- a/app/services/idv/agent.rb +++ b/app/services/idv/agent.rb @@ -7,7 +7,7 @@ def proofer_attribute?(key) end def initialize(applicant) - @applicant = applicant.symbolize_keys! + @applicant = applicant.symbolize_keys end def proof(*stages) @@ -18,6 +18,7 @@ def proof(*stages) log_vendor(vendor, results, stage) proofer_result = vendor.proof(@applicant) results = merge_results(results, proofer_result) + results[:timed_out] = proofer_result.timed_out? break unless proofer_result.success? end @@ -35,6 +36,7 @@ def init_results }, exception: nil, success: false, + timed_out: false, } end diff --git a/app/services/idv/flows/doc_auth_flow.rb b/app/services/idv/flows/doc_auth_flow.rb new file mode 100644 index 00000000000..731b785327c --- /dev/null +++ b/app/services/idv/flows/doc_auth_flow.rb @@ -0,0 +1,29 @@ +module Idv + module Flows + class DocAuthFlow < Flow::BaseFlow + STEPS = { + ssn: Idv::Steps::SsnStep, + front_image: Idv::Steps::FrontImageStep, + back_image: Idv::Steps::BackImageStep, + doc_failed: Idv::Steps::DocFailedStep, + doc_success: Idv::Steps::DocSuccessStep, + self_image: Idv::Steps::SelfImageStep, + }.freeze + + ACTIONS = { + reset: Idv::Actions::ResetAction, + }.freeze + + attr_reader :idv_session # this is needed to support (and satisfy) the current LOA3 flow + + def initialize(session, current_user, name) + @idv_session = self.class.session_idv(session) + super(STEPS, ACTIONS, session[name], current_user) + end + + def self.session_idv(session) + session[:idv] ||= { params: {}, step_attempts: { phone: 0 } } + end + end + end +end diff --git a/app/services/idv/job.rb b/app/services/idv/job.rb deleted file mode 100644 index 91e28de29a4..00000000000 --- a/app/services/idv/job.rb +++ /dev/null @@ -1,18 +0,0 @@ -module Idv - module Job - class << self - def submit(idv_session, stages) - result_id = SecureRandom.uuid - - idv_session.async_result_id = result_id - idv_session.async_result_started_at = Time.zone.now.to_i - - Idv::ProoferJob.perform_later( - result_id: result_id, - applicant_json: idv_session.vendor_params.to_json, - stages: stages.to_json - ) - end - end - end -end diff --git a/app/services/idv/phone_step.rb b/app/services/idv/phone_step.rb index 3f8bad55004..1acfa040e4c 100644 --- a/app/services/idv/phone_step.rb +++ b/app/services/idv/phone_step.rb @@ -1,35 +1,73 @@ module Idv - class PhoneStep < Step - def submit - if complete? - update_idv_session - else - idv_session.vendor_phone_confirmation = false - end + class PhoneStep + def initialize(idv_session:) + self.idv_session = idv_session + end + + def submit(step_params) + self.step_params = step_params + self.idv_result = Idv::Agent.new(applicant).proof(:address) + increment_attempts_count + success = idv_result[:success] + update_idv_session if success + FormResponse.new( + success: success, errors: idv_result[:errors], + extra: extra_analytics_attributes + ) + end - FormResponse.new(success: complete?, errors: errors, extra: extra_analytics_attributes) + def failure_reason + return :fail if idv_session.step_attempts[:phone] >= Idv::Attempter.idv_max_attempts + return :timeout if idv_result[:timed_out] + return :jobfail if idv_result[:exception].present? + return :warning if idv_result[:success] != true end private - def complete? - vendor_validation_passed? + attr_accessor :idv_session, :step_params, :idv_result + + def applicant + @applicant ||= idv_session.applicant.merge( + phone: normalized_phone + ) + end + + def normalized_phone + @normalized_phone ||= begin + formatted_phone = PhoneFormatter.format(step_params[:phone]) + formatted_phone.gsub(/\D/, '')[1..-1] if formatted_phone.present? + end + end + + def increment_attempts_count + idv_session.step_attempts[:phone] += 1 end def update_idv_session - idv_session.vendor_phone_confirmation = true idv_session.address_verification_mechanism = :phone - idv_session.params = idv_form_params - idv_session.user_phone_confirmation = idv_form_params[:phone_confirmed_at].present? + idv_session.applicant = applicant + idv_session.vendor_phone_confirmation = true + idv_session.user_phone_confirmation = phone_matches_user_phone? + end + + def phone_matches_user_phone? + applicant_phone = PhoneFormatter.format(applicant[:phone]) + return false if applicant_phone.blank? + user_phones.include?(applicant_phone) + end + + def user_phones + MfaContext.new( + idv_session.current_user + ).phone_configurations.map do |phone_configuration| + PhoneFormatter.format(phone_configuration.phone) + end.compact end def extra_analytics_attributes { - vendor: { - messages: vendor_validator_result.messages, - context: vendor_validator_result.context, - exception: vendor_validator_result.exception, - }, + vendor: idv_result.except(:errors, :success), } end end diff --git a/app/services/idv/profile_step.rb b/app/services/idv/profile_step.rb index f83eba0735c..d8324c08fb6 100644 --- a/app/services/idv/profile_step.rb +++ b/app/services/idv/profile_step.rb @@ -1,47 +1,58 @@ module Idv - class ProfileStep < Step - def submit - @success = complete? + class ProfileStep + def initialize(idv_session:) + self.idv_session = idv_session + end + def submit(step_params) + consume_step_params(step_params) + self.idv_result = Idv::Agent.new(applicant).proof(:resolution, :state_id) increment_attempts_count + success = idv_result[:success] update_idv_session if success - - FormResponse.new(success: success, errors: errors, extra: extra_analytics_attributes) + FormResponse.new( + success: success, errors: idv_result[:errors], + extra: extra_analytics_attributes + ) end - def attempts_exceeded? - attempter.exceeded? + def failure_reason + return :fail if attempter.exceeded? + return :timeout if idv_result[:timed_out] + return :jobfail if idv_result[:exception].present? + return :warning if idv_result[:success] != true end private - attr_reader :success + attr_accessor :idv_session, :step_params, :idv_result - def complete? - !attempts_exceeded? && vendor_validation_passed? + def consume_step_params(params) + self.step_params = params.merge!(state_id_jurisdiction: params[:state]) end - def attempter - @_idv_attempter ||= Idv::Attempter.new(idv_session.current_user) + def applicant + step_params.merge(uuid: idv_session.current_user.uuid) end def increment_attempts_count attempter.increment end + def attempter + @attempter ||= Idv::Attempter.new(idv_session.current_user) + end + def update_idv_session + idv_session.applicant = applicant idv_session.profile_confirmation = true idv_session.resolution_successful = true end def extra_analytics_attributes { - idv_attempts_exceeded: attempts_exceeded?, - vendor: { - messages: vendor_validator_result.messages, - context: vendor_validator_result.context, - exception: vendor_validator_result.exception, - }, + idv_attempts_exceeded: attempter.exceeded?, + vendor: idv_result.except(:errors, :success), } end end diff --git a/app/services/idv/send_phone_confirmation_otp.rb b/app/services/idv/send_phone_confirmation_otp.rb index 414bd143696..79e73115a40 100644 --- a/app/services/idv/send_phone_confirmation_otp.rb +++ b/app/services/idv/send_phone_confirmation_otp.rb @@ -23,7 +23,7 @@ def user_locked_out? end def phone - @phone ||= PhoneFormatter.format(idv_session.params[:phone]) + @phone ||= PhoneFormatter.format(idv_session.applicant[:phone]) end private diff --git a/app/services/idv/session.rb b/app/services/idv/session.rb index a6e59634c29..d92dcbaec94 100644 --- a/app/services/idv/session.rb +++ b/app/services/idv/session.rb @@ -5,17 +5,20 @@ class Session async_result_started_at address_verification_mechanism applicant - params vendor_phone_confirmation user_phone_confirmation phone_confirmation_otp_delivery_method phone_confirmation_otp_sent_at phone_confirmation_otp pii + previous_phone_step_params + previous_profile_step_params profile_confirmation profile_id + profile_step_params personal_key resolution_successful + selected_jurisdiction step_attempts ].freeze @@ -61,7 +64,7 @@ def cache_encrypted_pii(password) end def vendor_params - applicant_params.merge('uuid' => current_user.uuid) + applicant.merge('uuid' => current_user.uuid) end def profile @@ -113,7 +116,7 @@ def set_idv_session end def new_idv_session - { params: {}, step_attempts: { phone: 0 } } + { step_attempts: { phone: 0 } } end def move_pii_to_user_session @@ -125,13 +128,9 @@ def session user_session.fetch(:idv, {}) end - def applicant_params - params.select { |key, _value| Idv::Agent.proofer_attribute?(key) } - end - def build_profile_maker(user_password) Idv::ProfileMaker.new( - applicant: applicant_params, + applicant: applicant, user: current_user, user_password: user_password ) diff --git a/app/services/idv/step.rb b/app/services/idv/step.rb deleted file mode 100644 index 5c8a92e6fe6..00000000000 --- a/app/services/idv/step.rb +++ /dev/null @@ -1,36 +0,0 @@ -# abstract base class for Idv Steps -module Idv - class Step - def initialize(idv_session:, idv_form_params:, vendor_validator_result:) - @idv_form_params = idv_form_params - @idv_session = idv_session - @vendor_validator_result = vendor_validator_result - end - - def vendor_validation_passed? - return false if vendor_validator_job_failed? - vendor_validator_result.success? - end - - def vendor_validation_timed_out? - vendor_validator_result.timed_out? - end - - def vendor_validator_job_failed? - vendor_validator_result&.job_failed? - end - - private - - attr_accessor :idv_session - attr_reader :idv_form_params, :vendor_validator_result - - def errors - @_errors ||= begin - vendor_validator_result.errors.each_with_object({}) do |(key, value), errs| - errs[key] = Array(value) - end - end - end - end -end diff --git a/app/services/idv/steps/back_image_step.rb b/app/services/idv/steps/back_image_step.rb new file mode 100644 index 00000000000..920ad6478dc --- /dev/null +++ b/app/services/idv/steps/back_image_step.rb @@ -0,0 +1,74 @@ +module Idv + module Steps + class BackImageStep < DocAuthBaseStep + def call + good, data = assure_id.post_back_image(image.read) + return failure(data) unless good + + failure_data, data = verify_back_image + return failure_data if failure_data + + extract_pii_from_doc_and_perform_resolution(data) + end + + private + + def form_submit + Idv::ImageUploadForm.new(current_user).submit(permit(:image)) + end + + def extract_pii_from_doc_and_perform_resolution(data) + pii_from_doc = Idv::Utils::PiiFromDoc.new(data). + call(flow_session[:ssn], current_user.phone_configurations.first.phone) + result = perform_resolution(pii_from_doc) + if result.success? + step_successful(pii_from_doc) + else + flow_session[:matcher_pii_from_doc] = pii_from_doc + end + end + + def step_successful(pii_from_doc) + mark_step_complete(:doc_failed) # skip doc failed + save_legacy_state(pii_from_doc) + end + + def save_legacy_state(pii_from_doc) + skip_legacy_steps + idv_session['params'] = pii_from_doc + idv_session['applicant'] = pii_from_doc + idv_session['applicant']['uuid'] = current_user.uuid + end + + def skip_legacy_steps + idv_session['profile_confirmation'] = true + idv_session['vendor_phone_confirmation'] = true + idv_session['user_phone_confirmation'] = true + idv_session['address_verification_mechanism'] = 'phone' + idv_session['resolution_successful'] = 'phone' + end + + def perform_resolution(pii_from_doc) + idv_result = Idv::Agent.new(pii_from_doc).proof(:resolution) + FormResponse.new( + success: idv_result[:success], errors: idv_result[:errors] + ) + end + + def verify_back_image + back_image_verified, data = assure_id.results + return failure(data) unless back_image_verified + + return [nil, data] if data['Result'] == 1 + + failure_alerts(data) + end + + def failure_alerts(data) + failure(data['Alerts']. + reject { |res| res['Result'] == 2 }. + map { |act| act['Actions'] }) + end + end + end +end diff --git a/app/services/idv/steps/doc_auth_base_step.rb b/app/services/idv/steps/doc_auth_base_step.rb new file mode 100644 index 00000000000..6228c43ae88 --- /dev/null +++ b/app/services/idv/steps/doc_auth_base_step.rb @@ -0,0 +1,24 @@ +module Idv + module Steps + class DocAuthBaseStep < Flow::BaseStep + def initialize(context) + @assure_id = nil + super(context, :doc_auth) + end + + private + + def image + flow_params[:image] + end + + def assure_id + @assure_id ||= Idv::Acuant::AssureId.new + @assure_id.instance_id = flow_session[:instance_id] + @assure_id + end + + delegate :idv_session, to: :@context + end + end +end diff --git a/app/services/idv/steps/doc_failed_step.rb b/app/services/idv/steps/doc_failed_step.rb new file mode 100644 index 00000000000..b5c35a4d42e --- /dev/null +++ b/app/services/idv/steps/doc_failed_step.rb @@ -0,0 +1,6 @@ +module Idv + module Steps + class DocFailedStep < DocAuthBaseStep + end + end +end diff --git a/app/services/idv/steps/doc_success_step.rb b/app/services/idv/steps/doc_success_step.rb new file mode 100644 index 00000000000..d7c5f6949cd --- /dev/null +++ b/app/services/idv/steps/doc_success_step.rb @@ -0,0 +1,7 @@ +module Idv + module Steps + class DocSuccessStep < DocAuthBaseStep + def call; end + end + end +end diff --git a/app/services/idv/steps/front_image_step.rb b/app/services/idv/steps/front_image_step.rb new file mode 100644 index 00000000000..987eb693ade --- /dev/null +++ b/app/services/idv/steps/front_image_step.rb @@ -0,0 +1,24 @@ +module Idv + module Steps + class FrontImageStep < DocAuthBaseStep + def call + success, instance_id_or_message = assure_id.create_document + return failure(instance_id_or_message) unless success + + flow_session[:instance_id] = instance_id_or_message + upload_front_image + end + + private + + def form_submit + Idv::ImageUploadForm.new(current_user).submit(permit(:image)) + end + + def upload_front_image + success, message = assure_id.post_front_image(image.read) + return failure(message) unless success + end + end + end +end diff --git a/app/services/idv/steps/self_image_step.rb b/app/services/idv/steps/self_image_step.rb new file mode 100644 index 00000000000..f0f74ccb523 --- /dev/null +++ b/app/services/idv/steps/self_image_step.rb @@ -0,0 +1,48 @@ +module Idv + module Steps + class SelfImageStep < DocAuthBaseStep + def call + success, data = verify_image(image) + return failure(data) unless success + + return failure(I18n.t('doc_auth.errors.selfie')) unless data['FacialMatch'] + + step_successful(data) + end + + private + + def form_submit + Idv::ImageUploadForm.new(current_user).submit(permit(:image)) + end + + def step_successful(data) + save_doc_auth + flow_session[:image_verification_data] = data + end + + def save_doc_auth + doc_auth.license_confirmed_at = Time.zone.now + doc_auth.save + end + + def verify_image(self_image) + face_image_verified, data = assure_id.face_image + return failure(data) unless face_image_verified + + decoded_self_image = Base64.decode64(self_image.sub('data:image/png;base64,', '')) + Idv::Utils::ImagesToTmpFiles.new(data, decoded_self_image).call do |tmp_images| + facial_match.call(*tmp_images) + end + end + + def facial_match + @facial_match ||= Idv::Acuant::FacialMatch.new + end + + def doc_auth + @doc_auth ||= ::DocAuth.find_or_create_by(user_id: current_user.id) + end + end + end +end diff --git a/app/services/idv/steps/ssn_step.rb b/app/services/idv/steps/ssn_step.rb new file mode 100644 index 00000000000..a3ba3d34afa --- /dev/null +++ b/app/services/idv/steps/ssn_step.rb @@ -0,0 +1,15 @@ +module Idv + module Steps + class SsnStep < DocAuthBaseStep + def call + flow_session[:ssn] = flow_params[:ssn] + end + + private + + def form_submit + Idv::SsnForm.new(current_user).submit(permit(:ssn)) + end + end + end +end diff --git a/app/services/idv/utils/images_to_tmp_files.rb b/app/services/idv/utils/images_to_tmp_files.rb new file mode 100644 index 00000000000..e5f7fec5a04 --- /dev/null +++ b/app/services/idv/utils/images_to_tmp_files.rb @@ -0,0 +1,36 @@ +module Idv + module Utils + class ImagesToTmpFiles + def initialize(*images) + @images = images + end + + def call + tmp_files = images_to_tmp_files + yield tmp_files + ensure + tmp_files.each { |tmp| delete_file(tmp) } + end + + private + + def images_to_tmp_files + @images.map do |image| + Tempfile.open('foo', encoding: 'ascii-8bit').tap do |tmp| + write_file(tmp, image) + end + end + end + + def write_file(tmp, image) + tmp.write(image) + tmp.rewind + end + + def delete_file(tmp) + tmp.close + tmp.unlink + end + end + end +end diff --git a/app/services/idv/utils/pii_from_doc.rb b/app/services/idv/utils/pii_from_doc.rb new file mode 100644 index 00000000000..cfd59b32577 --- /dev/null +++ b/app/services/idv/utils/pii_from_doc.rb @@ -0,0 +1,43 @@ +module Idv + module Utils + class PiiFromDoc + VALUE = { + 'First Name' => :first_name, + 'Middle Name' => :middle_name, + 'Surname' => :last_name, + 'Address Line 1' => :address1, + 'Address City' => :city, + 'Address State' => :state, + 'Address Postal Code' => :zipcode, + 'Birth Date' => :dob, + }.freeze + + def initialize(id_data_fields) + @name_to_value = {} + id_data_fields['Fields'].each do |field| + @name_to_value[field['Name']] = field['Value'] + end + end + + def call(ssn, phone) + VALUE.each do |key, value| + hash[value] = @name_to_value[key] + end + hash[:dob] = convert_date(hash[:dob]) + hash[:ssn] = ssn + hash[:phone] = phone + hash + end + + private + + def hash + @hash ||= {} + end + + def convert_date(date) + Date.strptime((date[6..-3].to_f / 1000).to_s, '%s').strftime('%m/%d/%Y') + end + end + end +end diff --git a/app/services/idv/vendor_result.rb b/app/services/idv/vendor_result.rb deleted file mode 100644 index a56396cc3a8..00000000000 --- a/app/services/idv/vendor_result.rb +++ /dev/null @@ -1,39 +0,0 @@ -module Idv - class VendorResult - attr_reader :success, - :errors, - :messages, - :context, - :applicant, - :timed_out, - :exception - - def self.new_from_json(json) - parsed = JSON.parse(json, symbolize_names: true) - new(**parsed) - end - - def initialize(success: nil, errors: {}, messages: [], context: {}, - applicant: nil, timed_out: nil, exception: nil) - @success = success - @errors = errors - @messages = messages - @context = context - @applicant = applicant - @timed_out = timed_out - @exception = exception - end - - def success? - success == true - end - - def timed_out? - timed_out == true - end - - def job_failed? - errors.fetch(:job_failed, false) - end - end -end diff --git a/app/services/otp_delivery_preference_updater.rb b/app/services/otp_delivery_preference_updater.rb index 67a2a79e0bf..ce1592a504d 100644 --- a/app/services/otp_delivery_preference_updater.rb +++ b/app/services/otp_delivery_preference_updater.rb @@ -21,7 +21,7 @@ def should_update_user? def otp_delivery_preference_changed? return true if preference != user.otp_delivery_preference - phone_configuration = user.phone_configurations.first + phone_configuration = MfaContext.new(user).phone_configurations.first phone_configuration.present? && preference != phone_configuration.delivery_preference end end diff --git a/app/services/phone_verification.rb b/app/services/phone_verification.rb index 1ecc430869a..eea8381f454 100644 --- a/app/services/phone_verification.rb +++ b/app/services/phone_verification.rb @@ -1,15 +1,16 @@ class PhoneVerification - AUTHY_START_ENDPOINT = 'https://api.authy.com/protected/json/phones/verification/start'.freeze + AUTHY_HOST = 'https://api.authy.com'.freeze + AUTHY_VERIFY_ENDPOINT = '/protected/json/phones/verification/start'.freeze - HEADERS = { 'X-Authy-API-Key' => Figaro.env.twilio_verify_api_key }.freeze - OPEN_TIMEOUT = 5 - READ_TIMEOUT = 5 + TIMEOUT = Figaro.env.twilio_timeout.to_i AVAILABLE_LOCALES = %w[af ar ca zh zh-CN zh-HK hr cs da nl en fi fr de el he hi hu id it ja ko ms nb pl pt-BR pt ro ru es sv tl th tr vi].freeze cattr_accessor :adapter do - Typhoeus + Faraday.new(url: AUTHY_HOST, request: { open_timeout: TIMEOUT, timeout: TIMEOUT }) do |faraday| + faraday.adapter :typhoeus + end end def initialize(phone:, code:, locale: nil) @@ -18,58 +19,42 @@ def initialize(phone:, code:, locale: nil) @locale = locale end - # rubocop:disable Style/GuardClause def send_sms - unless start_request.success? - raise VerifyError.new( - code: error_code, - message: error_message, - status: start_request.response_code, - response: start_request.response_body - ) - end + tries ||= 2 + raise_bad_request_error unless response.success? + rescue Faraday::TimeoutError, Faraday::ConnectionFailed => exception + retry unless (tries -= 1).zero? + raise_connection_timed_out_or_failed_error(exception) end - # rubocop:enable Style/GuardClause private - attr_reader :phone, :code, :locale - - def error_code - response_body.fetch('error_code', nil).to_i - end - - def error_message - response_body.fetch('message', '') - end + attr_reader :phone, :code, :locale, :connection - def response_body - @response_body ||= JSON.parse(start_request.response_body) - rescue JSON::ParserError - {} - end - - def start_request - @start_request ||= adapter.post(AUTHY_START_ENDPOINT, start_params) + def response + @response ||= begin + adapter.post do |request| + request.url AUTHY_VERIFY_ENDPOINT + request.headers['X-Authy-API-Key'] = Figaro.env.twilio_verify_api_key + request.body = request_body + end + end end - # rubocop:disable Metrics/MethodLength - def start_params + def request_body { - headers: HEADERS, - body: { - code_length: 6, - country_code: country_code, - custom_code: code, - locale: locale, - phone_number: number_without_country_code, - via: 'sms', - }, - connecttimeout: OPEN_TIMEOUT, - timeout: READ_TIMEOUT, + code_length: 6, + country_code: country_code, + custom_code: code, + locale: locale, + phone_number: number_without_country_code, + via: 'sms', } end - # rubocop:enable Metrics/MethodLength + + def country_code + parsed_phone.country_code + end def number_without_country_code parsed_phone.raw_national @@ -79,8 +64,36 @@ def parsed_phone @parsed_phone ||= Phonelib.parse(phone) end - def country_code - parsed_phone.country_code + def raise_bad_request_error + raise VerifyError.new( + code: error_code, + message: error_message, + status: response.status, + response: response.body + ) + end + + def raise_connection_timed_out_or_failed_error(exception) + raise VerifyError.new( + code: 4_815_162_342, + message: "Twilio Verify: #{exception.class}", + status: 0, + response: '' + ) + end + + def error_code + response_body.fetch('error_code', nil).to_i + end + + def error_message + response_body.fetch('message', '') + end + + def response_body + @response_body ||= JSON.parse(response.body) + rescue JSON::ParserError + {} end class VerifyError < StandardError diff --git a/app/services/piv_cac_service.rb b/app/services/piv_cac_service.rb index 20464254015..0d1ec848def 100644 --- a/app/services/piv_cac_service.rb +++ b/app/services/piv_cac_service.rb @@ -14,7 +14,7 @@ def decode_token(token) end def piv_cac_service_link(nonce) - if FeatureManagement.development_and_piv_cac_entry_enabled? + if FeatureManagement.development_and_identity_pki_disabled? test_piv_cac_entry_url else uri = URI(randomize_uri(Figaro.env.piv_cac_service_url)) @@ -29,7 +29,6 @@ def piv_cac_verify_token_link end def piv_cac_available_for_agency?(agency, email = nil) - return unless FeatureManagement.piv_cac_enabled? available_for_agency?(agency) || available_for_email?(agency, email) end @@ -120,7 +119,7 @@ def decode_token_response(res) end def decode_test_token(token) - if FeatureManagement.development_and_piv_cac_entry_enabled? + if FeatureManagement.development_and_identity_pki_disabled? JSON.parse(token[5..-1]) else { 'error' => 'token.bad' } diff --git a/app/services/populate_email_addresses_table.rb b/app/services/populate_email_addresses_table.rb new file mode 100644 index 00000000000..069be4bbdab --- /dev/null +++ b/app/services/populate_email_addresses_table.rb @@ -0,0 +1,39 @@ +class PopulateEmailAddressesTable + def initialize + @count = 0 + @total = 0 + end + + # :reek:DuplicateMethodCall + def call + User.in_batches(of: 1000) do |relation| + sleep(1) + process_batch(relation) + Rails.logger.info "#{@count} / #{@total}" + end + Rails.logger.info "Processed #{@count} user email addresses" + end + + private + + def process_batch(relation) + User.transaction do + relation.each do |user| + @total += 1 + next if user.email_address.present? || user.encrypted_email.blank? + user.create_email_address(email_info_for_user(user)) + @count += 1 + end + end + end + + def email_info_for_user(user) + { + encrypted_email: user.encrypted_email, + email_fingerprint: user.email_fingerprint, + confirmed_at: user.confirmed_at, + confirmation_sent_at: user.confirmation_sent_at, + confirmation_token: user.confirmation_token, + } + end +end diff --git a/app/services/remember_device_cookie.rb b/app/services/remember_device_cookie.rb index ad1b6b5f663..84fabb18674 100644 --- a/app/services/remember_device_cookie.rb +++ b/app/services/remember_device_cookie.rb @@ -46,7 +46,7 @@ def expired? end def user_has_changed_phone?(user) - user.phone_configurations.any? do |phone_configuration| + MfaContext.new(user).phone_configurations.any? do |phone_configuration| phone_configuration.confirmed_at.to_i > created_at.to_i end end diff --git a/app/services/twilio_service.rb b/app/services/twilio_service.rb index 1e72e04d662..cf56e9c60b7 100644 --- a/app/services/twilio_service.rb +++ b/app/services/twilio_service.rb @@ -53,16 +53,35 @@ def random_phone_number end def sanitize_errors + tries ||= 2 yield rescue Twilio::REST::RestError => error sanitize_phone_number(error.message) raise - rescue Faraday::TimeoutError - raise Twilio::REST::RestError.new('timeout', TwilioTimeoutResponse.new) + rescue Faraday::TimeoutError, Faraday::ConnectionFailed + retry unless (tries -= 1).zero? + raise_custom_timeout_error end DIGITS_TO_PRESERVE = 5 + def raise_custom_timeout_error + Rails.logger.info(request_data.to_json) + raise Twilio::REST::RestError.new('timeout', TwilioTimeoutResponse.new) + end + + def request_data + last_request = @client.http_client.last_request + + { + event: 'Twilio Request Timeout', + url: last_request.url, + method: last_request.method, + params: last_request.params, + headers: last_request.headers, + } + end + def sanitize_phone_number(str) str.gsub!(/\+[\d\(\)\- ]+/) do |match| digits_preserved = 0 diff --git a/app/services/update_user.rb b/app/services/update_user.rb index ff67ac120f8..014fa8e8277 100644 --- a/app/services/update_user.rb +++ b/app/services/update_user.rb @@ -15,7 +15,7 @@ def call attr_reader :user, :attributes def manage_phone_configuration - if user.phone_configurations.any? + if MfaContext.new(user).phone_configurations.any? update_phone_configuration else create_phone_configuration @@ -23,12 +23,12 @@ def manage_phone_configuration end def update_phone_configuration - user.phone_configurations.first.update!(phone_attributes) + MfaContext.new(user).phone_configurations.first.update!(phone_attributes) end def create_phone_configuration return if phone_attributes[:phone].blank? - user.phone_configurations.create(phone_attributes) + MfaContext.new(user).phone_configurations.create(phone_attributes) end def phone_attributes diff --git a/app/services/vendor_validator_result_storage.rb b/app/services/vendor_validator_result_storage.rb deleted file mode 100644 index 098ba2422ff..00000000000 --- a/app/services/vendor_validator_result_storage.rb +++ /dev/null @@ -1,24 +0,0 @@ -class VendorValidatorResultStorage - TTL = Figaro.env.session_timeout_in_minutes.to_i.minutes.seconds.to_i - - def store(result_id:, result:) - Sidekiq.redis do |redis| - redis.setex(redis_key(result_id), TTL, result.to_json) - end - end - - def load(result_id) - result_json = Sidekiq.redis do |redis| - redis.get(redis_key(result_id)) - end - - return unless result_json - - Idv::VendorResult.new_from_json(result_json) - end - - # @api private - def redis_key(result_id) - "vendor-validator-result-#{result_id}" - end -end diff --git a/app/validators/account_reset/cancel_token_validator.rb b/app/validators/account_reset/cancel_token_validator.rb new file mode 100644 index 00000000000..65c07725bfd --- /dev/null +++ b/app/validators/account_reset/cancel_token_validator.rb @@ -0,0 +1,24 @@ +module AccountReset + module CancelTokenValidator + extend ActiveSupport::Concern + + included do + validates :token, presence: { message: I18n.t('errors.account_reset.cancel_token_missing') } + validate :valid_token + end + + private + + attr_reader :token + + def valid_token + return if account_reset_request + + errors.add(:token, I18n.t('errors.account_reset.cancel_token_invalid')) if token + end + + def account_reset_request + @account_reset_request ||= AccountResetRequest.find_by(request_token: token) + end + end +end diff --git a/app/validators/idv/form_jurisdiction_validator.rb b/app/validators/idv/form_jurisdiction_validator.rb index 16c3aebcef4..cd3dfbba265 100644 --- a/app/validators/idv/form_jurisdiction_validator.rb +++ b/app/validators/idv/form_jurisdiction_validator.rb @@ -3,7 +3,8 @@ module FormJurisdictionValidator extend ActiveSupport::Concern SUPPORTED_JURISDICTIONS = %w[ - AR AZ CO DC DE FL IA ID IL IN KY MA MD ME MI MS MT ND NE NJ NM PA SD TX VA WA WI WY + AR AZ CO DC DE FL IA ID IL IN KY MA MD ME MI MO MS MT ND NE NJ NM PA RI SC + SD TX VA VT WA WI WY ].freeze included do diff --git a/app/validators/idv/form_ssn_validator.rb b/app/validators/idv/form_ssn_validator.rb new file mode 100644 index 00000000000..60ae58b1a84 --- /dev/null +++ b/app/validators/idv/form_ssn_validator.rb @@ -0,0 +1,41 @@ +module Idv + module FormSsnValidator + extend ActiveSupport::Concern + + included do + validates :ssn, presence: true + validate :ssn_is_unique + validates_format_of :ssn, + with: /\A\d{3}-?\d{2}-?\d{4}\z/, + message: I18n.t('idv.errors.pattern_mismatch.ssn'), + allow_blank: false + end + + def duplicate_ssn? + return true if any_matching_ssn_signatures?(ssn_signature) || + ssn_is_duplicate_with_old_key? + false + end + + private + + def ssn_signature(key = Pii::Fingerprinter.current_key) + Pii::Fingerprinter.fingerprint(ssn, key) if ssn + end + + def ssn_is_unique + errors.add :ssn, I18n.t('idv.errors.duplicate_ssn') if duplicate_ssn? + end + + def ssn_is_duplicate_with_old_key? + signatures = KeyRotator::Utils.old_keys(:hmac_fingerprinter_key_queue).map do |key| + ssn_signature(key) + end + any_matching_ssn_signatures?(signatures) + end + + def any_matching_ssn_signatures?(signatures) + Profile.where.not(user_id: @user.id).where(ssn_signature: signatures).any? + end + end +end diff --git a/app/validators/idv/form_state_id_validator.rb b/app/validators/idv/form_state_id_validator.rb index ed7ac3135eb..8f56542efd7 100644 --- a/app/validators/idv/form_state_id_validator.rb +++ b/app/validators/idv/form_state_id_validator.rb @@ -6,7 +6,11 @@ module FormStateIdValidator STATE_ID_TYPES = %w[drivers_license drivers_permit state_id_card].freeze included do - validates :state_id_number, presence: true + validates :state_id_number, presence: true, + length: { + maximum: 25, + message: I18n.t('idv.errors.pattern_mismatch.state_id_number'), + } validates :state_id_type, inclusion: { in: STATE_ID_TYPES } end end diff --git a/app/validators/otp_delivery_preference_validator.rb b/app/validators/otp_delivery_preference_validator.rb index 42a22dc404f..be310e9437d 100644 --- a/app/validators/otp_delivery_preference_validator.rb +++ b/app/validators/otp_delivery_preference_validator.rb @@ -16,7 +16,7 @@ def otp_delivery_preference_supported errors.add( :phone, I18n.t( - 'devise.two_factor_authentication.otp_delivery_preference.phone_unsupported', + 'two_factor_authentication.otp_delivery_preference.phone_unsupported', location: phone_number_capabilities.unsupported_location ) ) diff --git a/app/view_models/account_show.rb b/app/view_models/account_show.rb index 45fb06b4db7..70e9413dee0 100644 --- a/app/view_models/account_show.rb +++ b/app/view_models/account_show.rb @@ -1,11 +1,12 @@ # :reek:TooManyMethods class AccountShow - attr_reader :decorated_user, :decrypted_pii, :personal_key + attr_reader :decorated_user, :decrypted_pii, :personal_key, :sp - def initialize(decrypted_pii:, personal_key:, decorated_user:) + def initialize(decrypted_pii:, personal_key:, decorated_user:, sp: '') @decrypted_pii = decrypted_pii @personal_key = personal_key @decorated_user = decorated_user + @sp = sp end def header_partial @@ -53,7 +54,7 @@ def pii_partial end def totp_partial - if decorated_user.totp_enabled? + if TwoFactorAuthentication::AuthAppPolicy.new(decorated_user.user).enabled? 'accounts/actions/disable_totp' else 'accounts/actions/enable_totp' @@ -61,7 +62,7 @@ def totp_partial end def piv_cac_partial - if decorated_user.piv_cac_enabled? + if TwoFactorAuthentication::PivCacPolicy.new(decorated_user.user).enabled? 'accounts/actions/disable_piv_cac' else 'accounts/actions/enable_piv_cac' @@ -91,15 +92,19 @@ def header_personalization end def totp_content - return I18n.t('account.index.auth_app_enabled') if decorated_user.totp_enabled? - - I18n.t('account.index.auth_app_disabled') + if TwoFactorAuthentication::AuthAppPolicy.new(decorated_user.user).enabled? + I18n.t('account.index.auth_app_enabled') + else + I18n.t('account.index.auth_app_disabled') + end end def piv_cac_content - return I18n.t('account.index.piv_cac_enabled') if decorated_user.piv_cac_enabled? - - I18n.t('account.index.piv_cac_disabled') + if TwoFactorAuthentication::PivCacPolicy.new(decorated_user.user).enabled? + I18n.t('account.index.piv_cac_enabled') + else + I18n.t('account.index.piv_cac_disabled') + end end delegate :recent_events, :connected_apps, to: :decorated_user diff --git a/app/views/account_reset/cancel/show.html.slim b/app/views/account_reset/cancel/show.html.slim new file mode 100644 index 00000000000..a3b09a53394 --- /dev/null +++ b/app/views/account_reset/cancel/show.html.slim @@ -0,0 +1,13 @@ +- title t('account_reset.cancel_request.title') + +h1.h3.my0 = t('account_reset.cancel_request.title') +br +h4.my2 = t('account_reset.cancel_request.are_you_sure') + += button_to t('account_reset.cancel_request.cancel_button'), \ + account_reset_cancel_path, method: :post, \ + class: 'btn btn-primary btn-wide' +br +br +hr += link_to t('account_reset.cancel_request.cancel'), root_url diff --git a/app/views/account_reset/confirm_delete_account/show.html.slim b/app/views/account_reset/confirm_delete_account/show.html.slim index f08b4c9c699..ac80b744234 100644 --- a/app/views/account_reset/confirm_delete_account/show.html.slim +++ b/app/views/account_reset/confirm_delete_account/show.html.slim @@ -4,6 +4,6 @@ = image_tag(asset_url('alert/fail-x.svg'), size: '48x48', alt: '',\ class: 'absolute top-n24 left-0 right-0 mx-auto') h3 = t('account_reset.confirm_delete_account.title') - p == t('account_reset.confirm_delete_account.info', \ - email: email, \ + p == t('account_reset.confirm_delete_account.info', email: email) + p == t('account_reset.confirm_delete_account.cta', \ link: link_to(t('account_reset.confirm_delete_account.link_text'), sign_up_email_path)) diff --git a/app/views/accounts/_phone.html.slim b/app/views/accounts/_phone.html.slim new file mode 100644 index 00000000000..93b25b5435d --- /dev/null +++ b/app/views/accounts/_phone.html.slim @@ -0,0 +1,16 @@ +.clearfix.border-top.border-blue-light + .p2.col.col-12 + .col.col-6.bold + = t('account.index.phone') + .right-align.col.col-6 + - if MfaContext.new(current_user).phone_configurations.empty? + .btn.btn-account-action.rounded-lg.bg-light-blue + = link_to t('account.index.phone_add'), manage_phone_path + - MfaContext.new(current_user).phone_configurations.each do |phone_configuration| + .p2.col.col-12.border-top.border-blue-light.account-list-item + .col.col-8.sm-6.truncate + = phone_configuration.phone + .col.col-4.sm-6.right-align + = render @view_model.edit_action_partial, + path: manage_phone_url, + name: t('account.index.phone') diff --git a/app/views/accounts/_pii.html.slim b/app/views/accounts/_pii.html.slim index 7c298aff5e5..b62fc10609b 100644 --- a/app/views/accounts/_pii.html.slim +++ b/app/views/accounts/_pii.html.slim @@ -27,7 +27,7 @@ .p2.border-top .clearfix.mxn1 .sm-col.sm-col-4.px1.bold = t('account.index.ssn') - .sm-col.sm-col-8.px1 = "***-**-#{decrypted_pii.ssn[-4..-1]}" + .sm-col.sm-col-8.px1 = '***-**-****' .p2.border-top.border-bottom .clearfix.mxn1 diff --git a/app/views/accounts/_webauthn.html.slim b/app/views/accounts/_webauthn.html.slim index 3e480dc8b39..98ca45f7ba4 100644 --- a/app/views/accounts/_webauthn.html.slim +++ b/app/views/accounts/_webauthn.html.slim @@ -5,8 +5,8 @@ .right-align.col.col-6 .btn.btn-account-action.rounded-lg.bg-light-blue = link_to t('account.index.webauthn_add'), webauthn_setup_url -- current_user.webauthn_configurations.each do |cfg| - .p2.col.col-12.border-top.border-blue-light +- MfaContext.new(current_user).webauthn_configurations.each do |cfg| + .p2.col.col-12.border-top.border-blue-light.account-list-item .col.col-8.sm-6.truncate = cfg.name .col.col-4.sm-6.right-align diff --git a/app/views/accounts/show.html.slim b/app/views/accounts/show.html.slim index ebceab75168..402d9fc3d64 100644 --- a/app/views/accounts/show.html.slim +++ b/app/views/accounts/show.html.slim @@ -34,30 +34,29 @@ h1.hide = t('titles.account') = t('headings.account.two_factor') = image_tag asset_url('2fa-account.svg'), width: 8, class: 'ml1' - = render 'account_item', - name: t('account.index.phone'), - content: current_user.phone_configurations.first&.phone, - path: manage_phone_path, - action: @view_model.edit_action_partial + - if TwoFactorAuthentication::PhonePolicy.new(current_user).visible? + = render 'phone' - = render 'account_item', - name: t('account.index.authentication_app'), - content: content_tag(:em, @view_model.totp_content), - action: @view_model.totp_partial + - if TwoFactorAuthentication::AuthAppPolicy.new(current_user).visible? + = render 'account_item', + name: t('account.index.authentication_app'), + content: content_tag(:em, @view_model.totp_content), + action: @view_model.totp_partial - - if FeatureManagement.webauthn_enabled? + - if TwoFactorAuthentication::WebauthnPolicy.new(current_user, @view_model.sp).visible? = render 'webauthn' - - if current_user.piv_cac_available? + - if TwoFactorAuthentication::PivCacPolicy.new(current_user).visible? = render 'account_item', name: t('account.index.piv_cac_card'), content: content_tag(:em, @view_model.piv_cac_content), action: @view_model.piv_cac_partial - - @view_model.manage_personal_key_partial do - = render 'account_item', - name: render(@view_model.personal_key_item_partial), - action: @view_model.personal_key_action_partial + - if TwoFactorAuthentication::PersonalKeyPolicy.new(current_user).visible? + - @view_model.manage_personal_key_partial do + = render 'account_item', + name: render(@view_model.personal_key_item_partial), + action: @view_model.personal_key_action_partial = render @view_model.pii_partial, decrypted_pii: @view_model.decrypted_pii diff --git a/app/views/devise/shared/_password_strength.html.slim b/app/views/devise/shared/_password_strength.html.slim index 9a94024b795..68298a525eb 100644 --- a/app/views/devise/shared/_password_strength.html.slim +++ b/app/views/devise/shared/_password_strength.html.slim @@ -9,4 +9,4 @@ span.h6 = t('instructions.password.strength.intro') span#pw-strength-txt.bold*{ 'data-forbidden-passwords' => @forbidden_passwords } = '...' .h6 - #pw-strength-feedback.italic + #pw-strength-feedback.italic   diff --git a/app/views/exception_notifier/_data.text.erb b/app/views/exception_notifier/_data.text.erb deleted file mode 100644 index 83f84559605..00000000000 --- a/app/views/exception_notifier/_data.text.erb +++ /dev/null @@ -1,11 +0,0 @@ -<% job = @data.dig(:sidekiq, :job) || {} %> - -Job class: <%= job['wrapped'] %> -Queue: <%= job['queue'] %> -Retry: <%= job['retry'] %> -Error class: <%= job['error_class'] %> -Error message: <%= job['error_message'] %> -Created at: <%= Time.at(job['created_at']) rescue 'unknown' %> -Enqueued at: <%= Time.at(job['enqueued_at']) rescue 'unknown' %> -Failed at: <%= Time.at(job['failed_at']) rescue 'unknown' %> -Retry count: <%= job['retry_count'] %> diff --git a/app/views/exception_notifier/_session.text.erb b/app/views/exception_notifier/_session.text.erb index 6560cec71f6..6b0cdd25723 100644 --- a/app/views/exception_notifier/_session.text.erb +++ b/app/views/exception_notifier/_session.text.erb @@ -18,8 +18,8 @@ Session: <%= session %> <% user = @kontroller.analytics_user || AnonymousUser.new %> User UUID: <%= user.uuid %> -<% if user.phone_configurations.any? %> - User's Country (based on first phone): <%= Phonelib.parse(user.phone_configurations.first.phone).country %> +<% if MfaContext.new(user).phone_configurations.any? %> + User's Country (based on first phone): <%= Phonelib.parse(MfaContext.new(user).phone_configurations.first.phone).country %> <% end %> Visitor ID: <%= @request.cookies['ahoy_visitor'] %> diff --git a/app/views/idv/cancellations/destroy.html.slim b/app/views/idv/cancellations/destroy.html.slim index cab629d3254..12b36c9e972 100644 --- a/app/views/idv/cancellations/destroy.html.slim +++ b/app/views/idv/cancellations/destroy.html.slim @@ -4,4 +4,8 @@ ul class="list-reset #{@presenter.state_color}-dots" - @presenter.cancellation_effects.each do |effect| li = effect -.mt2 = link_to t('idv.cancel.return_to_account'), account_path +- if decorated_session.sp_name + .mt2 = link_to "‹ #{t('links.back_to_sp', sp: decorated_session.sp_name)}", + decorated_session.failure_to_proof_url +- else + .mt2 = link_to "‹ #{t('links.back_to_sp', sp: t('links.my_account'))}", account_url diff --git a/app/views/idv/doc_auth/_start_over_or_cancel.html.slim b/app/views/idv/doc_auth/_start_over_or_cancel.html.slim new file mode 100644 index 00000000000..f662b97c00b --- /dev/null +++ b/app/views/idv/doc_auth/_start_over_or_cancel.html.slim @@ -0,0 +1,4 @@ +br += button_to(t('doc_auth.buttons.start_over'), idv_doc_auth_step_path(:reset), method: :put, + class: 'btn btn-link', form_class: 'inline-block') += render 'shared/cancel', link: idv_cancel_path diff --git a/app/views/idv/doc_auth/back_image.html.slim b/app/views/idv/doc_auth/back_image.html.slim new file mode 100644 index 00000000000..94c8b061798 --- /dev/null +++ b/app/views/idv/doc_auth/back_image.html.slim @@ -0,0 +1,17 @@ +-title t('doc_auth.titles.doc_auth') + +h1.h3 = t('doc_auth.headings.upload_back') + += simple_form_for(:doc_auth, url: idv_doc_auth_step_path(step: :back_image), method: 'PUT', + html: { autocomplete: 'off', role: 'form', class: 'mt2' }) do |f| + .clearfix.mxn1 + .sm-col.sm-col-9.px1 + = f.input :image, label: false, as: 'file', required: true + + p = flow_session[:error_message] + + .mt4 + button type='submit' class='btn btn-primary btn-wide sm-col-6 col-12' + = t('forms.buttons.continue') + += render 'start_over_or_cancel' diff --git a/app/views/idv/doc_auth/doc_failed.html.slim b/app/views/idv/doc_auth/doc_failed.html.slim new file mode 100644 index 00000000000..3ec7a90aefa --- /dev/null +++ b/app/views/idv/doc_auth/doc_failed.html.slim @@ -0,0 +1,22 @@ +-title t('doc_auth.titles.doc_auth') + +h1.h3.red = t('doc_auth.errors.state_id_fail') + += form_for('', url: idv_doc_auth_step_path(step: :doc_failed), method: 'PUT', + html: { autocomplete: 'off', role: 'form', class: 'mt2' }) do |f| + + - f.simple_fields_for :doc_auth do + + h4 = t('doc_auth.forms.state_id_info') + ul + li = "#{t('doc_auth.forms.first_name')}: #{flow_session[:matcher_pii_from_doc][:first_name]}" + li = "#{t('doc_auth.forms.last_name')}: #{flow_session[:matcher_pii_from_doc][:last_name]}" + li = "#{t('doc_auth.forms.dob')}: #{flow_session[:matcher_pii_from_doc][:dob]}" + h4 = t('doc_auth.forms.entered_info') + ul + li = "#{t('doc_auth.forms.ssn')}: #{flow_session[:ssn]}" + .mt4 + button type='submit' class='btn btn-primary btn-wide sm-col-6 col-12' + = t('forms.buttons.continue') + += render 'start_over_or_cancel' diff --git a/app/views/idv/doc_auth/doc_success.html.slim b/app/views/idv/doc_auth/doc_success.html.slim new file mode 100644 index 00000000000..7b69e5f8eb8 --- /dev/null +++ b/app/views/idv/doc_auth/doc_success.html.slim @@ -0,0 +1,20 @@ +-title t('doc_auth.titles.doc_auth') + += image_tag(asset_url('state-id-confirm@3x.png'), + alt: t('idv.titles.session.success'), width: 210) + +h1.h3.mb2.mt3.my0 = t('doc_auth.forms.doc_success') + +.col-2 + hr.mt3.mb3.bw4.border-green.rounded + +h2.h3.mb6.my0 = t('doc_auth.forms.selfie_next') + += form_for('', url: idv_doc_auth_step_path(step: :doc_success), method: 'PUT', + html: { autocomplete: 'off', role: 'form', class: 'mt2' }) do |f| + - f.simple_fields_for :doc_auth do + button type='submit' class='btn btn-primary btn-wide sm-col-6 col-12' + = t('forms.buttons.continue') + +.mt2.pt1.border-top + = link_to t('links.cancel'), idv_cancel_path, class: 'h5' diff --git a/app/views/idv/doc_auth/front_image.html.slim b/app/views/idv/doc_auth/front_image.html.slim new file mode 100644 index 00000000000..dc6e0280ad6 --- /dev/null +++ b/app/views/idv/doc_auth/front_image.html.slim @@ -0,0 +1,17 @@ +-title t('doc_auth.titles.doc_auth') + +h1.h3 = t('doc_auth.headings.upload_front') + += simple_form_for(:doc_auth, url: idv_doc_auth_step_path(step: :front_image), method: 'PUT', + html: { autocomplete: 'off', role: 'form', class: 'mt2' }) do |f| + .clearfix.mxn1 + .sm-col.sm-col-8.px1 + = f.input :image, label: false, as: 'file', required: true + + p = flow_session[:error_message] + + .mt0 + button type='submit' class='btn btn-primary btn-wide sm-col-6 col-6' + = t('forms.buttons.continue') + += render 'start_over_or_cancel' diff --git a/app/views/idv/doc_auth/self_image.html.slim b/app/views/idv/doc_auth/self_image.html.slim new file mode 100644 index 00000000000..c5bb83337d9 --- /dev/null +++ b/app/views/idv/doc_auth/self_image.html.slim @@ -0,0 +1,24 @@ +-title t('doc_auth.titles.doc_auth') + +h1.h3 = t('doc_auth.headings.selfie') + +video id='player' controls=true autoplay=true width=460 height=345 +canvas id='canvas' width=460 height=345 style='display: none;' +button id='capture' = t('doc_auth.buttons.capture') + += form_for('', url: idv_doc_auth_step_path(step: :self_image), method: 'PUT', + html: { autocomplete: 'off', role: 'form', class: 'mt2' }) do |f| + + - f.simple_fields_for :doc_auth do |ff| + + = ff.input :image, label: false, as: :hidden, required: true + + p = flow_session[:error_message] + + .mt4 + button type='submit' class='btn btn-primary btn-wide sm-col-6 col-12' + = t('forms.buttons.continue') + += render 'start_over_or_cancel' + +== javascript_pack_tag 'doc-auth' diff --git a/app/views/idv/doc_auth/ssn.html.slim b/app/views/idv/doc_auth/ssn.html.slim new file mode 100644 index 00000000000..8708f8be676 --- /dev/null +++ b/app/views/idv/doc_auth/ssn.html.slim @@ -0,0 +1,26 @@ +-title t('doc_auth.titles.doc_auth') + += image_tag(asset_url('state-id-none@3x.png'), + alt: t('idv.titles.jurisdiction'), width: 210) + +h1.h3 = t('doc_auth.headings.ssn') + += simple_form_for(:doc_auth, url: idv_doc_auth_step_path(step: :ssn), method: 'PUT', + html: { autocomplete: 'off', role: 'form', class: 'mt2' }) do |f| + .clearfix.mxn1 + .sm-col.sm-col-6.px1 + / using :tel for mobile numeric keypad + = f.input :ssn, as: :tel, + label: t('idv.form.ssn_label_html'), required: true, + pattern: '^\d{3}-?\d{2}-?\d{4}$', + input_html: { class: 'ssn', value: '' } + + p = flow_session[:error_message] + p = link_to t('idv.messages.jurisdiction.no_id'), idv_jurisdiction_failure_path(:no_id) + + .mt4 + button type='submit' class='btn btn-primary btn-wide sm-col-6 col-12' + = t('forms.buttons.continue') + +.mt2.pt1.border-top + = link_to t('links.cancel'), idv_cancel_path, class: 'h5' diff --git a/app/views/idv/otp_delivery_method/new.html.slim b/app/views/idv/otp_delivery_method/new.html.slim index 92eac66e68c..27f32504258 100644 --- a/app/views/idv/otp_delivery_method/new.html.slim +++ b/app/views/idv/otp_delivery_method/new.html.slim @@ -6,23 +6,24 @@ p.mt1 = t('idv.messages.otp_delivery_method.phone_number_html', fieldset.mb3.p0.border-none label.btn-border.col-12.mb1 .radio - = radio_button_tag 'otp_delivery_selection_form[otp_delivery_preference]', :sms, true, + = radio_button_tag 'otp_delivery_preference', + :sms, false, class: :otp_delivery_preference_sms span.indicator.mt-tiny span.blue.bold.fs-20p - = t('devise.two_factor_authentication.otp_delivery_preference.sms') + = t('two_factor_authentication.otp_delivery_preference.sms') .regular.gray-dark.fs-10p.mb-tiny - = t('devise.two_factor_authentication.two_factor_choice_options.sms_info') + = t('two_factor_authentication.two_factor_choice_options.sms_info') label.btn-border.col-12.mb0 .radio - = radio_button_tag 'otp_delivery_selection_form[otp_delivery_preference]', + = radio_button_tag 'otp_delivery_preference', :voice, false, class: :otp_delivery_preference_voice span.indicator.mt-tiny span.blue.bold.fs-20p - = t('devise.two_factor_authentication.otp_delivery_preference.voice') + = t('two_factor_authentication.otp_delivery_preference.voice') .regular.gray-dark.fs-10p.mb-tiny - = t('devise.two_factor_authentication.two_factor_choice_options.voice_info') + = t('two_factor_authentication.two_factor_choice_options.voice_info') - if FeatureManagement.enable_usps_verification? .mt3 = t('idv.form.no_alternate_phone_html', diff --git a/app/views/idv/otp_verification/show.html.slim b/app/views/idv/otp_verification/show.html.slim index 997a4007232..17c3e273c61 100644 --- a/app/views/idv/otp_verification/show.html.slim +++ b/app/views/idv/otp_verification/show.html.slim @@ -1,6 +1,6 @@ - title t('titles.enter_2fa_code') -h1.h3.my0 = t('devise.two_factor_authentication.header_text') +h1.h3.my0 = t('two_factor_authentication.header_text') p == @presenter.phone_number_message diff --git a/app/views/idv/phone/new.html.slim b/app/views/idv/phone/new.html.slim index 2308854d491..f13a0c3e24e 100644 --- a/app/views/idv/phone/new.html.slim +++ b/app/views/idv/phone/new.html.slim @@ -2,6 +2,8 @@ h1.h3.my0 = t('idv.titles.session.phone') +.py1.m0 = t('idv.messages.phone.description') + .mt2 == t('idv.messages.phone.alert') diff --git a/app/views/idv/review/new.html.slim b/app/views/idv/review/new.html.slim index 05791147ad3..49fbe80917d 100644 --- a/app/views/idv/review/new.html.slim +++ b/app/views/idv/review/new.html.slim @@ -6,9 +6,9 @@ h1.h3 = t('idv.titles.session.review') .mt4 = accordion('review-verified-info', t('idv.messages.review.intro')) do - - phone = @idv_params[:phone] + - phone = @applicant[:phone] - formatted_phone = PhoneFormatter.format(phone) - = render 'shared/pii_review', pii: @idv_params, phone: formatted_phone + = render 'shared/pii_review', pii: @applicant, phone: formatted_phone .mt2.pt1.border-top = link_to t('links.cancel'), idv_cancel_path, class: 'h5' diff --git a/app/views/idv/sessions/new.html.slim b/app/views/idv/sessions/new.html.slim index 174f18a49cf..87232f5ba95 100644 --- a/app/views/idv/sessions/new.html.slim +++ b/app/views/idv/sessions/new.html.slim @@ -46,8 +46,10 @@ p = link_to t('links.access_help'), 'aria-labelledby': 'profile_state_id_type_label' span.indicator .block = state_id_type[0] - = f.input :state_id_number, label: t('idv.form.state_id'), input_html: { class: 'sm-col-8' }, - required: true + = f.input :state_id_number, label: t('idv.form.state_id'), + required: true, + pattern: '^.{0,25}$', + input_html: { class: 'sm-col-8 state_id_number', value: @idv_form.state_id_number } = f.input :address1, label: t('idv.form.address1'), wrapper_html: { class: 'mb1' }, required: true = f.input :address2, label: t('idv.form.address2') diff --git a/app/views/partials/personal_key/_key.slim b/app/views/partials/personal_key/_key.slim index 97b252ddc49..ada75a33ae7 100644 --- a/app/views/partials/personal_key/_key.slim +++ b/app/views/partials/personal_key/_key.slim @@ -1,17 +1,11 @@ -#personal-key(class="col-12 border-box mt4 mb3 py2 px2 sm-px4 fs-20p sans-serif\ - border border-dashed border-red rounded-xl relative clearfix") - = image_tag asset_url('scissors.svg'), width: 24, - class: 'absolute ico-scissors' - p.bold.center.mt1 - = image_tag(asset_url('p-key.svg'), width: 36, class: 'align-middle mr1') +.bg-light-blue.bg-personal-key.pl4.pr4 + p.bold.center.mt1.pt1.pb1.fs-20p = t('users.personal_key.header') - .my4.px0.sm-px1.py2.center.border-box.border.border-teal.bw2.rounded-md.separator-text + .my4.px0.sm-px1.py2.center.bg-white.bw2.separator-text.bg-pk-box - code.split('-').each do |word| .inline.h3.bold.navy.monospace(data-personal-key='word') = word - .left.h5.mt2 + .center.h5.mt2.pb2.fs-13p = t('users.personal_key.generated_on_html', date: content_tag(:strong, I18n.l(Time.zone.today, format: '%B %d, %Y'))) - .right.mt1 - = image_tag asset_url('logo.svg'), width: 96, class: 'align-middle mt-tiny' diff --git a/app/views/shared/_personal_key.html.slim b/app/views/shared/_personal_key.html.slim index 32b4d73434f..8dba8f0d716 100644 --- a/app/views/shared/_personal_key.html.slim +++ b/app/views/shared/_personal_key.html.slim @@ -1,29 +1,33 @@ h1.h3.my0 = t('headings.personal_key') -p.mt-tiny.mb0 - = t('instructions.personal_key_html', - accent: content_tag(:strong, t('instructions.personal_key_accent'))) - -= render 'partials/personal_key/key', code: code - -.mb3.right-align - = button_to(t('users.personal_key.get_another'), create_new_personal_key_path(resend: true), - method: :post, - class: 'btn btn-link ml1 btn-border ico ico-refresh text-decoration-none', - form_class: 'inline-block') - - = link_to t('users.personal_key.print'), '#', - data: { print: true }, - class: 'ml2 btn-border ico ico-print text-decoration-none' - -= accordion('personal-key-info', t('users.personal_key.help_text_header')) do - = simple_format(t('users.personal_key.help_text')) - -div +p.mt-tiny.mb4 + = t('instructions.personal_key.info_html', + accent: content_tag(:strong, t('instructions.personal_key.accent'))) + +.full-width-box + = render 'partials/personal_key/key', code: code + +p.mt3 + .sm-col.sm-col-6 + .pr2.left-align.pl2 + = image_tag(asset_url('personal-key/warning.svg'), size: '60') + br + = t('instructions.personal_key.once_html') + .sm-col.sm-col-6 + .pl2 + = image_tag(asset_url('personal-key/lock-check.svg'), size: '60') + br + = t('instructions.personal_key.safety_html') + +.clearfix +br +.center = button_to t('forms.buttons.continue'), update_path, - class: 'btn btn-primary btn-wide mb1 personal-key-continue sm-col-6 col-12 mb3', + class: 'btn btn-primary btn-wide mb1 personal-key-continue sm-col-6 col-12 mb2', 'data-toggle': 'modal' + = link_to t('users.personal_key.print'), '#', data: { print: true } + = render 'shared/personal_key_confirmation_modal', code: code, update_path: update_path == javascript_pack_tag 'personal-key-page-controller' diff --git a/app/views/shared/refresh.html.slim b/app/views/shared/refresh.html.slim deleted file mode 100644 index 8bb1287a5e1..00000000000 --- a/app/views/shared/refresh.html.slim +++ /dev/null @@ -1,11 +0,0 @@ -- content_for(:meta_refresh) do - = Figaro.env.async_job_refresh_interval_seconds.to_s - -h2 = t('idv.messages.loading') - -.loading-spinner - img { src="#{image_path('spinner.gif')}" - srcset="#{image_path('spinner@2x.gif')} 2x" - height="144" - width="144" - alt="" } diff --git a/app/views/sign_up/completions/_show_sp.html.slim b/app/views/sign_up/completions/_show_sp.html.slim index 389411450ea..133b59e1e3f 100644 --- a/app/views/sign_up/completions/_show_sp.html.slim +++ b/app/views/sign_up/completions/_show_sp.html.slim @@ -3,10 +3,10 @@ p = button_to t('forms.buttons.continue'), sign_up_completed_path, \ class: 'btn btn-primary btn-wide' -p.mr5.ml5.mt3.mb3 +p.sm-mr5.sm-ml5.mt3.mb3 = t('help_text.requested_attributes.intro_html', app_name: APP_NAME, sp: content_tag(:strong, decorated_session.sp_agency)) -
+
= render @view_model.requested_attributes_partial, view_model: @view_model
diff --git a/app/views/two_factor_authentication/options/index.html.slim b/app/views/two_factor_authentication/options/index.html.slim index 2a10779a082..4ad5242d9af 100644 --- a/app/views/two_factor_authentication/options/index.html.slim +++ b/app/views/two_factor_authentication/options/index.html.slim @@ -10,12 +10,12 @@ p.mt-tiny.mb3 = @presenter.info .mb3 fieldset.m0.p0.border-none. legend.mb2.serif.bold = @presenter.label - - @presenter.options.each do |option| + - @presenter.options.each_with_index do |option, index| label.btn-border.col-12.mb2 for="two_factor_options_form_selection_#{option.type}" .radio = radio_button_tag('two_factor_options_form[selection]', option.type, - option.selected) + index.zero?) span.indicator.mt-tiny span.blue.bold.fs-20p = option.label .regular.gray-dark.fs-10p.mb-tiny = option.info diff --git a/app/views/two_factor_authentication/personal_key_verification/show.html.slim b/app/views/two_factor_authentication/personal_key_verification/show.html.slim index 7c372eacd9d..0957ac88e44 100644 --- a/app/views/two_factor_authentication/personal_key_verification/show.html.slim +++ b/app/views/two_factor_authentication/personal_key_verification/show.html.slim @@ -1,7 +1,7 @@ - title t('titles.enter_2fa_code') -h1.h3.my0 = t('devise.two_factor_authentication.personal_key_header_text') -p.mt-tiny.mb0 = t('devise.two_factor_authentication.personal_key_prompt') +h1.h3.my0 = t('two_factor_authentication.personal_key_header_text') +p.mt-tiny.mb0 = t('two_factor_authentication.personal_key_prompt') = simple_form_for(@personal_key_form, url: login_two_factor_personal_key_path, html: { autocomplete: 'off', method: :post, role: 'form' }) do |f| diff --git a/app/views/two_factor_authentication/webauthn_verification/show.html.slim b/app/views/two_factor_authentication/webauthn_verification/show.html.slim new file mode 100644 index 00000000000..f86e3ccc92c --- /dev/null +++ b/app/views/two_factor_authentication/webauthn_verification/show.html.slim @@ -0,0 +1,39 @@ +- title t('titles.present_webauthn') + +h1.h3.my0 = t('two_factor_authentication.webauthn_header_text') +.no-spinner + p.mt-tiny.mb3 = t('instructions.mfa.webauthn.confirm_webauthn_html') + +ul.list-reset + li.py2.border-top + .mr1.inline-block.circle.circle-number.bg-blue.white + | 1 + .inline.bold = t('forms.webauthn_setup.step_1') + li.py2.border-top + .mb2 + .mr1.inline-block.circle.circle-number.bg-blue.white + | 2 + .inline.bold = t('forms.webauthn_setup.step_2') + .sm-col-9.sm-ml-28p + = form_tag(login_two_factor_webauthn_path, method: :patch, role: 'form', class: 'mb1', + id: 'webauthn_form') do + = hidden_field_tag :user_challenge, + '[' + user_session[:webauthn_challenge].split.join(',') + ']', id: 'user_challenge' + = hidden_field_tag :credential_ids, @presenter.credential_ids, id: 'credential_ids' + = hidden_field_tag :credential_id, '', id: 'credential_id' + = hidden_field_tag :authenticator_data, '', id: 'authenticator_data' + = hidden_field_tag :signature, '', id: 'signature' + = hidden_field_tag :client_data_json, '', id: 'client_data_json' +.spinner[id='spinner'] + div + = image_tag(asset_url('spinner.gif'), + srcset: asset_url('spinner@2x.gif'), + height: 144, + width: 144, + alt: '') + += render 'shared/fallback_links', presenter: @presenter += render 'shared/cancel', link: @presenter.cancel_link + +== javascript_pack_tag 'clipboard' +== javascript_pack_tag 'webauthn-authenticate' diff --git a/app/views/users/phone_setup/index.html.slim b/app/views/users/phone_setup/index.html.slim index 6bb605abeb4..dd963b3f50b 100644 --- a/app/views/users/phone_setup/index.html.slim +++ b/app/views/users/phone_setup/index.html.slim @@ -25,7 +25,12 @@ p.mt-tiny.mb0 = @presenter.info = f.button :submit, t('forms.buttons.send_security_code'), class: 'sm-col-6 col-12 btn-wide mb3 no-auto-enable' .mt2.pt1.border-top - - path = current_user.piv_cac_enabled? ? account_recovery_setup_path : two_factor_options_path + ruby: + path = if TwoFactorAuthentication::PivCacPolicy.new(current_user).enabled? + account_recovery_setup_path + else + two_factor_options_path + end = link_to t('two_factor_authentication.choose_another_option'), path = stylesheet_link_tag 'intl-tel-number/intlTelInput' diff --git a/app/views/users/shared/_otp_delivery_preference_selection.html.slim b/app/views/users/shared/_otp_delivery_preference_selection.html.slim index ca75710a428..5f2da83e73e 100644 --- a/app/views/users/shared/_otp_delivery_preference_selection.html.slim +++ b/app/views/users/shared/_otp_delivery_preference_selection.html.slim @@ -1,19 +1,19 @@ .mb3 fieldset.m0.p0.border-none - legend.mb1.h4.serif.bold = t('devise.two_factor_authentication.otp_delivery_preference.title') + legend.mb1.h4.serif.bold = t('two_factor_authentication.otp_delivery_preference.title') p#otp_delivery_preference_instruction.mt0.mb2 - = t('devise.two_factor_authentication.otp_delivery_preference.instruction') + = t('two_factor_authentication.otp_delivery_preference.instruction') label.btn-border.col-12.sm-col-5.sm-mr2.mb2.sm-mb0 .radio = radio_button_tag 'user_phone_form[otp_delivery_preference]', :sms, true, class: :otp_delivery_preference_sms span.indicator - = t('devise.two_factor_authentication.otp_delivery_preference.sms') + = t('two_factor_authentication.otp_delivery_preference.sms') label.btn-border.col-12.sm-col-5.mb0 .radio = radio_button_tag 'user_phone_form[otp_delivery_preference]', :voice, false, class: :otp_delivery_preference_voice span.indicator - = t('devise.two_factor_authentication.otp_delivery_preference.voice') + = t('two_factor_authentication.otp_delivery_preference.voice') p.mb0.mt1 = link_to t('links.two_factor_authentication.app_option'), authenticator_setup_path diff --git a/config/application.yml.example b/config/application.yml.example index ac959f5d3cd..5d25bf7f763 100644 --- a/config/application.yml.example +++ b/config/application.yml.example @@ -41,7 +41,7 @@ queue_health_check_dead_interval_seconds: '60' queue_health_check_frequency_seconds: '30' # Configuration to probabilistically select the config.active_job.queue_adapter -# Currently known options are: sidekiq, async, inline +# Currently known options are: async, inline queue_adapter_weights: '{"inline": 1}' # The number of words in the personal key phrase @@ -78,6 +78,12 @@ development: aamva_public_key: '123abc' aamva_private_key: '123abc' aamva_verification_url: 'https://example.org:12345/verification/url' + acuant_assure_id_subscription_id: '' + acuant_assure_id_username: '' + acuant_assure_id_password: '' + acuant_assure_id_url: '' + acuant_facial_match_license_key: '' + acuant_facial_match_url: '' account_reset_auth_token: 'abc123' account_reset_enabled: 'true' account_reset_token_valid_for_days: '1' @@ -103,13 +109,14 @@ development: database_name: '' database_password: '' database_pool_idp: '5' - database_pool_worker: '5' database_readonly_password: '' database_readonly_username: '' database_statement_timeout: '2500' database_timeout: '5000' database_username: '' disallow_all_web_crawlers: 'true' + doc_auth_enabled: 'false' + doc_auth_exclusive: 'false' domain_name: 'localhost:3000' enable_identity_verification: 'true' enable_rate_limiting: 'false' @@ -144,7 +151,6 @@ development: password_strength_enabled: 'true' piv_cac_agencies: '["Test Government Agency"]' piv_cac_email_domains: '[".mil"]' - piv_cac_enabled: 'true' piv_cac_verify_token_secret: 'ee7f20f44cdc2ba0c6830f70470d1d1d059e1279cdb58134db92b35947b1528ef5525ece5910cf4f2321ab989a618feea12ef95711dbc62b9601e8520a34ee12' piv_cac_service_url: 'https://localhost:8443/' piv_cac_verify_token_url: 'https://localhost:8443/' @@ -206,6 +212,12 @@ production: account_reset_enabled: 'true' account_reset_token_valid_for_days: '1' account_reset_wait_period_days: '1' + acuant_assure_id_subscription_id: '' + acuant_assure_id_username: '' + acuant_assure_id_password: '' + acuant_assure_id_url: '' + acuant_facial_match_license_key: '' + acuant_facial_match_url: '' async_job_refresh_interval_seconds: '5' async_job_refresh_max_wait_seconds: '15' attribute_cost: '4000$8$4$' # SCrypt::Engine.calibrate(max_time: 0.5) @@ -225,6 +237,8 @@ production: dashboard_api_token: database_statement_timeout: '2500' disallow_all_web_crawlers: 'false' + doc_auth_enabled: 'false' + doc_auth_exclusive: 'false' domain_name: 'login.gov' enable_identity_verification: 'false' enable_rate_limiting: 'true' @@ -262,7 +276,6 @@ production: piv_cac_agencies: '["DOD","NGA","EOP"]' piv_cac_agencies_scoped_by_email: '["GSA"]' piv_cac_email_domains: '[".mil"]' - piv_cac_enabled: 'false' pkcs11_lib: '/opt/cloudhsm/lib/libcloudhsm_pkcs11.so' programmable_sms_countries: 'US,CA,MX' proofer_mock_fallback: 'true' @@ -304,7 +317,7 @@ production: usps_upload_sftp_username: usps_upload_sftp_password: usps_upload_token: - webauthn_enabled: 'false' + webauthn_enabled: 'true' test: aamva_cert_enabled: 'true' @@ -315,6 +328,12 @@ test: account_reset_enabled: 'true' account_reset_token_valid_for_days: '1' account_reset_wait_period_days: '1' + acuant_assure_id_subscription_id: '' + acuant_assure_id_username: '' + acuant_assure_id_password: '' + acuant_assure_id_url: 'https://example.com' + acuant_facial_match_license_key: '' + acuant_facial_match_url: 'https://example.com' async_job_refresh_interval_seconds: '1' async_job_refresh_max_wait_seconds: '15' attribute_cost: '800$8$1$' # SCrypt::Engine.calibrate(max_time: 0.01) @@ -335,7 +354,6 @@ test: database_name: '' database_password: '' database_pool_idp: - database_pool_worker: database_readonly_password: '' database_readonly_username: '' database_statement_timeout: '2500' @@ -343,6 +361,8 @@ test: database_username: '' dashboard_api_token: '123ABC' disallow_all_web_crawlers: 'true' + doc_auth_enabled: 'true' + doc_auth_exclusive: 'false' enable_identity_verification: 'true' enable_rate_limiting: 'true' enable_test_routes: 'true' @@ -374,7 +394,6 @@ test: password_strength_enabled: 'false' piv_cac_agencies: '["Test Government Agency"]' piv_cac_email_domains: '[".mil"]' - piv_cac_enabled: 'true' piv_cac_service_url: 'https://localhost:8443/' piv_cac_verify_token_secret: '3ac13bfa23e22adae321194c083e783faf89469f6f85dcc0802b27475c94b5c3891b5657bd87d0c1ad65de459166440512f2311018db90d57b15d8ab6660748f' piv_cac_verify_token_url: 'https://localhost:8443/' diff --git a/config/environments/test.rb b/config/environments/test.rb index 9f56c86ad2a..c6a14524e0a 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -37,6 +37,9 @@ Bullet.add_whitelist( type: :n_plus_one_query, class_name: 'User', association: :phone_configurations ) + Bullet.add_whitelist( + type: :n_plus_one_query, class_name: 'User', association: :email_address + ) end config.active_support.test_order = :random diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index 0f56c8f2fa3..39925884b5d 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -96,21 +96,21 @@ ignore_unused: - 'devise.mailer.confirmation_instructions.subject' - 'devise.mailer.reset_password_instructions.subject' - 'devise.sessions.signed_in' - - 'devise.two_factor_authentication.invalid_otp' - - 'devise.two_factor_authentication.invalid_personal_key' - - 'devise.two_factor_authentication.invalid_piv_cac' - - 'devise.two_factor_authentication.max_generic_login_attempts_reached' - - 'devise.two_factor_authentication.max_otp_login_attempts_reached' - - 'devise.two_factor_authentication.max_otp_requests_reached' - - 'devise.two_factor_authentication.max_personal_key_login_attempts_reached' - - 'devise.two_factor_authentication.max_piv_cac_login_attempts_reached' - - 'devise.two_factor_authentication.phone_sms_info_html' - - 'devise.two_factor_authentication.phone_sms_label' - - 'devise.two_factor_authentication.phone_voice_info_html' - - 'devise.two_factor_authentication.phone_voice_label' - - 'devise.two_factor_authentication.please_try_again_html' - - 'devise.two_factor_authentication.read_about_two_factor_authentication.link' - - 'devise.two_factor_authentication.read_about_two_factor_authentication.text_html' + - 'two_factor_authentication.invalid_otp' + - 'two_factor_authentication.invalid_personal_key' + - 'two_factor_authentication.invalid_piv_cac' + - 'two_factor_authentication.max_generic_login_attempts_reached' + - 'two_factor_authentication.max_otp_login_attempts_reached' + - 'two_factor_authentication.max_otp_requests_reached' + - 'two_factor_authentication.max_personal_key_login_attempts_reached' + - 'two_factor_authentication.max_piv_cac_login_attempts_reached' + - 'two_factor_authentication.phone_sms_info_html' + - 'two_factor_authentication.phone_sms_label' + - 'two_factor_authentication.phone_voice_info_html' + - 'two_factor_authentication.phone_voice_label' + - 'two_factor_authentication.please_try_again_html' + - 'two_factor_authentication.read_about_two_factor_authentication.link' + - 'two_factor_authentication.read_about_two_factor_authentication.text_html' - 'errors.messages.*' - 'forms.piv_cac_setup.*' - 'headings.piv_cac_setup.*' diff --git a/config/initializers/exception_notification.rb b/config/initializers/exception_notification.rb index eaf7359ea2a..940eb6af3de 100644 --- a/config/initializers/exception_notification.rb +++ b/config/initializers/exception_notification.rb @@ -1,5 +1,4 @@ require 'exception_notification/rails' -require 'exception_notification/sidekiq' EXCEPTION_RECIPIENTS = Figaro.env.exception_recipients.split(',').freeze diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb deleted file mode 100644 index 63d4cce47ef..00000000000 --- a/config/initializers/sidekiq.rb +++ /dev/null @@ -1,28 +0,0 @@ -require 'encrypted_sidekiq_redis' -require 'sidekiq_logger_formatter' -require 'worker_health_checker' -require 'no_retry_jobs' - -Sidekiq::Logging.logger.level = Logger::INFO -Sidekiq::Logging.logger.formatter = SidekiqLoggerFormatter.new - -redis_connection = proc do - EncryptedSidekiqRedis.new(url: Figaro.env.redis_url) -end - -size = (Sidekiq.server? ? (Sidekiq.options[:concurrency] + 2) : 5) - -Sidekiq.configure_server do |config| - config.redis = ConnectionPool.new(size: size, &redis_connection) - - # NOTE: Sidekiq does not run middleware in tests by default. Make sure to also add - # middleware to spec/rails_helper.rb to run in tests as well - config.server_middleware do |chain| - chain.add WorkerHealthChecker::Middleware - chain.add NoRetryJobs - end -end - -Sidekiq.configure_client do |config| - config.redis = ConnectionPool.new(size: size, &redis_connection) -end diff --git a/config/locales/account/en.yml b/config/locales/account/en.yml index 17476875454..c506e0082d0 100644 --- a/config/locales/account/en.yml +++ b/config/locales/account/en.yml @@ -14,6 +14,7 @@ en: login: Login Information password: Password phone: Phone number + phone_add: "+ Add phone" piv_cac_card: PIV/CAC Card piv_cac_disabled: not enabled piv_cac_enabled: enabled diff --git a/config/locales/account/es.yml b/config/locales/account/es.yml index b32a03c4e7d..c5ef747f60e 100644 --- a/config/locales/account/es.yml +++ b/config/locales/account/es.yml @@ -14,6 +14,7 @@ es: login: Información para iniciar sesión password: Contraseña phone: Teléfono + phone_add: "+ Añadir teléfono" piv_cac_card: Tarjeta PIV/CAC piv_cac_disabled: desactivada piv_cac_enabled: activada diff --git a/config/locales/account/fr.yml b/config/locales/account/fr.yml index 1212575cb64..f45a81afe0d 100644 --- a/config/locales/account/fr.yml +++ b/config/locales/account/fr.yml @@ -14,6 +14,7 @@ fr: login: Information de connexion password: Mot de passe phone: Numéro de téléphone + phone_add: "+ Ajouter un téléphone" piv_cac_card: Carte PIV/CAC piv_cac_disabled: désactivée piv_cac_enabled: activée @@ -27,8 +28,8 @@ fr: instructions: Votre compte requiert la vérification d'un code secret. reactivate_button: Entrez le code que vous avez reçu par la poste success: Votre compte a été vérifié. - webauthn: Clé de sécurité matérielle - webauthn_add: "+ Ajouter une clé de sécurité matérielle" + webauthn: Clé de sécurité physique + webauthn_add: "+ Ajouter une clé de sécurité physique" webauthn_delete: Retirer items: delete_your_account: Supprimer votre compte diff --git a/config/locales/account_reset/en.yml b/config/locales/account_reset/en.yml index c0af8a61f3c..aa5ca0313a3 100644 --- a/config/locales/account_reset/en.yml +++ b/config/locales/account_reset/en.yml @@ -1,10 +1,15 @@ --- en: account_reset: + cancel_request: + are_you_sure: Are you sure you want to cancel your delete account request? + cancel: Exit + cancel_button: Cancel delete account + title: Cancel delete account confirm_delete_account: + cta: You may %{link} or close this window if you're done. info: The account for %{email} has been deleted. We sent an - email confirmation of the account deletion.

You may %{link} or close - this window if you're done. + email confirmation of the account deletion. link_text: create a new account title: You have deleted your account confirm_request: @@ -20,7 +25,7 @@ en: delete_button: Delete account info: Deleting your account should be your last resort if you are locked out of your account. You will not be able to recover any information linked to - your account. Once your account is deleted, you can create a new one using + your account. Once your account is deleted, you can create a new one using the same email address. title: Deleting your account should be your last resort request: diff --git a/config/locales/account_reset/es.yml b/config/locales/account_reset/es.yml index e4344e0de0c..ec756dff785 100644 --- a/config/locales/account_reset/es.yml +++ b/config/locales/account_reset/es.yml @@ -1,10 +1,15 @@ --- es: account_reset: + cancel_request: + are_you_sure: "¿Seguro que quieres cancelar tu solicitud de eliminación de cuenta?" + cancel: Salida + cancel_button: Cancelar la cuenta eliminada + title: Cancelar la cuenta eliminada confirm_delete_account: + cta: Puede %{link} o cierra esta ventana si ya terminaste. info: La cuenta para %{email} ha sido eliminada. Nosotros enviamos - una confirmación por correo electrónico de la eliminación de la cuenta.

- Puede %{link} o cierra esta ventana si ya terminaste. + una confirmación por correo electrónico de la eliminación de la cuenta. link_text: crea una cuenta nueva title: Has eliminado tu cuenta confirm_request: diff --git a/config/locales/account_reset/fr.yml b/config/locales/account_reset/fr.yml index 089b9e588df..bbd0758b6d5 100644 --- a/config/locales/account_reset/fr.yml +++ b/config/locales/account_reset/fr.yml @@ -1,10 +1,16 @@ --- fr: account_reset: + cancel_request: + are_you_sure: Êtes-vous sûr de vouloir annuler votre demande de suppression + de compte? + cancel: Sortie + cancel_button: Annuler supprimer un compte + title: Annuler supprimer un compte confirm_delete_account: + cta: Vous pouvez %{link} ou fermer cette fenêtre si vous avez terminé. info: Le compte pour %{email} a été supprimé. Nous avons envoyé - un email de confirmation de la suppression du compte.

Vous pouvez - %{link} ou fermer cette fenêtre si vous avez terminé. + un email de confirmation de la suppression du compte. link_text: créer un nouveau compte title: Vous avez supprimé votre compte confirm_request: diff --git a/config/locales/devise/en.yml b/config/locales/devise/en.yml index 2ed967d24f9..c58594198d7 100644 --- a/config/locales/devise/en.yml +++ b/config/locales/devise/en.yml @@ -69,70 +69,3 @@ en: sessions: signed_in: '' signed_out: You are now signed out. - two_factor_authentication: - account_reset: - cancel_link: Cancel your request - link: deleting your account - pending_html: You currently have a pending request to delete your account. - It takes 24 hours from the time you made the request to complete the process. - Please check back later. %{cancel_link} - successful_cancel: Thank you. Your request to delete your login.gov account - has been cancelled. - text_html: If you can't use any of these security options above, you can reset - your preferences by %{link}. - header_text: Enter your security code - invalid_otp: That security code is invalid. You can try entering it again or - request a new one-time security code. - invalid_personal_key: That personal key is invalid. - invalid_piv_cac: That PIV/CAC is incorrect. - max_generic_login_attempts_reached: For your security, your account is temporarily - locked. - max_otp_login_attempts_reached: For your security, your account is temporarily - locked because you have entered the one-time security code incorrectly too - many times. - max_otp_requests_reached: For your security, your account is temporarily locked - because you have requested a security code too many times. - max_personal_key_login_attempts_reached: For your security, your account is - temporarily locked because you have entered the personal key incorrectly too - many times. - max_piv_cac_login_attempts_reached: For your security, your account is temporarily - locked because you have presented your piv/cac credential incorrectly too - many times. - otp_delivery_preference: - instruction: You can change this selection the next time you log in. If you - entered a landline, please select "Phone call" below. - phone_unsupported: We're unable to make phone calls to people in %{location} - at this time. - sms: Text message (SMS) - title: How should we send you a code? - voice: Phone call - personal_key_header_text: Enter your personal key - personal_key_prompt: You can use this personal key once. After you enter it, - you'll be provided a new key. - phone_sms_info_html: We'll text a security code each time you sign in. - phone_sms_label: Mobile phone number - phone_voice_info_html: We'll call you with a security code each time - you sign in. - phone_voice_label: Phone number - piv_cac_fallback: - link: Use your PIV/CAC instead - text_html: Do you have your PIV/CAC? %{link} - piv_cac_header_text: Present your PIV/CAC - please_try_again_html: Please try again in %{time_remaining}. - read_about_two_factor_authentication: - link: read about two-factor authentication - text_html: You can %{link} and why we use it at our Help page. - totp_header_text: Enter your authentication app code - two_factor_choice: Secure your account - two_factor_choice_intro: login.gov makes sure you can access your account by - adding a second layer of security. - two_factor_choice_options: - auth_app: Authentication application - auth_app_info: Set up an authentication application to get your security code - without providing a phone number - piv_cac: Government employees - piv_cac_info: Use your PIV/CAC card to secure your account - sms: Text message / SMS - sms_info: Get your security code via text message / SMS - voice: Phone call - voice_info: Get your security code via phone call diff --git a/config/locales/devise/es.yml b/config/locales/devise/es.yml index 005161c8b43..07348c31d70 100644 --- a/config/locales/devise/es.yml +++ b/config/locales/devise/es.yml @@ -71,67 +71,3 @@ es: sessions: signed_in: '' signed_out: Su sesión ha terminado ahora. - two_factor_authentication: - account_reset: - cancel_link: Cancelar su solicitud - link: eliminando su cuenta - pending_html: Actualmente tiene una solicitud pendiente para eliminar su cuenta. - Se necesitan 24 horas desde el momento en que realizó la solicitud para - completar el proceso. Por favor, vuelva más tarde. %{cancel_link} - successful_cancel: Gracias. Su solicitud para eliminar su cuenta de login.gov - ha sido cancelada. - text_html: Si no puede usar ninguna de estas opciones de seguridad anteriores, - puede restablecer tus preferencias por %{link}. - header_text: Ingrese su código de seguridad - invalid_otp: Ese código de seguridad no es válido. Puede intentar ingresarlo - de nuevo o solicitar un nuevo código de seguridad de sólo un uso. - invalid_personal_key: Esa clave personal no es válida. - invalid_piv_cac: NOT TRANSLATED YET - max_generic_login_attempts_reached: Para su seguridad, su cuenta está bloqueada - temporalmente. - max_otp_login_attempts_reached: Para su seguridad, su cuenta ha sido bloqueada - temporalmente porque ha ingresado incorrectamente el código de seguridad de - sólo un uso demasiadas veces. - max_otp_requests_reached: Para su seguridad, su cuenta ha sido bloqueada temporalmente - porque ha solicitado un código de seguridad demasiadas veces más de lo permitido. - max_personal_key_login_attempts_reached: Para su seguridad, su cuenta ha sido - bloqueada temporalmente porque ha ingresado incorrectamente la clave personal - demasiadas veces. - max_piv_cac_login_attempts_reached: NOT TRANSLATED YET - otp_delivery_preference: - instruction: Puede cambiar esta selección la próxima vez que inicie sesión. - phone_unsupported: NOT TRANSLATED YET - sms: Mensaje de texto (SMS, sigla en inglés) - title: "¿Cómo deberíamos enviarle un código?" - voice: Llamada telefónica - personal_key_header_text: Ingrese su clave personal - personal_key_prompt: Puede usar esta clave personal una vez. Después de ingresarlo, - se le dará una nueva clave. - phone_sms_info_html: Le enviaremos un mensaje de texto con un código de seguridad - cada vez que inicie sesión. - phone_sms_label: Número de teléfono móvil - phone_voice_info_html: Te llamaremos con un código de seguridad cada - vez que inicies sesión. - phone_voice_label: Número de teléfono - piv_cac_fallback: - link: Use su PIV/CAC en su lugar - text_html: "¿Tiene usted PIV/CAC? %{link}" - piv_cac_header_text: NOT TRANSLATED YET - please_try_again_html: Inténtelo de nuevo en %{time_remaining}. - read_about_two_factor_authentication: - link: leer acerca de la autenticación de dos factores - text_html: Puede %{link} y por qué la utilizamos en nuestra página de Ayuda. - totp_header_text: Ingrese su código de la app de autenticación - two_factor_choice: Asegure su cuenta - two_factor_choice_intro: login.gov se asegura de que pueda acceder a su cuenta - agregando una segunda capa de seguridad. - two_factor_choice_options: - auth_app: Aplicación de autenticación - auth_app_info: Configure una aplicación de autenticación para obtener su código - de seguridad sin proporcionar un número de teléfono - piv_cac: Empleados del Gobierno - piv_cac_info: Use su tarjeta PIV / CAC para asegurar su cuenta - sms: Mensaje de texto / SMS - sms_info: Obtenga su código de seguridad a través de mensajes de texto / SMS - voice: Llamada telefónica - voice_info: Obtenga su código de seguridad a través de una llamada telefónica diff --git a/config/locales/devise/fr.yml b/config/locales/devise/fr.yml index 6e186b1d2eb..c67096c17f5 100644 --- a/config/locales/devise/fr.yml +++ b/config/locales/devise/fr.yml @@ -77,70 +77,3 @@ fr: sessions: signed_in: '' signed_out: Vous êtes maintenant déconnecté(e). - two_factor_authentication: - account_reset: - cancel_link: Annuler votre demande - link: supprimer votre compte - pending_html: Vous avez actuellement une demande en attente pour supprimer - votre compte. Il faut compter 24 heures à partir du moment où vous avez - fait la demande pour terminer le processus. Veuillez vérifier plus tard. - %{cancel_link} - successful_cancel: Je vous remercie. Votre demande de suppression de votre - compte login.gov a été annulée. - text_html: Si vous ne pouvez pas utiliser l'une de ces options de sécurité - ci-dessus, vous pouvez réinitialiser vos préférences par %{link}. - header_text: Entrez votre code de sécurité - invalid_otp: Ce code de sécurité est non valide. Vous pouvez essayer de l'entrer - de nouveau ou demander un nouveau code de sécurité à utilisation unique. - invalid_personal_key: Cette clé personnelle est non valide. - invalid_piv_cac: NOT TRANSLATED YET - max_generic_login_attempts_reached: Pour votre sécurité, votre compte est temporairement - verrouillé. - max_otp_login_attempts_reached: Pour votre sécurité, votre compte est temporairement - verrouillé, car vous avez entré le code de sécurité à utilisation unique de - façon erronée à de trop nombreuses reprises. - max_otp_requests_reached: Pour votre sécurité, votre compte est temporairement - verrouillé car vous avez demandé un code de sécurité à trop de reprises. - max_personal_key_login_attempts_reached: Pour votre sécurité, votre compte est - temporairement verrouillé, car vous avez entré le code de sécurité à utilisation - unique de façon erronée à de trop nombreuses reprises. - max_piv_cac_login_attempts_reached: NOT TRANSLATED YET - otp_delivery_preference: - instruction: Vous pouvez changer cette sélection la prochaine fois que vous - vous connectez. - phone_unsupported: NOT TRANSLATED YET - sms: Message texte (SMS) - title: Comment devrions-nous vous envoyer un code? - voice: Appel téléphonique - personal_key_header_text: Entrez votre clé personnelle - personal_key_prompt: Vous pouvez utiliser cette clé personnelle une fois seulement. - Une fois que vous l'entrez, vous recevrez une nouvelle clé. - phone_sms_info_html: Nous vous enverrons un code de sécurité chaque - fois que vous vous connectez. - phone_sms_label: Numéro de téléphone portable - phone_voice_info_html: Nous vous appellerons avec un code de sécurité chaque - fois que vous vous connectez. - phone_voice_label: Numéro de téléphone - piv_cac_fallback: - link: Utilisez plutôt votre PIV/CAC - text_html: Avez-vous votre PIV/CAC? %{link} - piv_cac_header_text: NOT TRANSLATED YET - please_try_again_html: Veuillez essayer de nouveau dans %{time_remaining}. - read_about_two_factor_authentication: - link: lire sur l'authentification à deux facteurs - text_html: Vous pouvez %{link} et pourquoi nous l'utilisons sur notre page - d'aide. - totp_header_text: Entrez votre code d'application d'authentification - two_factor_choice: Sécurise ton compte - two_factor_choice_intro: login.gov s'assure que vous pouvez accéder à votre - compte en ajoutant une deuxième couche de sécurité. - two_factor_choice_options: - auth_app: Application d'authentification - auth_app_info: Configurez une application d'authentification pour obtenir - votre code de sécurité sans fournir de numéro de téléphone - piv_cac: Employés du gouvernement - piv_cac_info: Utilisez votre carte PIV / CAC pour sécuriser votre compte - sms: SMS - sms_info: Obtenez votre code de sécurité par SMS - voice: Appel téléphonique - voice_info: Obtenez votre code de sécurité par appel téléphonique diff --git a/config/locales/doc_auth/en.yml b/config/locales/doc_auth/en.yml new file mode 100644 index 00000000000..f042646b799 --- /dev/null +++ b/config/locales/doc_auth/en.yml @@ -0,0 +1,27 @@ +--- +en: + doc_auth: + buttons: + capture: Capture + start_over: Start over + errors: + selfie: Sorry, we are unable to match your picture, please try again. + state_id_fail: Sorry. Information from your uploaded state-issued ID does not + match information for your social security number. + forms: + dob: Date of Birth + doc_success: We've verified your social security number and state-issued ID. + entered_info: 'Information you entered:' + first_name: First Name + last_name: Last Name + selfie_next: Next, we'll need to take your picture. + ssn: Social Security Number + state_id_info: 'Information from your uploaded state-issued ID:' + headings: + selfie: Take a selfie! + ssn: To verify your identity, you'll need your social security number and state-issued + ID. + upload_back: Please upload a photo of the back of your state-issued ID. + upload_front: Please upload a photo of the front of your state-issued ID. + titles: + doc_auth: Document Authentication diff --git a/config/locales/doc_auth/es.yml b/config/locales/doc_auth/es.yml new file mode 100644 index 00000000000..e25000ca40d --- /dev/null +++ b/config/locales/doc_auth/es.yml @@ -0,0 +1,29 @@ +--- +es: + doc_auth: + buttons: + capture: Capturar + start_over: Comenzar de nuevo + errors: + selfie: Lo sentimos, no podemos hacer coincidir su imagen, intente de nuevo. + state_id_fail: Lo siento. La información de su ID emitida por el estado no coincide + con la información de su número de seguro social. + forms: + dob: Fecha de nacimiento + doc_success: Verificamos su número de seguro social y su identificación emitida + por el estado. + entered_info: 'Información que ingresó:' + first_name: Nombre de pila + last_name: Apellido + selfie_next: A continuación, necesitaremos tomar su foto. + ssn: Número de seguridad social + state_id_info: 'Información de su ID emitida por el estado:' + headings: + selfie: "¡Toma una selfie!" + ssn: Para verificar su identidad, necesitará su número de seguro social y su + identificación emitida por el estado. + upload_back: Cargue una foto del dorso de su identificación emitida por el estado. + upload_front: Cargue una foto del frente de su identificación emitida por el + estado. + titles: + doc_auth: Autenticación de documentos diff --git a/config/locales/doc_auth/fr.yml b/config/locales/doc_auth/fr.yml new file mode 100644 index 00000000000..dae08a2b117 --- /dev/null +++ b/config/locales/doc_auth/fr.yml @@ -0,0 +1,31 @@ +--- +fr: + doc_auth: + buttons: + capture: Capturer + start_over: Recommencer + errors: + selfie: Désolé, nous ne pouvons pas correspondre à votre photo, veuillez réessayer. + state_id_fail: Pardon. Les informations de votre identifiant émis par l'état + téléchargé ne correspondent pas aux informations de votre numéro de sécurité + sociale. + forms: + dob: Date de naissance + doc_success: Nous avons vérifié votre numéro de sécurité sociale et votre identifiant + délivré par l'État. + entered_info: 'Informations que vous avez entrées:' + first_name: Prénom + last_name: Nom de famille + selfie_next: Ensuite, nous devrons prendre votre photo. + ssn: Numéro de sécurité sociale + state_id_info: 'Information from your uploaded state-issued ID:' + headings: + selfie: Prendre un selfie! + ssn: Pour vérifier votre identité, vous aurez besoin de votre numéro de sécurité + sociale et de votre identifiant délivré par l'État. + upload_back: S'il vous plaît télécharger une photo du dos de votre ID émis par + l'état. + upload_front: Veuillez télécharger une photo du recto de votre identifiant émis + par l'État. + titles: + doc_auth: Authentification de document diff --git a/config/locales/errors/en.yml b/config/locales/errors/en.yml index aab2f394d27..6a7b966143d 100644 --- a/config/locales/errors/en.yml +++ b/config/locales/errors/en.yml @@ -27,8 +27,8 @@ en: format_mismatch: Please match the requested format. improbable_phone: Invalid phone number. Please make sure you enter a valid phone number. - invalid_calling_area: Calls to that phone number are not supported. Please - try SMS if you have an SMS-capable phone. + invalid_calling_area: Calls to that phone number are not supported. Please try + SMS if you have an SMS-capable phone. invalid_phone_number: The phone number entered is not valid. invalid_sms_number: The phone number entered doesn't support text messaging. Try the Phone call option. @@ -53,6 +53,6 @@ en: weak_password: Your password is not strong enough. %{feedback} webauthn_setup: delete_last: Sorry, you can not remove your last MFA option. - general_error: There was an error adding your hardward security key. Please + general_error: There was an error adding your hardware security key. Please try again. - unique_name: That name is already taken. Please choose a different name. + unique_name: That name is already taken. Please choose a different name. diff --git a/config/locales/errors/fr.yml b/config/locales/errors/fr.yml index 5de9f9da324..377194462f9 100644 --- a/config/locales/errors/fr.yml +++ b/config/locales/errors/fr.yml @@ -51,5 +51,5 @@ fr: webauthn_setup: delete_last: Désolé, vous ne pouvez pas supprimer votre dernière option MFA general_error: Une erreur s'est produite lors de l'ajout de votre clé de sécurité - matérielle. Veuillez réessayer. + physique. Veuillez réessayer. unique_name: Ce nom est déjà pris. Veuillez choisir un autre nom. diff --git a/config/locales/forms/fr.yml b/config/locales/forms/fr.yml index 2e55658e0c5..d7bbbe966dd 100644 --- a/config/locales/forms/fr.yml +++ b/config/locales/forms/fr.yml @@ -90,7 +90,7 @@ fr: title: Confirmez votre compte webauthn_setup: intro_html: Lorsque vous vous connectez, vous pouvez utiliser votre clé de sécurité - matérielle. %{link} + physique. %{link} step_1: Insérez votre clé de sécurité dans le port USB de votre ordinateur ou connectez-le avec un câble USB. step_2: Une fois connecté, appuyez sur le bouton ou le disque d'or si votre diff --git a/config/locales/headings/en.yml b/config/locales/headings/en.yml index 5f89dd03125..6d76ba17319 100644 --- a/config/locales/headings/en.yml +++ b/config/locales/headings/en.yml @@ -30,7 +30,7 @@ en: change: Change your password confirm: Confirm your current password to continue forgot: Forgot your password? - personal_key: Store your personal key + personal_key: Always have access to your account with your personal key piv_cac_setup: certificate: bad: The certificate you selected is invalid. diff --git a/config/locales/headings/es.yml b/config/locales/headings/es.yml index a6d77419541..5829a932541 100644 --- a/config/locales/headings/es.yml +++ b/config/locales/headings/es.yml @@ -30,7 +30,7 @@ es: change: Cambie su contraseña confirm: Confirme la contraseña actual para continuar forgot: "¿Olvidó su contraseña?" - personal_key: Aquí está su clave personal + personal_key: Siempre tenga acceso a su cuenta con su clave personal piv_cac_setup: certificate: bad: El certificado que seleccionaste no es válido. diff --git a/config/locales/headings/fr.yml b/config/locales/headings/fr.yml index cd236014104..df54de5f284 100644 --- a/config/locales/headings/fr.yml +++ b/config/locales/headings/fr.yml @@ -30,7 +30,7 @@ fr: change: Changez votre mot de passe confirm: Confirmez votre mot de passe actuel pour continuer forgot: Vous avez oublié votre mot de passe? - personal_key: Voici votre clé personnelle + personal_key: Ayez toujours accès à votre compte avec votre clé personnelle piv_cac_setup: certificate: bad: Le certificat que vous avez sélectionné n'est pas valide. @@ -55,4 +55,4 @@ fr: new: Activer une application d'authentification verify_email: Consultez vos courriels webauthn_setup: - new: Enregistrez votre clé de sécurité matérielle + new: Enregistrez votre clé de sécurité physique diff --git a/config/locales/idv/en.yml b/config/locales/idv/en.yml index 688c82967ae..0c33593ef1c 100644 --- a/config/locales/idv/en.yml +++ b/config/locales/idv/en.yml @@ -11,7 +11,6 @@ en: send_confirmation_code: Continue cancel: modal_header: Are you sure you want to cancel? - return_to_account: Return to account warning_header: If you cancel now warnings: warning_1: We won't be able to verify your identity @@ -29,9 +28,11 @@ en: dob: Your date of birth must be entered in as mm/dd/yyyy personal_key: 'Please enter your personal key for this account. Example: ABC1-DEF2-G3HI-J456' ssn: 'Your Social Security Number must be entered in as ###-##-####' + state_id_number: Your ID number cannot be more than 25 characters. zipcode: 'Your zipcode must be entered in as #####-####' unsupported_jurisdiction: Sorry, we can't verify people from this state. unsupported_jurisdiction_sp: Please visit %{sp_name} to access your account. + unsupported_otp_delivery_method: Select a method to receive a code. failure: attempts: one: You have 1 attempt remaining. @@ -122,7 +123,6 @@ en: where: Where was your driver's license, driver's permit, or state ID issued? why: To verify your identity, you'll need information from your state-issued ID. - loading: Verifying your identity mail_sent: Your letter is on its way otp_delivery_method: phone_number_html: We'll send a code to %{phone} @@ -130,9 +130,11 @@ en: safe place. You will need it if you ever lose your password. phone: alert: This phone number must be + description: We're checking records to make sure you are who you say you are, + and that you have ownership of your account. phone_of_record: Phone of record rules: - - in your name, or a family member's name + - on a phone plan with your name on it - not a virtual phone (such as Google Voice or Skype) - a U.S. number return_to_profile: "‹ Return to your login.gov profile" diff --git a/config/locales/idv/es.yml b/config/locales/idv/es.yml index 2647bec0619..549c8c4f43c 100644 --- a/config/locales/idv/es.yml +++ b/config/locales/idv/es.yml @@ -11,7 +11,6 @@ es: send_confirmation_code: NOT TRANSLATED YET cancel: modal_header: "¿Está seguro que desea cancelar?" - return_to_account: NOT TRANSLATED YET warning_header: Si usted cancela ahora warnings: warning_1: NOT TRANSLATED YET @@ -28,10 +27,12 @@ es: dob: Su fecha de nacimiento debe ser ingresada en este formato mes/día/año. personal_key: 'Introduzca su clave personal para esta cuenta. Ejemplo: ABC1-DEF2-G3HI-J456' ssn: 'Su número de Seguro Social debe ser ingresado como ### - ## - ####' + state_id_number: Su número de ID no puede tener más de 25 caracteres zipcode: 'Su código postal debe ser ingresado como #####-####' unsupported_jurisdiction: Lo sentimos, no podemos verificar personas de este estado. unsupported_jurisdiction_sp: Visita %{sp_name} para acceder a tu cuenta. + unsupported_otp_delivery_method: Seleccione una manera de recibir un código. failure: attempts: one: Tiene usted 1 intento restante. @@ -121,7 +122,6 @@ es: del estado?" why: Para verificar su identidad, necesitará información de su identificación emitida por el estado. - loading: NOT TRANSLATED YET mail_sent: Su carta está en camino otp_delivery_method: phone_number_html: NOT TRANSLATED YET @@ -129,9 +129,11 @@ es: seguro. La necesitará si pierde su contraseña. phone: alert: Este número de teléfono debe ser + description: Verificamos los registros para asegurarnos de que eres quien + eres, y de que eres el propietario de tu cuenta. phone_of_record: Teléfono del registro rules: - - a su nombre o el nombre de un miembro de familia + - en un plan de teléfono con su nombre en él - no es un teléfono virtual (como Google Voice o Skype) - no es un número de teléfono prepago - un número de EE. UU. diff --git a/config/locales/idv/fr.yml b/config/locales/idv/fr.yml index 3e3aa266df2..269cb238c58 100644 --- a/config/locales/idv/fr.yml +++ b/config/locales/idv/fr.yml @@ -11,7 +11,6 @@ fr: send_confirmation_code: NOT TRANSLATED YET cancel: modal_header: Souhaitez-vous vraiment annuler? - return_to_account: NOT TRANSLATED YET warning_header: Si vous annulez maintenant warnings: warning_1: NOT TRANSLATED YET @@ -31,11 +30,13 @@ fr: exemple : ABC1-DEF2-G3HI-J456' ssn: 'Votre numéro de sécurité sociale doit être inscrit de cette façon : ###-##-####' + state_id_number: Votre numéro d'identification ne peut excéder 25 caractères zipcode: 'Votre code ZIP doit être inscrit de cette façon : #####-####' unsupported_jurisdiction: Désolé, nous ne pouvons pas vérifier les personnes de cet état. unsupported_jurisdiction_sp: Veuillez visiter %{sp_name} pour accéder à votre compte. + unsupported_otp_delivery_method: Sélectionnez une méthode pour recevoir un code. failure: attempts: one: Il ne vous reste qu' strongune tentative./strong @@ -127,7 +128,6 @@ fr: ou votre carte d'identité? why: Pour vérifier votre identité, vous aurez besoin d'informations provenant de votre carte d'identité officielle. - loading: NOT TRANSLATED YET mail_sent: Votre lettre est en route otp_delivery_method: phone_number_html: NOT TRANSLATED YET @@ -136,9 +136,11 @@ fr: de passe. phone: alert: Ce numéro de téléphone doit être + description: Nous vérifions les dossiers pour vous assurer que vous êtes ce + que vous dites et que vous êtes propriétaire de votre compte. phone_of_record: numéro de téléphone enregistré rules: - - en votre nom ou celui d'un membre de votre famille + - sur un plan de téléphone avec votre nom dessus - pas un téléphone virtuel (comme Google Voice ou Skype) - pas un numéro de téléphone prépayé - un numéro américain diff --git a/config/locales/instructions/en.yml b/config/locales/instructions/en.yml index d9be77b1ae4..bd072e01754 100644 --- a/config/locales/instructions/en.yml +++ b/config/locales/instructions/en.yml @@ -36,6 +36,9 @@ en: voice: confirm_code_html: Want us to call you again? %{resend_code_link} number_message: We just called you at %{number}. + webauthn: + confirm_webauthn_html: Present the hardware security key that you associated + with your account. wrong_number_html: Entered the wrong phone number? %{link} password: forgot: Don’t know your password? Reset it after confirming your email address. @@ -53,6 +56,11 @@ en: intro: 'Password strength: ' iv: Good v: Great! - personal_key_accent: Write it down or print it out. - personal_key_html: This is the only way to regain access to your account if you - lose the phone where we send your security code. %{accent} + personal_key: + accent: Write it down or print it out. + info_html: This is the only way to regain access to your account if you lose + your phone or security options. %{accent} + once_html: "Personal keys are one-time use. If you need to + use your personal key for any reason, you'll be issued a new key." + safety_html: "Keep it private and safe. Don't share your personal + key, and only use it when you do not have access to your normal sign-in methods." diff --git a/config/locales/instructions/es.yml b/config/locales/instructions/es.yml index e50e7460309..6dd8e41aa07 100644 --- a/config/locales/instructions/es.yml +++ b/config/locales/instructions/es.yml @@ -37,6 +37,9 @@ es: voice: confirm_code_html: "¿Desea que le llamemos de nuevo? %{resend_code_link}" number_message: Acabamos de llamarte en %{number}. + webauthn: + confirm_webauthn_html: Presente la clave de seguridad de hardware que ha asociado + con su cuenta. wrong_number_html: "¿Ingresó el número de teléfono equivocado? %{link}" password: forgot: "¿No sabe su contraseña? Restablézcala después de confirmar su email." @@ -54,6 +57,12 @@ es: intro: 'Seguridad de la contraseña:' iv: Buena v: "¡Muy buena!" - personal_key_accent: Anótelo o imprímalo. - personal_key_html: Esta es la única manera de recuperar el acceso a su cuenta - si pierde el teléfono donde enviamos su código de seguridad. %{accent} + personal_key: + accent: Anótelo o imprímalo. + info_html: Esta es la única manera de recuperar el acceso a su cuenta si pierde + su teléfono o las opciones de seguridad. %{accent} + once_html: "Las claves personales son de un solo uso. Si necesita + utilizar su clave personal por algún motivo, se le emitirá una nueva clave." + safety_html: "Manténgalo privado y seguro. No comparta su clave + personal, y solo úsela cuando no tenga acceso a sus métodos normales de inicio + de sesión." diff --git a/config/locales/instructions/fr.yml b/config/locales/instructions/fr.yml index 8d3264ab284..454c9d802c7 100644 --- a/config/locales/instructions/fr.yml +++ b/config/locales/instructions/fr.yml @@ -39,6 +39,9 @@ fr: voice: confirm_code_html: Vous voulez que nous vous appelions de nouveau? %{resend_code_link} number_message: Nous venons de vous appeler à %{number}. + webauthn: + confirm_webauthn_html: Présentez la clé de sécurité physique associée à votre + compte. wrong_number_html: Vous avez entré un mauvais numéro de téléphone? %{link} password: forgot: Vous ne connaissez pas votre mot de passe? Réinitialisez-le après avoir @@ -58,7 +61,13 @@ fr: intro: 'Force du mot de passe : ' iv: Bonne v: Excellente! - personal_key_accent: Notez-la ou imprimez-la. - personal_key_html: Il s'agit de la seule façon de récupérer l'accès à votre compte - si vous perdez le téléphone sur lequel nous envoyons votre code de sécurité. - %{accent} + personal_key: + accent: Notez-la ou imprimez-la. + info_html: C'est le seul moyen de retrouver l'accès à votre compte si vous perdez + votre téléphone ou vos options de sécurité. %{accent} + once_html: "Les clés personnelles sont à usage unique. Si vous + devez utiliser votre clé personnelle pour quelque raison que ce soit, vous + recevrez une nouvelle clé." + safety_html: "Protégez-vous en privé. Ne partagez pas votre + clé personnelle et ne l'utilisez que lorsque vous n'avez pas accès à vos méthodes + de connexion habituelles." diff --git a/config/locales/links/en.yml b/config/locales/links/en.yml index 4d334021e03..49f65c605bb 100644 --- a/config/locales/links/en.yml +++ b/config/locales/links/en.yml @@ -14,6 +14,7 @@ en: create_account: Create account go_back: Go back help: Help + my_account: my account next: Next passwords: forgot: Forgot your password? diff --git a/config/locales/links/es.yml b/config/locales/links/es.yml index 6bb67395759..70297bd60dc 100644 --- a/config/locales/links/es.yml +++ b/config/locales/links/es.yml @@ -14,6 +14,7 @@ es: create_account: Crear cuenta go_back: Regresa help: Ayuda + my_account: mi cuenta next: Siguiente passwords: forgot: "¿Olvidó su contraseña?" diff --git a/config/locales/links/fr.yml b/config/locales/links/fr.yml index 274cd199b08..769acfe5e98 100644 --- a/config/locales/links/fr.yml +++ b/config/locales/links/fr.yml @@ -14,6 +14,7 @@ fr: create_account: Créer un compte go_back: Retourner help: Aide + my_account: mon compte next: Suivant passwords: forgot: Vous avez oublié votre mot de passe? @@ -26,4 +27,4 @@ fr: app_option: Utilisez une application d'authentification à la place. get_another_code: Obtenir un autre code what_is_totp: Qu'est-ce qu'une application d'authentification? - what_is_webauthn: Qu'est-ce qu'une clé de sécurité matérielle? + what_is_webauthn: Qu'est-ce qu'une clé de sécurité physique? diff --git a/config/locales/notices/en.yml b/config/locales/notices/en.yml index 72abeff218c..fbc5a3728f9 100644 --- a/config/locales/notices/en.yml +++ b/config/locales/notices/en.yml @@ -18,8 +18,6 @@ en: piv_cac_disabled: PIV/CAC card unlinked successfully. resend_confirmation_email: success: We sent another confirmation email. - send_code: - personal_key: You have a new personal key. session_cleared: For your security, we clear what you entered if you don't move to a new page within %{minutes} minutes. signed_up_but_unconfirmed: diff --git a/config/locales/notices/es.yml b/config/locales/notices/es.yml index d19462fb9ba..f207da9bb60 100644 --- a/config/locales/notices/es.yml +++ b/config/locales/notices/es.yml @@ -18,8 +18,6 @@ es: piv_cac_disabled: Tarjeta PIV/CAC desvinculada con éxito. resend_confirmation_email: success: Enviamos otro email de confirmación. - send_code: - personal_key: Tiene una nueva clave personal. session_cleared: Para su seguridad, borramos lo que ingresó si no pasa a una página nueva dentro de %{minutes} minutos. signed_up_but_unconfirmed: diff --git a/config/locales/notices/fr.yml b/config/locales/notices/fr.yml index 3a39856f34b..bb495f0f119 100644 --- a/config/locales/notices/fr.yml +++ b/config/locales/notices/fr.yml @@ -19,8 +19,6 @@ fr: piv_cac_disabled: Carte PIV/CAC dissociée avec succès. resend_confirmation_email: success: Nous avons envoyé un autre courriel de confirmation. - send_code: - personal_key: Vous avez une nouvelle clé personnelle. session_cleared: Pour votre sécurité, nous effacerons l'information que vous avez entrée si vous ne vous déplacez pas vers une nouvelle page dans les %{minutes} prochaines minutes. @@ -47,8 +45,8 @@ fr: use_diff_email: link: utilisez une adresse courriel différente text_html: Or, %{link} - webauthn_added: Vous avez ajouté une clé de sécurité matérielle. - webauthn_deleted: Vous avez supprimé une clé de sécurité matérielle. + webauthn_added: Vous avez ajouté une clé de sécurité physique. + webauthn_deleted: Vous avez supprimé une clé de sécurité physique. session_timedout: Nous vous avons déconnecté. Pour votre sécurité, %{app} désactive votre session lorsque vous demeurez sur une page sans vous déplacer pendant %{minutes} minutes. diff --git a/config/locales/service_providers/en.yml b/config/locales/service_providers/en.yml index 68aa3071517..a4c3c206366 100644 --- a/config/locales/service_providers/en.yml +++ b/config/locales/service_providers/en.yml @@ -1,6 +1,17 @@ --- en: service_providers: + homes_mil: + account_page: + body: Your old HOMES.mil username and password won’t work. Please create a + login.gov account using the same email address you use for HOMES.mil. + body_html: Your old HOMES.mil username and password won’t work. Please %{link} + using the same email address you use for HOMES.mil. + create_account_link: create a login.gov account + create_account_page: + body: Please create a login.gov account using the same email address you use + for HOMES.mil + header: First time here from HOMES.mil? learn_more: Learn more. sam: account_page: diff --git a/config/locales/service_providers/es.yml b/config/locales/service_providers/es.yml index abd83083fe2..fc9526d3893 100644 --- a/config/locales/service_providers/es.yml +++ b/config/locales/service_providers/es.yml @@ -1,6 +1,18 @@ --- es: service_providers: + homes_mil: + account_page: + body: Si tiene un perfil de HOMES.mil existente, favor de usar la dirección + de correo electrónico primaria o secundaria que usó para HOMES.mil para + crear su nueva cuenta de login.gov. + body_html: Si tiene un perfil de HOMES.mil existente, favor de usar la dirección + de correo electrónico primaria o secundaria que usó para HOMES.mil para %{link}. + create_account_link: crear su nueva cuenta de login.gov + create_account_page: + body: Por favor crea un login.gov cuenta usando la misma dirección de correo + electrónico que utiliza para HOMES.mil. + header: "¿Ha venido de HOMES.mil?" learn_more: Obtenga más información. sam: account_page: diff --git a/config/locales/service_providers/fr.yml b/config/locales/service_providers/fr.yml index 5c2fb500ef2..15a819eec50 100644 --- a/config/locales/service_providers/fr.yml +++ b/config/locales/service_providers/fr.yml @@ -1,6 +1,19 @@ --- fr: service_providers: + homes_mil: + account_page: + body: Si vous avez déjà un profil HOMES.mil, veuillez utiliser l'adresse e-mail + principale ou secondaire que vous avez utilisée pour HOMES.mil pour créer + votre nouveau compte login.gov. + body_html: Si vous avez déjà un profil HOMES.mil, veuillez utiliser l'adresse + e-mail principale ou secondaire que vous avez utilisée pour HOMES.mil pour + %{link}. + create_account_link: créer votre nouveau compte login.gov + create_account_page: + body: Veuillez créer un compte login.gov avec la même adresse e-mail que vous + avez utilisée pour HOMES.mil. + header: Êtes-vous venu(e) de HOMES.mil? learn_more: En savoir plus. sam: account_page: diff --git a/config/locales/titles/en.yml b/config/locales/titles/en.yml index 436844f9338..6e5379493ed 100644 --- a/config/locales/titles/en.yml +++ b/config/locales/titles/en.yml @@ -36,6 +36,7 @@ en: invalid: PIV/CAC certificate error missing: Internal error present_piv_cac: Present your PIV/CAC + present_webauthn: Present your hardware security key reactivate_account: Reactivate your account registrations: new: Sign up for a account diff --git a/config/locales/titles/es.yml b/config/locales/titles/es.yml index 2cae099f029..823e5f7ecc0 100644 --- a/config/locales/titles/es.yml +++ b/config/locales/titles/es.yml @@ -36,6 +36,7 @@ es: invalid: NOT TRANSLATED YET missing: NOT TRANSLATED YET present_piv_cac: NOT TRANSLATED YET + present_webauthn: Presente su clave de seguridad de hardware reactivate_account: Reactive su cuenta registrations: new: Regístrese para una cuenta diff --git a/config/locales/titles/fr.yml b/config/locales/titles/fr.yml index af068ced0b0..90e59d1377e 100644 --- a/config/locales/titles/fr.yml +++ b/config/locales/titles/fr.yml @@ -36,6 +36,7 @@ fr: invalid: NOT TRANSLATED YET missing: NOT TRANSLATED YET present_piv_cac: NOT TRANSLATED YET + present_webauthn: Présentez votre clé de sécurité physique reactivate_account: Réactiver le profil registrations: new: S'inscrire et créer un compte diff --git a/config/locales/two_factor_authentication/en.yml b/config/locales/two_factor_authentication/en.yml index e327f048f22..74e92186695 100644 --- a/config/locales/two_factor_authentication/en.yml +++ b/config/locales/two_factor_authentication/en.yml @@ -1,7 +1,22 @@ --- en: two_factor_authentication: + account_reset: + cancel_link: Cancel your request + link: deleting your account + pending_html: You currently have a pending request to delete your account. It + takes 24 hours from the time you made the request to complete the process. + Please check back later. %{cancel_link} + successful_cancel: Thank you. Your request to delete your login.gov account + has been cancelled. + text_html: If you can't use any of these security options above, you can reset + your preferences by %{link}. choose_another_option: "‹ Choose another option" + header_text: Enter your security code + invalid_otp: That security code is invalid. You can try entering it again or request + a new one-time security code. + invalid_personal_key: That personal key is invalid. + invalid_piv_cac: That PIV/CAC is incorrect. login_intro: You set these up when you created your account login_options: auth_app: Authentication app @@ -12,16 +27,74 @@ en: piv_cac: Government employee ID piv_cac_info: Use your PIV/CAC card instead of a security code. sms: Text message - sms_info: Get security code via text message. + sms_info_html: Get security code via text message to %{phone}. + sms_setup_info: Get security code via text message. voice: Automated phone call - voice_info: Get security code via phone call (North America phone numbers only). + voice_info_html: Get security code via phone call to %{phone} + (North America phone numbers only). + voice_setup_info: Get security code via phone call (North America phone numbers + only). + webauthn: Hardware security key + webauthn_info: Use your hardware security key instead of a security code. login_options_link_text: Choose another security option login_options_title: Select your security option + max_generic_login_attempts_reached: For your security, your account is temporarily + locked. + max_otp_login_attempts_reached: For your security, your account is temporarily + locked because you have entered the one-time security code incorrectly too many + times. + max_otp_requests_reached: For your security, your account is temporarily locked + because you have requested a security code too many times. + max_personal_key_login_attempts_reached: For your security, your account is temporarily + locked because you have entered the personal key incorrectly too many times. + max_piv_cac_login_attempts_reached: For your security, your account is temporarily + locked because you have presented your piv/cac credential incorrectly too many + times. + otp_delivery_preference: + instruction: You can change this selection the next time you log in. If you + entered a landline, please select "Phone call" below. + phone_unsupported: We're unable to make phone calls to people in %{location} + at this time. + sms: Text message (SMS) + title: How should we send you a code? + voice: Phone call personal_key_fallback: question: Don't have your personal key? + personal_key_header_text: Enter your personal key + personal_key_prompt: You can use this personal key once. After you enter it, you'll + be provided a new key. phone_fallback: question: Don't have access to your phone right now? + phone_sms_info_html: We'll text a security code each time you sign in. + phone_sms_label: Mobile phone number + phone_voice_info_html: We'll call you with a security code each time you + sign in. + phone_voice_label: Phone number piv_cac_fallback: question: Don't have your piv/cac card available? + piv_cac_header_text: Present your PIV/CAC + please_try_again_html: Please try again in %{time_remaining}. + read_about_two_factor_authentication: + link: read about two-factor authentication + text_html: You can %{link} and why we use it at our Help page. totp_fallback: question: Don't have your authenticator app? + totp_header_text: Enter your authentication app code + two_factor_choice: Secure your account + two_factor_choice_intro: login.gov makes sure you can access your account by adding + a second layer of security. + two_factor_choice_options: + auth_app: Authentication application + auth_app_info: Set up an authentication application to get your security code + without providing a phone number + piv_cac: Government employees + piv_cac_info: Use your PIV/CAC card to secure your account + sms: Text message / SMS + sms_info: Get your security code via text message / SMS + voice: Phone call + voice_info: Get your security code via phone call + webauthn: Hardware security key + webauthn_info: Use a hardware security key to secure your account + webauthn_fallback: + question: Don't have your hardware security key available? + webauthn_header_text: Present your hardware security key diff --git a/config/locales/two_factor_authentication/es.yml b/config/locales/two_factor_authentication/es.yml index 4af916b12e4..c48a5d2774a 100644 --- a/config/locales/two_factor_authentication/es.yml +++ b/config/locales/two_factor_authentication/es.yml @@ -1,7 +1,22 @@ --- es: two_factor_authentication: + account_reset: + cancel_link: Cancelar su solicitud + link: eliminando su cuenta + pending_html: Actualmente tiene una solicitud pendiente para eliminar su cuenta. + Se necesitan 24 horas desde el momento en que realizó la solicitud para completar + el proceso. Por favor, vuelva más tarde. %{cancel_link} + successful_cancel: Gracias. Su solicitud para eliminar su cuenta de login.gov + ha sido cancelada. + text_html: Si no puede usar ninguna de estas opciones de seguridad anteriores, + puede restablecer tus preferencias por %{link}. choose_another_option: "‹ Elige otra opción" + header_text: Ingrese su código de seguridad + invalid_otp: Ese código de seguridad no es válido. Puede intentar ingresarlo de + nuevo o solicitar un nuevo código de seguridad de sólo un uso. + invalid_personal_key: Esa clave personal no es válida. + invalid_piv_cac: NOT TRANSLATED YET login_intro: Usted configuró esto cuando creó su cuenta login_options: auth_app: Aplicación de autenticación @@ -13,17 +28,75 @@ es: piv_cac: Empleados del Gobierno piv_cac_info: Use su tarjeta PIV / CAC para asegurar su cuenta. sms: Mensaje de texto / SMS - sms_info: Obtenga su código de seguridad a través de mensajes de texto / SMS. + sms_info_html: Obtenga su código de seguridad a través de mensajes de texto + / SMS a %{phone}. + sms_setup_info: Obtenga su código de seguridad a través de mensajes de texto + / SMS. voice: Llamada telefónica automatizada - voice_info: Obtenga su código de seguridad a través de una llamada telefónica. + voice_info_html: Obtenga su código de seguridad a través de una llamada telefónica + a %{phone}. (Solo números de teléfono de América del Norte). + voice_setup_info: Obtenga su código de seguridad a través de una llamada telefónica. (Solo números de teléfono de América del Norte). + webauthn: Clave de seguridad de hardware + webauthn_info: Use su clave de seguridad de hardware en lugar de un código de + seguridad. login_options_link_text: Elige otra opción de seguridad login_options_title: Seleccione su opción de seguridad + max_generic_login_attempts_reached: Para su seguridad, su cuenta está bloqueada + temporalmente. + max_otp_login_attempts_reached: Para su seguridad, su cuenta ha sido bloqueada + temporalmente porque ha ingresado incorrectamente el código de seguridad de + sólo un uso demasiadas veces. + max_otp_requests_reached: Para su seguridad, su cuenta ha sido bloqueada temporalmente + porque ha solicitado un código de seguridad demasiadas veces más de lo permitido. + max_personal_key_login_attempts_reached: Para su seguridad, su cuenta ha sido + bloqueada temporalmente porque ha ingresado incorrectamente la clave personal + demasiadas veces. + max_piv_cac_login_attempts_reached: NOT TRANSLATED YET + otp_delivery_preference: + instruction: Puede cambiar esta selección la próxima vez que inicie sesión. + phone_unsupported: NOT TRANSLATED YET + sms: Mensaje de texto (SMS, sigla en inglés) + title: "¿Cómo deberíamos enviarle un código?" + voice: Llamada telefónica personal_key_fallback: question: "¿No tiene su clave personal?" + personal_key_header_text: Ingrese su clave personal + personal_key_prompt: Puede usar esta clave personal una vez. Después de ingresarlo, + se le dará una nueva clave. phone_fallback: question: "¿No tiene acceso a su teléfono ahora mismo?" + phone_sms_info_html: Le enviaremos un mensaje de texto con un código de seguridad + cada vez que inicie sesión. + phone_sms_label: Número de teléfono móvil + phone_voice_info_html: Te llamaremos con un código de seguridad cada vez + que inicies sesión. + phone_voice_label: Número de teléfono piv_cac_fallback: question: "¿No tiene su tarjeta PIV/CAC disponible?" + piv_cac_header_text: NOT TRANSLATED YET + please_try_again_html: Inténtelo de nuevo en %{time_remaining}. + read_about_two_factor_authentication: + link: leer acerca de la autenticación de dos factores + text_html: Puede %{link} y por qué la utilizamos en nuestra página de Ayuda. totp_fallback: question: "¿No tiene su aplicación de autenticación?" + totp_header_text: Ingrese su código de la app de autenticación + two_factor_choice: Asegure su cuenta + two_factor_choice_intro: login.gov se asegura de que pueda acceder a su cuenta + agregando una segunda capa de seguridad. + two_factor_choice_options: + auth_app: Aplicación de autenticación + auth_app_info: Configure una aplicación de autenticación para obtener su código + de seguridad sin proporcionar un número de teléfono + piv_cac: Empleados del Gobierno + piv_cac_info: Use su tarjeta PIV / CAC para asegurar su cuenta + sms: Mensaje de texto / SMS + sms_info: Obtenga su código de seguridad a través de mensajes de texto / SMS + voice: Llamada telefónica + voice_info: Obtenga su código de seguridad a través de una llamada telefónica + webauthn: Clave de seguridad de hardware + webauthn_info: Use una clave de seguridad de hardware para proteger su cuenta + webauthn_fallback: + question: "¿No tienes tu clave de seguridad de hardware disponible?" + webauthn_header_text: Presente su clave de seguridad de hardware diff --git a/config/locales/two_factor_authentication/fr.yml b/config/locales/two_factor_authentication/fr.yml index b2fec4b80ab..77b3c17b0e8 100644 --- a/config/locales/two_factor_authentication/fr.yml +++ b/config/locales/two_factor_authentication/fr.yml @@ -1,7 +1,22 @@ --- fr: two_factor_authentication: + account_reset: + cancel_link: Annuler votre demande + link: supprimer votre compte + pending_html: Vous avez actuellement une demande en attente pour supprimer votre + compte. Il faut compter 24 heures à partir du moment où vous avez fait la + demande pour terminer le processus. Veuillez vérifier plus tard. %{cancel_link} + successful_cancel: Je vous remercie. Votre demande de suppression de votre compte + login.gov a été annulée. + text_html: Si vous ne pouvez pas utiliser l'une de ces options de sécurité ci-dessus, + vous pouvez réinitialiser vos préférences par %{link}. choose_another_option: "‹ Choisissez une autre option" + header_text: Entrez votre code de sécurité + invalid_otp: Ce code de sécurité est non valide. Vous pouvez essayer de l'entrer + de nouveau ou demander un nouveau code de sécurité à utilisation unique. + invalid_personal_key: Cette clé personnelle est non valide. + invalid_piv_cac: NOT TRANSLATED YET login_intro: Vous les avez configurés lorsque vous avez crée votre compte login_options: auth_app: Application d'authentification @@ -13,17 +28,74 @@ fr: piv_cac: Employés du gouvernement piv_cac_info: Utilisez votre carte PIV / CAC pour sécuriser votre compte. sms: SMS - sms_info: Obtenez votre code de sécurité par SMS. + sms_info_html: Obtenez votre code de sécurité par SMS à %{phone}. + sms_setup_info: Obtenez votre code de sécurité par SMS. voice: Appel téléphonique - voice_info: Obtenez votre code de sécurité par appel téléphonique. (Seulement + voice_info_html: Obtenez votre code de sécurité par appel téléphonique à %{phone}. + (Seulement les numéros de téléphone en Amerique du Nord) + voice_setup_info: Obtenez votre code de sécurité par appel téléphonique. (Seulement les numéros de téléphone en Amerique du Nord) + webauthn: Clé de sécurité physique + webauthn_info: Utilisez votre clé de sécurité physique au lieu d'un code de + sécurité. login_options_link_text: Choisissez une autre option de sécurité login_options_title: Sélectionnez votre option de sécurité + max_generic_login_attempts_reached: Pour votre sécurité, votre compte est temporairement + verrouillé. + max_otp_login_attempts_reached: Pour votre sécurité, votre compte est temporairement + verrouillé, car vous avez entré le code de sécurité à utilisation unique de + façon erronée à de trop nombreuses reprises. + max_otp_requests_reached: Pour votre sécurité, votre compte est temporairement + verrouillé car vous avez demandé un code de sécurité à trop de reprises. + max_personal_key_login_attempts_reached: Pour votre sécurité, votre compte est + temporairement verrouillé, car vous avez entré le code de sécurité à utilisation + unique de façon erronée à de trop nombreuses reprises. + max_piv_cac_login_attempts_reached: NOT TRANSLATED YET + otp_delivery_preference: + instruction: Vous pouvez changer cette sélection la prochaine fois que vous + vous connectez. + phone_unsupported: NOT TRANSLATED YET + sms: Message texte (SMS) + title: Comment devrions-nous vous envoyer un code? + voice: Appel téléphonique personal_key_fallback: question: Vous n'avez pas votre clé personnelle? + personal_key_header_text: Entrez votre clé personnelle + personal_key_prompt: Vous pouvez utiliser cette clé personnelle une fois seulement. + Une fois que vous l'entrez, vous recevrez une nouvelle clé. phone_fallback: question: Vous n'avez pas accès à votre téléphone maintenant? + phone_sms_info_html: Nous vous enverrons un code de sécurité chaque fois + que vous vous connectez. + phone_sms_label: Numéro de téléphone portable + phone_voice_info_html: Nous vous appellerons avec un code de sécurité chaque + fois que vous vous connectez. + phone_voice_label: Numéro de téléphone piv_cac_fallback: question: Vous n'avez pas accès à votre carte PIV/CAC? + piv_cac_header_text: NOT TRANSLATED YET + please_try_again_html: Veuillez essayer de nouveau dans %{time_remaining}. + read_about_two_factor_authentication: + link: lire sur l'authentification à deux facteurs + text_html: Vous pouvez %{link} et pourquoi nous l'utilisons sur notre page d'aide. totp_fallback: question: Vous n'avez pas votre application d'authentification? + totp_header_text: Entrez votre code d'application d'authentification + two_factor_choice: Sécurise ton compte + two_factor_choice_intro: login.gov s'assure que vous pouvez accéder à votre compte + en ajoutant une deuxième couche de sécurité. + two_factor_choice_options: + auth_app: Application d'authentification + auth_app_info: Configurez une application d'authentification pour obtenir votre + code de sécurité sans fournir de numéro de téléphone + piv_cac: Employés du gouvernement + piv_cac_info: Utilisez votre carte PIV / CAC pour sécuriser votre compte + sms: SMS + sms_info: Obtenez votre code de sécurité par SMS + voice: Appel téléphonique + voice_info: Obtenez votre code de sécurité par appel téléphonique + webauthn: Clé de sécurité physique + webauthn_info: Utilisez une clé de sécurité physique pour sécuriser votre compte + webauthn_fallback: + question: Votre clé de sécurité physique n'est pas disponible? + webauthn_header_text: Présentez votre clé de sécurité physique diff --git a/config/locales/users/en.yml b/config/locales/users/en.yml index 6fc62dce80d..eb31ef6245b 100644 --- a/config/locales/users/en.yml +++ b/config/locales/users/en.yml @@ -17,17 +17,7 @@ en: close: Close confirmation_error: You've entered an incorrect personal key. generated_on_html: Generated on %{date} - get_another: Get another key header: Your personal key - help_text: |- - To protect your account, you need a password and access to your telephone or authentication application at sign-in. If you can’t use your phone or app, you can sign in with your personal key instead. - - For your privacy and security, login.gov does not store your password and personal key. Only you know them. Only you can access or share your personal information. - - We require you to store your personal key outside your computer or mobile device so that it will be safe even if your devices are stolen or your online accounts are hacked. - - If you don’t have your personal key and you forget your password, the only way to keep your account safe is to verify that you are the legal owner. - help_text_header: Why do I need to store my new key on paper? print: Print this page totp_setup: new: diff --git a/config/locales/users/es.yml b/config/locales/users/es.yml index 1540202175c..0d618b1fb5e 100644 --- a/config/locales/users/es.yml +++ b/config/locales/users/es.yml @@ -17,17 +17,7 @@ es: close: Cerrar confirmation_error: Ha ingresado una clave personal incorrecta. generated_on_html: Generado el %{date} - get_another: Obtener otra clave header: Su clave personal - help_text: |- - Para proteger su cuenta, necesita una contraseña y acceso a su teléfono o app de autenticación al iniciar una sesión. Si no puede utilizar su teléfono o app, puede iniciar una sesión con su clave personal. - - Para su privacidad y seguridad, login.gov no guarda su contraseña y clave personal. Sólo usted las conoce. Sólo usted puede acceder o compartir su información personal. - - Le pedimos que guarde su clave personal afuera de su computadora o dispositivo móvil para que mantenerla segura en caso de que le roben sus aparatos o sus cuentas en línea sean hackeadas. - - Si no tiene su clave personal y olvida su contraseña, la única manera de mantener su cuenta segura es verificando que usted es el propietario legal. - help_text_header: "¿Por qué necesito guardar mi nueva clave en papel?" print: Imprima esta página totp_setup: new: diff --git a/config/locales/users/fr.yml b/config/locales/users/fr.yml index fea033f1390..9a56406bf80 100644 --- a/config/locales/users/fr.yml +++ b/config/locales/users/fr.yml @@ -19,17 +19,7 @@ fr: close: Fermer confirmation_error: Vous avez entré un clé personnelle erronée. generated_on_html: Générée le %{date} - get_another: Obtenir une autre clé header: Votre clé personnelle - help_text: |- - Pour protéger votre compte, vous devez avoir un mot de passe et l'accès à votre téléphone ou application d'authentification au moment de la connexion. Si vous ne pouvez utiliser votre téléphone ou application, vous pouvez vous connecter avec votre clé personnelle. - - Pour votre confidentialité et votre sécurité, login.gov ne conserve pas votre mot de passe ni votre clé personnelle. Seul(e) vous les connaissez. Seul(e) vous pouvez accéder à votre information personnelle et la partager . - - Nous vous demandons de conserver votre clé personnelle à l'extérieur de votre ordinateur ou appareil mobile afin qu'elle soit en sûreté même si vos appareils sont volés ou si vos comptes en ligne sont piratés. - - Si vous n'avez pas votre clé personnelle et que vous oubliez votre mot de passe, la seule façon de garder votre compte en sécurité est de vérifier que vous en êtes le(la) propriétaire légal(e). - help_text_header: Pourquoi dois-je conserver ma nouvelle clé sur papier? print: Imprimer cette page totp_setup: new: diff --git a/config/routes.rb b/config/routes.rb index adc12757ff3..a7f8b5a3d6c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,11 +1,7 @@ Rails.application.routes.draw do - require 'sidekiq/web' - mount Sidekiq::Web => '/sidekiq', constraints: AdminConstraint.new - # Non i18n routes. Alphabetically sorted. get '/api/health' => 'health/health#index' get '/api/health/database' => 'health/database#index' - get '/api/health/workers' => 'health/workers#index' get '/api/openid_connect/certs' => 'openid_connect/certs#index' post '/api/openid_connect/token' => 'openid_connect/token#create' match '/api/openid_connect/token' => 'openid_connect/token#options', via: :options @@ -58,8 +54,8 @@ get '/account_reset/request' => 'account_reset/request#show' post '/account_reset/request' => 'account_reset/request#create' - get '/account_reset/cancel' => 'account_reset/cancel#create' - get '/account_reset/report_fraud' => 'account_reset/report_fraud#update' + get '/account_reset/cancel' => 'account_reset/cancel#show' + post '/account_reset/cancel' => 'account_reset/cancel#create' get '/account_reset/confirm_request' => 'account_reset/confirm_request#show' get '/account_reset/delete_account' => 'account_reset/delete_account#show' delete '/account_reset/delete_account' => 'account_reset/delete_account#delete' @@ -73,8 +69,10 @@ post '/login/two_factor/authenticator' => 'two_factor_authentication/totp_verification#create' get '/login/two_factor/personal_key' => 'two_factor_authentication/personal_key_verification#show' post '/login/two_factor/personal_key' => 'two_factor_authentication/personal_key_verification#create' - if FeatureManagement.piv_cac_enabled? - get '/login/two_factor/piv_cac' => 'two_factor_authentication/piv_cac_verification#show' + get '/login/two_factor/piv_cac' => 'two_factor_authentication/piv_cac_verification#show' + if FeatureManagement.webauthn_enabled? + get '/login/two_factor/webauthn' => 'two_factor_authentication/webauthn_verification#show' + patch '/login/two_factor/webauthn' => 'two_factor_authentication/webauthn_verification#confirm' end get '/login/two_factor/:otp_delivery_preference' => 'two_factor_authentication/otp_verification#show', as: :login_two_factor, constraints: { otp_delivery_preference: /sms|voice/ } @@ -93,10 +91,8 @@ get '/saml/decode_assertion' => 'saml_test#start' post '/saml/decode_assertion' => 'saml_test#decode_response' post '/saml/decode_slo_request' => 'saml_test#decode_slo_request' - if FeatureManagement.piv_cac_enabled? - get '/piv_cac_entry' => 'piv_cac_authentication_test_subject#new' - post '/piv_cac_entry' => 'piv_cac_authentication_test_subject#create' - end + get '/piv_cac_entry' => 'piv_cac_authentication_test_subject#new' + post '/piv_cac_entry' => 'piv_cac_authentication_test_subject#create' end end @@ -117,11 +113,9 @@ as: :create_verify_personal_key get '/account_recovery_setup' => 'account_recovery_setup#index' - if FeatureManagement.piv_cac_enabled? - get '/piv_cac' => 'users/piv_cac_authentication_setup#new', as: :setup_piv_cac - delete '/piv_cac' => 'users/piv_cac_authentication_setup#delete', as: :disable_piv_cac - get '/present_piv_cac' => 'users/piv_cac_authentication_setup#redirect_to_piv_cac_service', as: :redirect_to_piv_cac_service - end + get '/piv_cac' => 'users/piv_cac_authentication_setup#new', as: :setup_piv_cac + delete '/piv_cac' => 'users/piv_cac_authentication_setup#delete', as: :disable_piv_cac + get '/present_piv_cac' => 'users/piv_cac_authentication_setup#redirect_to_piv_cac_service', as: :redirect_to_piv_cac_service if FeatureManagement.webauthn_enabled? get '/webauthn_setup' => 'users/webauthn_setup#new', as: :webauthn_setup @@ -192,7 +186,6 @@ put '/otp_delivery_method' => 'otp_delivery_method#create' get '/phone' => 'phone#new' put '/phone' => 'phone#create' - get '/phone/result' => 'phone#show' get '/phone/failure/:reason' => 'phone#failure', as: :phone_failure post '/phone/resend_code' => 'resend_otp#create', as: :resend_otp get '/phone_confirmation' => 'otp_verification#show', as: :otp_verification @@ -201,7 +194,6 @@ put '/review' => 'review#create' get '/session' => 'sessions#new' put '/session' => 'sessions#create' - get '/session/result' => 'sessions#show' get '/session/success' => 'sessions#success' get '/session/failure/:reason' => 'sessions#failure', as: :session_failure delete '/session' => 'sessions#destroy' @@ -210,6 +202,11 @@ get '/jurisdiction/failure/:reason' => 'jurisdiction#failure', as: :jurisdiction_failure get '/cancel/' => 'cancellations#new', as: :cancel delete '/cancel' => 'cancellations#destroy' + if FeatureManagement.doc_auth_enabled? + get '/doc_auth' => 'doc_auth#index' + get '/doc_auth/:step' => 'doc_auth#show', as: :doc_auth_step + put '/doc_auth/:step' => 'doc_auth#update' + end end end diff --git a/config/service_providers.yml b/config/service_providers.yml index 7ec97a5db43..7e14c1ed52a 100644 --- a/config/service_providers.yml +++ b/config/service_providers.yml @@ -264,7 +264,7 @@ production: redirect_uris: - 'http://localhost:9292/auth/result' <% if LoginGov::Hostdata.in_datacenter? %> - - 'https://sp-oidc-sinatra.<%= LoginGov::Hostdata.env %>.<%= LoginGov::Hostdata.domain %>/' + - 'https://sp-oidc-sinatra.<%= LoginGov::Hostdata.env %>.<%= LoginGov::Hostdata.domain %>/auth/result' <% end %> 'urn:gov:gsa:openidconnect:sp:expressjs': @@ -399,6 +399,7 @@ production: redirect_uris: - 'https://ttp.cbp.dhs.gov' - 'https://ttp.cbp.dhs.gov/login' + - 'https://ttp.cbp.dhs.gov/login?logout=logout&undefined' return_to_sp_url: https://ttp.cbp.dhs.gov/ # CBP ROAM (formerly OARS) diff --git a/config/sidekiq.yml b/config/sidekiq.yml deleted file mode 100644 index f72f4153bcd..00000000000 --- a/config/sidekiq.yml +++ /dev/null @@ -1,7 +0,0 @@ -:queues: - - sms - - voice - - mailers - - analytics - - idv -:logfile: 'log/sidekiq.log' diff --git a/db/migrate/20180805121236_create_doc_auths.rb b/db/migrate/20180805121236_create_doc_auths.rb new file mode 100644 index 00000000000..bb50932e186 --- /dev/null +++ b/db/migrate/20180805121236_create_doc_auths.rb @@ -0,0 +1,12 @@ +class CreateDocAuths < ActiveRecord::Migration[5.1] + def change + create_table :doc_auths do |t| + t.references :user, null: false + t.datetime :attempted_at + t.integer :attempts, default: 0 + t.datetime :license_confirmed_at + t.datetime :selfie_confirmed_at + t.timestamps + end + end +end diff --git a/db/migrate/20180906181420_create_email_address_table.rb b/db/migrate/20180906181420_create_email_address_table.rb new file mode 100644 index 00000000000..6a82c5d3ef2 --- /dev/null +++ b/db/migrate/20180906181420_create_email_address_table.rb @@ -0,0 +1,16 @@ +class CreateEmailAddressTable < ActiveRecord::Migration[5.1] + def change + create_table :email_addresses do |t| + t.references :user + t.string :confirmation_token, limit: 255 + t.datetime :confirmed_at + t.datetime :confirmation_sent_at + t.string :email_fingerprint, null: false, default: "" + t.string :encrypted_email, null: false, default: "" + + t.timestamps + + t.index :email_fingerprint, unique: true, where: 'confirmed_at IS NOT NULL' + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 410c606a561..e9a0594cee3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20180827225542) do +ActiveRecord::Schema.define(version: 20180906181420) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -55,6 +55,30 @@ t.index ["user_id"], name: "index_authorizations_on_user_id" end + create_table "doc_auths", force: :cascade do |t| + t.bigint "user_id", null: false + t.datetime "attempted_at" + t.integer "attempts", default: 0 + t.datetime "license_confirmed_at" + t.datetime "selfie_confirmed_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_doc_auths_on_user_id" + end + + create_table "email_addresses", force: :cascade do |t| + t.bigint "user_id" + t.string "confirmation_token", limit: 255 + t.datetime "confirmed_at" + t.datetime "confirmation_sent_at" + t.string "email_fingerprint", default: "", null: false + t.string "encrypted_email", default: "", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["email_fingerprint"], name: "index_email_addresses_on_email_fingerprint", unique: true, where: "(confirmed_at IS NOT NULL)" + t.index ["user_id"], name: "index_email_addresses_on_user_id" + end + create_table "events", force: :cascade do |t| t.integer "user_id", null: false t.integer "event_type", null: false diff --git a/docker-compose.yml b/docker-compose.yml index fe7f7688b57..3e10e659070 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,19 +14,6 @@ services: depends_on: - db - redis - sidekiq: - build: . - command: "bundle exec sidekiq --config config/sidekiq.yml" - environment: - REDIS_URL: "redis://redis" - DATABASE_URL: "postgres://postgres@db" - SMTP_HOST: "mailcatcher" - depends_on: - - db - - redis - - mailcatcher - volumes: - - .:/upaya db: image: postgres redis: diff --git a/lib/encrypted_sidekiq_redis.rb b/lib/encrypted_sidekiq_redis.rb deleted file mode 100644 index 922b2b21e66..00000000000 --- a/lib/encrypted_sidekiq_redis.rb +++ /dev/null @@ -1,94 +0,0 @@ -require 'redis' -require 'gibberish' - -class EncryptedSidekiqRedis - attr_accessor :redis, :cipher - - def initialize(opts) - self.redis = Redis.new(opts) - self.cipher = Gibberish::AES.new(Figaro.env.session_encryption_key) - end - - def lpush(key, value) - super(key, encrypt_job(value)) - end - - def rpush(key, value) - super(key, encrypt_job(value)) - end - - def lpop(key) - decrypt_job(super(key)) - end - - def rpop(key) - decrypt_job(super(key)) - end - - def blpop(*args) - queue, job = super(args) - [queue, decrypt_job(job)] - end - - def brpop(*args) - queue, job = super(args) - [queue, decrypt_job(job)] - end - - def zadd(key, *args) - ts, job = args - super(key, [ts, encrypt_job(job)]) - end - - def zrem(key, member) - # member must be removed from redis as-is (encrypted) - # but it is used elsewhere as if it was decrypted, so alter it in place. - ret = super(key, member) - if ret - decrypted_job = decrypt_job(member) - member.clear - member << decrypted_job - end - ret - end - - # rubocop:disable Style/MethodMissingSuper - def method_missing(meth, *args, &block) - redis.send(meth, *args, &block) - end - # rubocop:enable Style/MethodMissingSuper - - def respond_to_missing?(meth, include_private) - redis.respond_to?(meth, include_private) - end - - private - - def decrypt_job(job_json) - # if job is JSON, possibly ActiveJob format, possibly Gibberish format. - begin - job = JSON.parse(job_json) - rescue StandardError - return job_json - end - if encrypted?(job) - cipher.decrypt(job_json) - else - job_json - end - end - - def encrypt_job(plain_job) - if plain_job.is_a?(Array) - plain_job.map { |job| encrypt_job(job) } - else - encrypted?(plain_job) ? plain_job : cipher.encrypt(plain_job) - end - end - - def encrypted?(job) - return true if job.is_a?(Hash) && job.key?('cipher') - return true if job.is_a?(String) && job =~ /"cipher"/ - false - end -end diff --git a/lib/feature_management.rb b/lib/feature_management.rb index 4ca5caf17e5..03720126f11 100644 --- a/lib/feature_management.rb +++ b/lib/feature_management.rb @@ -13,10 +13,6 @@ def self.telephony_disabled? Figaro.env.telephony_disabled == 'true' end - def self.piv_cac_enabled? - Figaro.env.piv_cac_enabled == 'true' - end - def self.identity_pki_disabled? env = Figaro.env env.identity_pki_disabled == 'true' || @@ -24,10 +20,10 @@ def self.identity_pki_disabled? !env.piv_cac_verify_token_url end - def self.development_and_piv_cac_entry_enabled? + def self.development_and_identity_pki_disabled? # This controls if we try to hop over to identity-pki or just throw up # a screen asking for a Subject or one of a list of error conditions. - Rails.env.development? && piv_cac_enabled? && identity_pki_disabled? + Rails.env.development? && identity_pki_disabled? end def self.prefill_otp_codes? @@ -109,4 +105,12 @@ def self.account_reset_enabled? def self.webauthn_enabled? Figaro.env.webauthn_enabled == 'true' end + + def self.doc_auth_enabled? + Figaro.env.doc_auth_enabled == 'true' + end + + def self.doc_auth_exclusive? + Figaro.env.doc_auth_exclusive == 'true' + end end diff --git a/lib/no_retry_jobs.rb b/lib/no_retry_jobs.rb deleted file mode 100644 index 840aeb0cba7..00000000000 --- a/lib/no_retry_jobs.rb +++ /dev/null @@ -1,8 +0,0 @@ -class NoRetryJobs - def call(_worker, msg, queue) - yield - rescue StandardError => _e - msg['retry'] = false if %w[idv sms voice].include?(queue) - raise - end -end diff --git a/lib/production_database_configuration.rb b/lib/production_database_configuration.rb index d7474b9c0f3..65979447772 100644 --- a/lib/production_database_configuration.rb +++ b/lib/production_database_configuration.rb @@ -18,16 +18,7 @@ def self.password end def self.pool - env = Figaro.env - role = File.read('/etc/login.gov/info/role') if File.exist?('/etc/login.gov/info/role') - case role - when 'idp' - env.database_pool_idp.presence || 5 - when 'worker' - env.database_pool_worker.presence || 26 - else - 5 - end + Figaro.env.database_pool_idp.presence || 5 end private_class_method def self.readonly_mode? diff --git a/lib/proofer_mocks/address_mock.rb b/lib/proofer_mocks/address_mock.rb index 530ac83d129..48c6e92cd17 100644 --- a/lib/proofer_mocks/address_mock.rb +++ b/lib/proofer_mocks/address_mock.rb @@ -7,6 +7,10 @@ class AddressMock < Proofer::Base plain_phone = applicant[:phone].gsub(/\D/, '').gsub(/\A1/, '') if plain_phone == '7035555555' result.add_error(:phone, 'The phone number could not be verified.') + elsif plain_phone == '7035555999' + raise 'Failed to contact proofing vendor' + elsif plain_phone == '7035555888' + raise Proofer::TimeoutError, 'address mock timeout' end result.context[:message] = 'some context for the mock address proofer' end diff --git a/lib/proofer_mocks/resolution_mock.rb b/lib/proofer_mocks/resolution_mock.rb index 60ad3bf31b0..ace8577c67d 100644 --- a/lib/proofer_mocks/resolution_mock.rb +++ b/lib/proofer_mocks/resolution_mock.rb @@ -12,7 +12,7 @@ class ResolutionMock < Proofer::Base result.add_error(:first_name, 'Unverified first name.') elsif first_name.match?(/Time/i) - sleep((Figaro.env.async_job_refresh_max_wait_seconds.to_i + 5).seconds) + raise Proofer::TimeoutError, 'resolution mock timeout' elsif applicant[:ssn].match?(/6666/) result.add_error(:ssn, 'Unverified SSN.') diff --git a/lib/proofer_mocks/state_id_mock.rb b/lib/proofer_mocks/state_id_mock.rb index 764de54ab75..aa5246ed172 100644 --- a/lib/proofer_mocks/state_id_mock.rb +++ b/lib/proofer_mocks/state_id_mock.rb @@ -1,6 +1,7 @@ class StateIdMock < Proofer::Base SUPPORTED_STATES = %w[ - AR AZ CO DC DE FL IA ID IL IN KY MA MD ME MI MS MT ND NE NJ NM PA SD TX VA WA WI WY + AR AZ CO DC DE FL IA ID IL IN KY MA MD ME MI MO MS MT ND NE NJ NM PA RI SC + SD TX VA VT WA WI WY ].freeze SUPPORTED_STATE_ID_TYPES = %w[ diff --git a/lib/queue_config.rb b/lib/queue_config.rb index 2853281f34a..e6615f63ba1 100644 --- a/lib/queue_config.rb +++ b/lib/queue_config.rb @@ -5,7 +5,7 @@ module QueueConfig # rubocop:disable Metrics/MethodLength # Known acceptable values for config.active_job.queue_adapter - KNOWN_QUEUE_ADAPTERS = %i[sidekiq inline async].freeze + KNOWN_QUEUE_ADAPTERS = %i[inline async].freeze # Select a queue adapter for use, including possible random weights as # defined by Figaro.env.queue_adapter_weights (a JSON mapping from queue @@ -13,8 +13,8 @@ module QueueConfig def self.choose_queue_adapter adapter_config = Figaro.env.queue_adapter_weights - # default to Sidekiq if no config present - return :sidekiq unless adapter_config + # default to async if no config present + return :async unless adapter_config options = JSON.parse(adapter_config, symbolize_names: true) diff --git a/lib/sidekiq_logger_formatter.rb b/lib/sidekiq_logger_formatter.rb deleted file mode 100644 index e8b87b2423d..00000000000 --- a/lib/sidekiq_logger_formatter.rb +++ /dev/null @@ -1,29 +0,0 @@ -class SidekiqLoggerFormatter < Logger::Formatter - # This method is stdlib in ruby :reek:LongParameterList { max_params: 4 } - def call(severity, time, progname, msg) - msg = filter_msg(msg) - super(severity, time, progname, msg) - end - - private - - def filter_msg(msg) - return filter_msg_string(msg) if msg.is_a? String - return filter_msg_hash(msg) if msg.is_a? Hash - end - - def filter_msg_string(msg) - parsed = JSON.parse(msg) - filter_msg_hash(parsed) - rescue StandardError - msg - end - - def filter_msg_hash(msg) - if msg.key?('job') - msg['job']['args'].each { |arg| arg['arguments'] = '[redacted]' } - msg['jobstr'] = '[redacted]' - end - msg - end -end diff --git a/lib/tasks/account_reset.rake b/lib/tasks/account_reset.rake index 5b8e5ceb4f2..03e014f54bb 100644 --- a/lib/tasks/account_reset.rake +++ b/lib/tasks/account_reset.rake @@ -1,6 +1,6 @@ namespace :account_reset do desc 'Send Notifications' task send_notifications: :environment do - AccountResetService.grant_tokens_and_send_notifications + AccountReset::GrantRequestsAndSendEmails.new.call end end diff --git a/lib/tasks/create_test_accounts.rb b/lib/tasks/create_test_accounts.rb index aa2ac0ae785..601b9d73212 100644 --- a/lib/tasks/create_test_accounts.rb +++ b/lib/tasks/create_test_accounts.rb @@ -16,7 +16,7 @@ def create_account(email: 'joe.smith@email.com', password: 'salty pickles', mfa_ user.skip_confirmation! user.reset_password(password, password) user.save! - user.phone_configurations.create( + MfaContext.new(user).phone_configurations.create( phone: mfa_phone || phone, confirmed_at: Time.zone.now, delivery_preference: user.otp_delivery_preference diff --git a/lib/tasks/dev.rake b/lib/tasks/dev.rake index 50b78ae7577..28064a2aa92 100644 --- a/lib/tasks/dev.rake +++ b/lib/tasks/dev.rake @@ -88,7 +88,7 @@ namespace :dev do user.encrypted_email = args[:ee].encrypted user.skip_confirmation! user.reset_password(args[:pw], args[:pw]) - user.phone_configurations.create(phone_configuration_data(user, args)) + MfaContext.new(user).phone_configurations.create(phone_configuration_data(user, args)) Event.create(user_id: user.id, event_type: :account_created) end diff --git a/lib/tasks/migrate_email_addresses.rake b/lib/tasks/migrate_email_addresses.rake new file mode 100644 index 00000000000..0d669df442f --- /dev/null +++ b/lib/tasks/migrate_email_addresses.rake @@ -0,0 +1,7 @@ +namespace :adhoc do + desc 'Copy email addresses to the new table' + task populate_email_addresses: :environment do + Rails.logger = Logger.new(STDOUT) + PopulateEmailAddressesTable.new.call + end +end diff --git a/lib/worker_health_checker.rb b/lib/worker_health_checker.rb deleted file mode 100644 index 721fdb76b12..00000000000 --- a/lib/worker_health_checker.rb +++ /dev/null @@ -1,94 +0,0 @@ -# Helps with reading and writing queue health from Sidekiq -module WorkerHealthChecker - module_function - - # Sidekiq server-side middleware that wraps jobs and marks the queues as healthy - # when a job completes successfully - class Middleware - def call(_worker, _job, queue) - yield - - WorkerHealthChecker.mark_healthy!(queue) - end - end - - # Empty job that we put in each background queue to make sure the queue is running - # Relies on the Middleware to mark the queue as healthy - class DummyJob < ApplicationJob - def perform; end - end - - Status = Struct.new(:queue, :last_run_at, :healthy) do - alias_method :healthy?, :healthy - end - - Summary = Struct.new(:statuses) do - def healthy? - statuses.all?(&:healthy?) - end - - def to_h - super.merge(all_healthy: healthy?) # monitoring currently depends on "all_healthy" - end - - def as_json(*args) - to_h.as_json(*args) - end - end - - # called on an interval to enqueue a dummy job in each queue - # @see deploy/schedule.rb - def enqueue_dummy_jobs(queues = sidekiq_queues) - queues.each do |queue| - DummyJob.set(queue: queue).perform_later - end - end - - def sidekiq_queues - @_queues ||= YAML.load_file(Rails.root.join('config', 'sidekiq.yml'))[:queues] - end - - # @return [Summary] - def check(now: Time.zone.now) - Summary.new(statuses(now: now)) - end - - # @return [Array] - def statuses(now: Time.zone.now) - Sidekiq::Queue.all.map(&:name).map do |name| - status(name, now: now) - end - end - - def mark_healthy!(queue_name, now: Time.zone.now) - with_redis do |redis| - redis.set(health_check_key(queue_name), now.to_i) - end - end - - # @return [Status] - def status(queue_name, now: Time.zone.now) - last_run_value = with_redis { |redis| redis.get(health_check_key(queue_name)) } - - last_run_at = last_run_value && Time.zone.at(last_run_value.to_i) - - Status.new(queue_name, last_run_at, healthy?(last_run_at, now: now)) - end - - # @api private - def healthy?(last_run_at, now: Time.zone.now) - last_run_at.present? && - (now.to_i - last_run_at.to_i < Figaro.env.queue_health_check_dead_interval_seconds.to_i) - end - - # @api private - def health_check_key(queue_name) - "health:#{queue_name}" - end - - # @api private - # This makes reek complain less about referencing things less than self - def with_redis(&block) - Sidekiq.redis(&block) - end -end diff --git a/spec/controllers/account_reset/cancel_controller_spec.rb b/spec/controllers/account_reset/cancel_controller_spec.rb index a3b2a5c3777..3b853b12e10 100644 --- a/spec/controllers/account_reset/cancel_controller_spec.rb +++ b/spec/controllers/account_reset/cancel_controller_spec.rb @@ -11,6 +11,7 @@ describe '#create' do it 'logs a good token to the analytics' do token = create_account_reset_request_for(user) + session[:cancel_token] = token stub_analytics analytics_hash = { @@ -23,7 +24,7 @@ expect(@analytics).to receive(:track_event). with(Analytics::ACCOUNT_RESET, analytics_hash) - post :create, params: { token: token } + post :create end it 'logs a bad token to the analytics' do @@ -37,8 +38,9 @@ expect(@analytics).to receive(:track_event). with(Analytics::ACCOUNT_RESET, analytics_hash) + session[:cancel_token] = 'FOO' - post :create, params: { token: 'FOO' } + post :create end it 'logs a missing token to the analytics' do @@ -63,11 +65,12 @@ it 'redirects to the root with a flash message when the token is valid' do token = create_account_reset_request_for(user) + session[:cancel_token] = token - post :create, params: { token: token } + post :create expect(flash[:success]). - to eq t('devise.two_factor_authentication.account_reset.successful_cancel') + to eq t('two_factor_authentication.account_reset.successful_cancel') expect(response).to redirect_to root_url end @@ -75,10 +78,44 @@ stub_sign_in(user) token = create_account_reset_request_for(user) + session[:cancel_token] = token expect(controller).to receive(:sign_out) - post :create, params: { token: token } + post :create + end + end + + describe '#show' do + it 'redirects to root if the token does not match one in the DB' do + stub_analytics + properties = { + user_id: 'anonymous-uuid', + event: 'visit', + success: false, + errors: { token: [t('errors.account_reset.cancel_token_invalid')] }, + } + expect(@analytics). + to receive(:track_event).with(Analytics::ACCOUNT_RESET, properties) + + get :show, params: { token: 'FOO' } + + expect(response).to redirect_to(root_url) + expect(flash[:error]).to eq t('errors.account_reset.cancel_token_invalid') + end + + it 'renders the show view if the token is missing' do + get :show + + expect(response).to render_template(:show) + end + + it 'redirects to root if feature is not enabled' do + allow(FeatureManagement).to receive(:account_reset_enabled?).and_return(false) + + get :show + + expect(response).to redirect_to root_url end end end diff --git a/spec/controllers/account_reset/delete_account_controller_spec.rb b/spec/controllers/account_reset/delete_account_controller_spec.rb index 2675a82b723..1923c3ff6ca 100644 --- a/spec/controllers/account_reset/delete_account_controller_spec.rb +++ b/spec/controllers/account_reset/delete_account_controller_spec.rb @@ -7,7 +7,7 @@ it 'logs a good token to the analytics' do user = create(:user) create_account_reset_request_for(user) - AccountResetService.new(user).grant_request + grant_request(user) session[:granted_token] = AccountResetRequest.all[0].granted_token stub_analytics @@ -63,7 +63,7 @@ it 'displays a flash and redirects to root if the token is expired' do user = create(:user) create_account_reset_request_for(user) - AccountResetService.new(user).grant_request + grant_request(user) stub_analytics properties = { @@ -114,7 +114,7 @@ it 'displays a flash and redirects to root if the token is expired' do user = create(:user) create_account_reset_request_for(user) - AccountResetService.new(user).grant_request + grant_request(user) stub_analytics properties = { diff --git a/spec/controllers/account_reset/report_fraud_controller_spec.rb b/spec/controllers/account_reset/report_fraud_controller_spec.rb deleted file mode 100644 index 56114cf0104..00000000000 --- a/spec/controllers/account_reset/report_fraud_controller_spec.rb +++ /dev/null @@ -1,31 +0,0 @@ -require 'rails_helper' - -describe AccountReset::ReportFraudController do - include AccountResetHelper - - describe '#update' do - it 'logs a good token to the analytics' do - user = create(:user) - create_account_reset_request_for(user) - - stub_analytics - expect(@analytics).to receive(:track_event). - with(Analytics::ACCOUNT_RESET, event: :fraud, token_valid: true) - - post :update, params: { token: AccountResetRequest.all[0].request_token } - end - - it 'logs a bad token to the analytics' do - stub_analytics - expect(@analytics).to receive(:track_event). - with(Analytics::ACCOUNT_RESET, event: :fraud, token_valid: false) - - post :update, params: { token: 'FOO' } - end - - it 'redirects to the root' do - post :update - expect(response).to redirect_to root_url - end - end -end diff --git a/spec/controllers/account_reset/send_notifications_controller_spec.rb b/spec/controllers/account_reset/send_notifications_controller_spec.rb index b32b17be624..c4a3dc37844 100644 --- a/spec/controllers/account_reset/send_notifications_controller_spec.rb +++ b/spec/controllers/account_reset/send_notifications_controller_spec.rb @@ -15,7 +15,9 @@ end it 'logs the number of notifications sent in the analytics' do - allow(AccountResetService).to receive(:grant_tokens_and_send_notifications).and_return(7) + service = instance_double(AccountReset::GrantRequestsAndSendEmails) + allow(AccountReset::GrantRequestsAndSendEmails).to receive(:new).and_return(service) + allow(service).to receive(:call).and_return(7) stub_analytics expect(@analytics).to receive(:track_event). diff --git a/spec/controllers/concerns/idv_step_concern_spec.rb b/spec/controllers/concerns/idv_step_concern_spec.rb index 61d589fa6d9..36fbf7546fc 100644 --- a/spec/controllers/concerns/idv_step_concern_spec.rb +++ b/spec/controllers/concerns/idv_step_concern_spec.rb @@ -106,7 +106,7 @@ def show context 'user has started IdV session' do before do - idv_session.params = { first_name: 'Jane' } + idv_session.applicant = { first_name: 'Jane' } allow(subject).to receive(:idv_session).and_return(idv_session) end diff --git a/spec/controllers/health/health_controller_spec.rb b/spec/controllers/health/health_controller_spec.rb index 9fa15add5d9..24671855569 100644 --- a/spec/controllers/health/health_controller_spec.rb +++ b/spec/controllers/health/health_controller_spec.rb @@ -2,74 +2,55 @@ RSpec.describe Health::HealthController do describe '#index' do - subject(:action) { get :index } - - before do - allow(WorkerHealthChecker).to receive(:check). - and_return(WorkerHealthChecker::Summary.new(statuses)) - end - - let(:statuses) { [WorkerHealthChecker::Status.new('voice', 0.minutes.ago, true)] } - context 'when all checked resources are healthy' do - it 'is a 200' do - action - - expect(response.status).to eq(200) - end - - it 'renders the result' do - action + it 'returns a successful JSON response' do + allow(DatabaseHealthChecker).to receive(:simple_query).and_return('foo') + allow(AccountResetHealthChecker).to receive(:check). + and_return(AccountResetHealthChecker::Summary.new(true, 'foo')) + get :index json = JSON.parse(response.body, symbolize_names: true) + expect(response.status).to eq(200) expect(json[:healthy]).to eq(true) - expect(json[:statuses][:workers][:all_healthy]).to eq(true) expect(json[:statuses][:database][:healthy]).to eq(true) expect(json[:statuses][:account_reset][:healthy]).to eq(true) end end context 'when one resource is unhealthy' do - before do - expect(DatabaseHealthChecker).to receive(:simple_query). + it 'returns an unsuccessful JSON response' do + allow(DatabaseHealthChecker).to receive(:simple_query). and_raise(RuntimeError.new('canceling statement due to statement timeout')) - end - - it 'is a 500' do - action - - expect(response.status).to eq(500) - end - - it 'renders the error' do - action + allow(AccountResetHealthChecker).to receive(:check). + and_return(AccountResetHealthChecker::Summary.new(true, 'foo')) + get :index json = JSON.parse(response.body, symbolize_names: true) expect(json[:healthy]).to eq(false) expect(json[:statuses][:database][:result]). to include('canceling statement due to statement timeout') + expect(json[:statuses][:account_reset][:healthy]).to eq(true) + expect(response.status).to eq(500) end end - context 'when activejob queue_adapter is inline/async' do - before do - expect(Rails.application.config.active_job).to receive(:queue_adapter). - and_return(:inline) - end - - it 'does not check worker health' do - expect(WorkerHealthChecker).not_to receive(:check) - - action + context 'all resources are unhealthy' do + it 'returns an unsuccessful JSON response' do + allow(DatabaseHealthChecker).to receive(:simple_query). + and_raise(RuntimeError.new('canceling statement due to statement timeout')) + allow(AccountResetHealthChecker).to receive(:check). + and_return(AccountResetHealthChecker::Summary.new(false, 'foo')) - expect(response.status).to eq(200) + get :index json = JSON.parse(response.body, symbolize_names: true) - expect(json[:healthy]).to eq(true) - expect(json[:statuses][:workers]).to be_nil - expect(json[:statuses][:database][:healthy]).to eq(true) + expect(json[:healthy]).to eq(false) + expect(json[:statuses][:database][:result]). + to include('canceling statement due to statement timeout') + expect(json[:statuses][:account_reset][:healthy]).to eq(false) + expect(response.status).to eq(500) end end end diff --git a/spec/controllers/health/workers_controller_spec.rb b/spec/controllers/health/workers_controller_spec.rb deleted file mode 100644 index 5e304b66e01..00000000000 --- a/spec/controllers/health/workers_controller_spec.rb +++ /dev/null @@ -1,52 +0,0 @@ -require 'rails_helper' - -RSpec.describe Health::WorkersController do - describe '#index' do - before do - allow(WorkerHealthChecker).to receive(:check). - and_return(WorkerHealthChecker::Summary.new(statuses)) - end - - subject(:action) { get :index } - - let(:statuses) do - [ - WorkerHealthChecker::Status.new('voice', 0.minutes.ago, true), - WorkerHealthChecker::Status.new('sms', 0.minutes.ago, true), - ] - end - - it 'renders the responses as json' do - action - - json = JSON.parse(response.body) - - expect(json['all_healthy']).to eq(true) - expect(json['statuses'].first['queue']).to eq('voice') - expect(json['statuses'].first['healthy']).to eq(true) - end - - context 'with all healthy statuses' do - it 'is a 200' do - action - - expect(response.status).to eq(200) - end - end - - context 'with an unhealthy status' do - let(:statuses) do - [ - WorkerHealthChecker::Status.new('voice', 0.minutes.ago, true), - WorkerHealthChecker::Status.new('sms', nil, false), - ] - end - - it 'is a 500' do - action - - expect(response.status).to eq(500) - end - end - end -end diff --git a/spec/controllers/idv/confirmations_controller_spec.rb b/spec/controllers/idv/confirmations_controller_spec.rb index 713e51d88f6..e9763fe30d1 100644 --- a/spec/controllers/idv/confirmations_controller_spec.rb +++ b/spec/controllers/idv/confirmations_controller_spec.rb @@ -10,7 +10,7 @@ def stub_idv_session current_user: user, issuer: nil ) - idv_session.applicant = idv_session.vendor_params + idv_session.applicant = applicant idv_session.resolution_successful = true profile_maker = Idv::ProfileMaker.new( applicant: applicant, @@ -110,7 +110,8 @@ def index context 'user used 2FA phone as phone of record' do before do - subject.idv_session.params['phone'] = user.phone_configurations.first.phone + subject.idv_session.applicant['phone'] = + MfaContext.new(user).phone_configurations.first.phone end it 'tracks final IdV event' do @@ -130,7 +131,7 @@ def index context 'user confirmed a new phone' do before do - subject.idv_session.params['phone'] = '+1 (202) 555-9876' + subject.idv_session.applicant['phone'] = '+1 (202) 555-9876' end it 'tracks final IdV event' do diff --git a/spec/controllers/idv/doc_auth_controller_spec.rb b/spec/controllers/idv/doc_auth_controller_spec.rb new file mode 100644 index 00000000000..c9fd4a2cf35 --- /dev/null +++ b/spec/controllers/idv/doc_auth_controller_spec.rb @@ -0,0 +1,100 @@ +require 'rails_helper' + +describe Idv::DocAuthController do + include DocAuthHelper + + describe 'before_actions' do + it 'includes corrects before_actions' do + expect(subject).to have_actions(:before, + :confirm_two_factor_authenticated, + :fsm_initialize, + :ensure_correct_step) + end + end + + before do + enable_doc_auth + stub_sign_in + stub_analytics + allow(@analytics).to receive(:track_event) + end + + describe '#index' do + it 'redirects to the first step' do + get :index + + expect(response).to redirect_to idv_doc_auth_step_url(step: :ssn) + end + end + + describe '#show' do + it 'renders the front_image template' do + get :show, params: { step: 'ssn' } + + expect(response).to render_template :ssn + end + + it 'renders the front_image template' do + mock_next_step(:front_image) + get :show, params: { step: 'front_image' } + + expect(response).to render_template :front_image + end + + it 'renders the back_image template' do + mock_next_step(:back_image) + get :show, params: { step: 'back_image' } + + expect(response).to render_template :back_image + end + + it 'renders the self image template' do + mock_next_step(:self_image) + get :show, params: { step: 'self_image' } + + expect(response).to render_template :self_image + end + + it 'redirect to the right step' do + mock_next_step(:front_image) + get :show, params: { step: 'back_image' } + + expect(response).to redirect_to idv_doc_auth_step_url(:front_image) + end + + it 'renders a 404 with a non existent step' do + get :show, params: { step: 'foo' } + + expect(response).to_not be_not_found + end + + it 'tracks analytics' do + result = { step: 'ssn' } + + get :show, params: { step: 'ssn' } + + expect(@analytics).to have_received(:track_event).with( + Analytics::DOC_AUTH + ' visited', result + ) + end + end + + describe '#update' do + it 'renders the front_image template' do + end + + it 'tracks analytics' do + result = { success: true, errors: {}, step: 'ssn' } + + put :update, params: { step: 'ssn', doc_auth: { step: 'ssn', ssn: '111-11-1111' } } + + expect(@analytics).to have_received(:track_event).with( + Analytics::DOC_AUTH + ' submitted', result + ) + end + end + + def mock_next_step(step) + allow_any_instance_of(Idv::Flows::DocAuthFlow).to receive(:next_step).and_return(step) + end +end diff --git a/spec/controllers/idv/jurisdiction_controller_spec.rb b/spec/controllers/idv/jurisdiction_controller_spec.rb index 4d0a515dd67..8e295ccf36e 100644 --- a/spec/controllers/idv/jurisdiction_controller_spec.rb +++ b/spec/controllers/idv/jurisdiction_controller_spec.rb @@ -49,7 +49,7 @@ it 'puts the jurisdiction into the user session' do post :create, params: { jurisdiction: { state: supported_jurisdiction } } - expect(controller.user_session[:idv_jurisdiction]).to eq(supported_jurisdiction) + expect(controller.user_session[:idv][:selected_jurisdiction]).to eq(supported_jurisdiction) end context 'with an unsupported jurisdiction' do @@ -73,7 +73,7 @@ let(:reason) { 'unsupported_jurisdiction' } before do - controller.user_session[:idv_jurisdiction] = supported_jurisdiction + controller.user_session[:idv] = { selected_jurisdiction: supported_jurisdiction } end it 'renders the `_failure` template' do diff --git a/spec/controllers/idv/otp_delivery_method_controller_spec.rb b/spec/controllers/idv/otp_delivery_method_controller_spec.rb index 5faa5629464..0e5fe4f6756 100644 --- a/spec/controllers/idv/otp_delivery_method_controller_spec.rb +++ b/spec/controllers/idv/otp_delivery_method_controller_spec.rb @@ -6,7 +6,7 @@ before do stub_verify_steps_one_and_two(user) subject.idv_session.address_verification_mechanism = 'phone' - subject.idv_session.params[:phone] = '2255555000' + subject.idv_session.applicant[:phone] = '2255555000' subject.idv_session.vendor_phone_confirmation = true subject.idv_session.user_phone_confirmation = false end @@ -64,13 +64,7 @@ end describe '#create' do - let(:params) do - { - otp_delivery_selection_form: { - otp_delivery_preference: :sms, - }, - } - end + let(:params) { { otp_delivery_preference: :sms } } context 'user has not selected phone verification method' do before do @@ -130,13 +124,7 @@ end context 'user has selected voice' do - let(:params) do - { - otp_delivery_selection_form: { - otp_delivery_preference: :voice, - }, - } - end + let(:params) { { otp_delivery_preference: :voice } } it 'redirects to the otp send path for voice' do post :create, params: params @@ -162,13 +150,7 @@ end context 'form is invalid' do - let(:params) do - { - otp_delivery_selection_form: { - otp_delivery_preference: :🎷, - }, - } - end + let(:params) { { otp_delivery_preference: :🎷 } } it 'renders the new template' do post :create, params: params diff --git a/spec/controllers/idv/otp_verification_controller_spec.rb b/spec/controllers/idv/otp_verification_controller_spec.rb index fd516a2a51c..5102fe4d1dc 100644 --- a/spec/controllers/idv/otp_verification_controller_spec.rb +++ b/spec/controllers/idv/otp_verification_controller_spec.rb @@ -15,7 +15,7 @@ sign_in(user) stub_verify_steps_one_and_two(user) - subject.idv_session.params[:phone] = phone + subject.idv_session.applicant[:phone] = phone subject.idv_session.vendor_phone_confirmation = true subject.idv_session.user_phone_confirmation = user_phone_confirmation subject.idv_session.phone_confirmation_otp_delivery_method = diff --git a/spec/controllers/idv/phone_controller_spec.rb b/spec/controllers/idv/phone_controller_spec.rb index ff2aac508a5..002174cec8f 100644 --- a/spec/controllers/idv/phone_controller_spec.rb +++ b/spec/controllers/idv/phone_controller_spec.rb @@ -62,6 +62,21 @@ expect(response).to redirect_to idv_phone_failure_url(:fail) end + + context 'when there is a pending verification' do + let(:pending_profile) do + create(:profile, user_id: user.id, deactivation_reason: :verification_pending) + end + + it 'clears the pending verification' do + pending_profile + expect(Profile.all.first.deactivation_reason).to eq('verification_pending') + + get :new + + expect(Profile.all.first.deactivation_reason).to eq('verification_cancelled') + end + end end describe '#create' do @@ -77,11 +92,11 @@ put :create, params: { idv_phone_form: { phone: '703' } } expect(flash[:warning]).to be_nil - expect(subject.idv_session.params).to be_empty + expect(response).to render_template(:new) end it 'tracks form error and does not make a vendor API call' do - expect(Idv::Job).to_not receive(:submit) + expect(Idv::Proofer).to_not receive(:get_vendor) put :create, params: { idv_phone_form: { phone: '703' } } @@ -126,7 +141,7 @@ end context 'when same as user phone' do - it 'redirects to result page and sets phone_confirmed_at' do + it 'redirects to review page and sets phone_confirmed_at' do user = build(:user, :with_phone, with: { phone: good_phone, confirmed_at: Time.zone.now }) @@ -134,17 +149,21 @@ put :create, params: { idv_phone_form: { phone: good_phone } } - expect(response).to redirect_to idv_phone_result_path + expect(response).to redirect_to idv_review_path - expected_params = { + expected_applicant = { + first_name: 'Some', + last_name: 'One', phone: normalized_phone, - phone_confirmed_at: user.phone_configurations.first.confirmed_at, - } - expect(subject.idv_session.params).to eq expected_params + }.with_indifferent_access + + expect(subject.idv_session.applicant).to eq expected_applicant + expect(subject.idv_session.vendor_phone_confirmation).to eq true + expect(subject.idv_session.user_phone_confirmation).to eq true end end - context 'when different from user phone' do + context 'when different phone from user phone' do it 'redirects to otp page and does not set phone_confirmed_at' do user = build(:user, :with_phone, with: { phone: '+1 (415) 555-0130', confirmed_at: Time.zone.now @@ -153,151 +172,75 @@ put :create, params: { idv_phone_form: { phone: good_phone } } - expect(response).to redirect_to idv_phone_result_path + expect(response).to redirect_to idv_otp_delivery_method_path - expected_params = { - phone: normalized_phone, - phone_confirmed_at: nil, - } - expect(subject.idv_session.params).to eq expected_params + expect(subject.idv_session.vendor_phone_confirmation).to eq true + expect(subject.idv_session.user_phone_confirmation).to eq false end end - end - end - describe '#show' do - let(:user) do - build(:user, :with_phone, with: { phone: good_phone, confirmed_at: Time.zone.now }) - end - let(:params) { { phone: good_phone } } - - before do - stub_verify_steps_one_and_two(user) - controller.idv_session.params = params - end + it 'tracks event with valid phone' do + user = build(:user, with: { phone: '+1 (415) 555-0130', phone_confirmed_at: Time.zone.now }) + stub_verify_steps_one_and_two(user) - context 'when the background job is not complete yet' do - render_views + stub_analytics + allow(@analytics).to receive(:track_event) - it 'renders a spinner and has the page refresh' do - get :show + context = { stages: [{ address: 'AddressMock' }] } + result = { + success: true, + errors: {}, + vendor: { messages: [], context: context, exception: nil, timed_out: false }, + } - expect(response).to render_template('shared/refresh') + expect(@analytics).to receive(:track_event).ordered.with( + Analytics::IDV_PHONE_CONFIRMATION_FORM, hash_including(:success) + ) + expect(@analytics).to receive(:track_event).ordered.with( + Analytics::IDV_PHONE_CONFIRMATION_VENDOR, result + ) - dom = Nokogiri::HTML(response.body) - expect(dom.css('meta[http-equiv="refresh"]')).to be_present + put :create, params: { idv_phone_form: { phone: good_phone } } end end - context 'when the background job has timed out' do - let(:expired_started_at) do - Time.zone.now.to_i - Figaro.env.async_job_refresh_max_wait_seconds.to_i - end + context 'when verification fails' do + it 'renders failure page and does not set phone confirmation' do + user = build(:user, with: { phone: '+1 (415) 555-0130', phone_confirmed_at: Time.zone.now }) + stub_verify_steps_one_and_two(user) - before do - controller.idv_session.async_result_started_at = expired_started_at - end + put :create, params: { idv_phone_form: { phone: '7035555555' } } - it 'displays an error' do - get :show + expect(response).to redirect_to idv_phone_failure_path(reason: :warning) - expect(response).to redirect_to idv_phone_failure_path(:timeout) + expect(subject.idv_session.vendor_phone_confirmation).to be_falsy + expect(subject.idv_session.user_phone_confirmation).to be_falsy end - it 'tracks the failure as a timeout' do + it 'tracks event with invalid phone' do + user = build(:user, with: { phone: '+1 (415) 555-0130', phone_confirmed_at: Time.zone.now }) + stub_verify_steps_one_and_two(user) + stub_analytics allow(@analytics).to receive(:track_event) - get :show - + context = { stages: [{ address: 'AddressMock' }] } result = { success: false, - errors: { timed_out: ['Timed out waiting for vendor response'] }, - vendor: { messages: [], context: {}, exception: nil }, + errors: { + phone: ['The phone number could not be verified.'], + }, + vendor: { messages: [], context: context, exception: nil, timed_out: false }, } - expect(@analytics).to have_received(:track_event).with( + expect(@analytics).to receive(:track_event).ordered.with( + Analytics::IDV_PHONE_CONFIRMATION_FORM, hash_including(:success) + ) + expect(@analytics).to receive(:track_event).ordered.with( Analytics::IDV_PHONE_CONFIRMATION_VENDOR, result ) - end - end - - context 'when the background job has completed' do - let(:result_id) { SecureRandom.uuid } - - before do - controller.idv_session.async_result_id = result_id - VendorValidatorResultStorage.new.store(result_id: result_id, result: result) - end - - let(:result) { Idv::VendorResult.new(success: true) } - - context 'when the phone is invalid' do - let(:result) do - Idv::VendorResult.new( - success: false, - errors: { phone: ['The phone number could not be verified.'] } - ) - end - - let(:params) { { phone: bad_phone } } - let(:user) do - build(:user, :with_phone, with: { phone: bad_phone, confirmed_at: Time.zone.now }) - end - - it 'tracks event with invalid phone' do - stub_analytics - allow(@analytics).to receive(:track_event) - - get :show - - result = { - success: false, - errors: { - phone: ['The phone number could not be verified.'], - }, - vendor: { messages: [], context: {}, exception: nil }, - } - expect(response).to redirect_to idv_phone_failure_path(:warning) - expect(@analytics).to have_received(:track_event).with( - Analytics::IDV_PHONE_CONFIRMATION_VENDOR, result - ) - end - end - - context 'attempt window has expired, previous attempts == max-1' do - let(:two_days_ago) { Time.zone.now - 2.days } - let(:user) do - build(:user, :with_phone, with: { phone: good_phone, confirmed_at: Time.zone.now }) - end - - before do - user.idv_attempts = max_attempts - 1 - user.idv_attempted_at = two_days_ago - subject.idv_session.user_phone_confirmation = true - end - - it 'allows and does not affect attempt counter' do - get :show - - expect(response).to redirect_to idv_review_path - expect(user.idv_attempts).to eq(max_attempts - 1) - expect(user.idv_attempted_at).to eq two_days_ago - end - end - - it 'passes the normalized phone to the background job' do - user = build(:user, :with_phone, with: { phone: good_phone, confirmed_at: Time.zone.now }) - - stub_verify_steps_one_and_two(user) - - subject.params = { phone: normalized_phone } - expect(Idv::Job).to receive(:submit). - with(subject.idv_session, [:address]). - and_call_original - - put :create, params: { idv_phone_form: { phone: good_phone } } + put :create, params: { idv_phone_form: { phone: '7035555555' } } end end end diff --git a/spec/controllers/idv/resend_otp_controller_spec.rb b/spec/controllers/idv/resend_otp_controller_spec.rb index 7d73377f7c1..74c598aba22 100644 --- a/spec/controllers/idv/resend_otp_controller_spec.rb +++ b/spec/controllers/idv/resend_otp_controller_spec.rb @@ -13,7 +13,7 @@ sign_in(user) stub_verify_steps_one_and_two(user) - subject.idv_session.params[:phone] = phone + subject.idv_session.applicant[:phone] = phone subject.idv_session.vendor_phone_confirmation = true subject.idv_session.user_phone_confirmation = user_phone_confirmation subject.idv_session.phone_confirmation_otp_delivery_method = diff --git a/spec/controllers/idv/review_controller_spec.rb b/spec/controllers/idv/review_controller_spec.rb index b981a6d942f..4c607f449bc 100644 --- a/spec/controllers/idv/review_controller_spec.rb +++ b/spec/controllers/idv/review_controller_spec.rb @@ -20,7 +20,7 @@ city: 'Somewhere', state: 'KS', zipcode: zipcode, - phone: user.phone_configurations.first&.phone, + phone: MfaContext.new(user).phone_configurations.first&.phone, ssn: '12345678', } end @@ -32,7 +32,7 @@ ) idv_session.profile_confirmation = true idv_session.vendor_phone_confirmation = true - idv_session.params = user_attrs + idv_session.applicant = user_attrs idv_session end @@ -61,7 +61,7 @@ def show routes.draw do get 'show' => 'idv/review#show' end - idv_session.params = user_attrs + idv_session.applicant = user_attrs allow(subject).to receive(:idv_session).and_return(idv_session) allow(subject).to receive(:confirm_idv_attempts_allowed).and_return(true) end @@ -151,7 +151,7 @@ def show end allow(subject).to receive(:confirm_idv_steps_complete).and_return(true) allow(subject).to receive(:confirm_idv_attempts_allowed).and_return(true) - idv_session.params = user_attrs.merge(phone_confirmed_at: Time.zone.now) + idv_session.applicant = user_attrs.merge(phone_confirmed_at: Time.zone.now) allow(subject).to receive(:idv_session).and_return(idv_session) end @@ -191,7 +191,7 @@ def show context 'user has completed all steps' do before do - idv_session.params = user_attrs + idv_session.applicant = user_attrs end it 'shows completed session' do @@ -266,7 +266,7 @@ def show context 'user fails to supply correct password' do before do - idv_session.params = user_attrs.merge(phone_confirmed_at: Time.zone.now) + idv_session.applicant = user_attrs.merge(phone_confirmed_at: Time.zone.now) end it 'redirects to original path' do @@ -278,7 +278,7 @@ def show context 'user has completed all steps' do before do - idv_session.params = user_attrs + idv_session.applicant = user_attrs idv_session.applicant = idv_session.vendor_params stub_analytics allow(@analytics).to receive(:track_event) diff --git a/spec/controllers/idv/sessions_controller_spec.rb b/spec/controllers/idv/sessions_controller_spec.rb index 3c3a5f2ad5f..1affa55d209 100644 --- a/spec/controllers/idv/sessions_controller_spec.rb +++ b/spec/controllers/idv/sessions_controller_spec.rb @@ -21,7 +21,6 @@ let(:idv_session) do Idv::Session.new(user_session: subject.user_session, current_user: user, issuer: nil) end - let(:applicant) { user_attrs } describe 'before_actions' do it 'includes before_actions from AccountStateChecker' do @@ -35,306 +34,175 @@ end end - context 'user has created account' do - before do - stub_sign_in(user) - allow(subject).to receive(:idv_session).and_return(idv_session) - stub_analytics - allow(@analytics).to receive(:track_event) - end + before do + stub_sign_in(user) + allow(subject).to receive(:idv_session).and_return(idv_session) + stub_analytics + allow(@analytics).to receive(:track_event) + end - describe '#new' do - it 'starts new proofing session' do - get :new + describe '#new' do + it 'starts a new proofing session' do + get :new - expect(response.status).to eq 200 - end + expect(response.status).to eq 200 + end - it 'redirects if step is complete' do + context 'the user has already completed the step' do + it 'redirects to the success step' do idv_session.profile_confirmation = true + idv_session.resolution_successful = true get :new expect(response).to redirect_to idv_session_success_path end - - context 'max attempts exceeded' do - before do - user.idv_attempts = max_attempts - user.idv_attempted_at = Time.zone.now - end - - it 'redirects to fail' do - get :new - - result = { - request_path: idv_session_path, - } - - expect(@analytics).to have_received(:track_event). - with(Analytics::IDV_MAX_ATTEMPTS_EXCEEDED, result) - expect(response).to redirect_to idv_session_failure_url(:fail) - end - end end - describe '#create' do - before do - stub_analytics - allow(@analytics).to receive(:track_event) - end - - context 'UUID' do - it 'assigned user UUID to applicant' do - post :create, params: { profile: user_attrs } + context 'max attempts exceeded' do + it 'redirects to fail' do + user.idv_attempts = max_attempts + user.idv_attempted_at = Time.zone.now - expect(subject.idv_session.applicant['uuid']).to eq subject.current_user.uuid - end - end - - context 'existing SSN' do - it 'redirects to custom error' do - create(:profile, pii: { ssn: '666-66-1234' }) - - result = { - success: false, - errors: { ssn: [t('idv.errors.duplicate_ssn')] }, - } - - expect(@analytics).to receive(:track_event). - with(Analytics::IDV_BASIC_INFO_SUBMITTED_FORM, result) - - post :create, params: { profile: user_attrs.merge(ssn: '666-66-1234') } - - expect(response).to redirect_to(idv_session_failure_url(:dupe_ssn)) - end - end - - context 'empty SSN' do - it 'renders the form' do - post :create, params: { profile: user_attrs.merge(ssn: '') } - - expect(response).to_not redirect_to(idv_session_failure_url(:dupe_ssn)) - expect(response).to render_template(:new) - end - end - - context 'missing fields' do - let(:partial_attrs) do - user_attrs.tap { |attrs| attrs.delete :first_name } - end - - it 'checks for required fields' do - post :create, params: { profile: partial_attrs } + get :new - expect(response).to render_template(:new) - expect(flash[:warning]).to be_nil - end + result = { + request_path: idv_session_path, + } - it 'does not increment attempts count' do - expect { post :create, params: { profile: partial_attrs } }. - to_not change(user, :idv_attempts) - end + expect(@analytics).to have_received(:track_event). + with(Analytics::IDV_MAX_ATTEMPTS_EXCEEDED, result) + expect(response).to redirect_to idv_session_failure_url(:fail) end end + end - describe '#show' do - before do - stub_analytics - allow(@analytics).to receive(:track_event) - end - - context 'when the background job is not complete yet' do - render_views - - it 'renders a spinner and has the page refresh' do - get :show - - expect(response).to render_template('shared/refresh') - - dom = Nokogiri::HTML(response.body) - expect(dom.css('meta[http-equiv="refresh"]')).to be_present - end - end - - context 'when the background job has timed out' do - let(:expired_started_at) do - Time.zone.now.to_i - Figaro.env.async_job_refresh_max_wait_seconds.to_i - end - - before do - controller.idv_session.async_result_started_at = expired_started_at - controller.idv_session.params = user_attrs - end + describe '#create' do + it 'assigns a UUID to the applicant' do + post :create, params: { profile: user_attrs } - it 'displays an error' do - get :show + expect(subject.idv_session.applicant['uuid']).to eq subject.current_user.uuid + end - expect(response).to redirect_to(idv_session_failure_url(:timeout)) - end + it 'redirects to the SSN error if the SSN exists' do + create(:profile, pii: { ssn: '666-66-1234' }) - it 'tracks the failure as a timeout' do - stub_analytics - allow(@analytics).to receive(:track_event) + result = { + success: false, + errors: { ssn: [t('idv.errors.duplicate_ssn')] }, + } - get :show + expect(@analytics).to receive(:track_event). + with(Analytics::IDV_BASIC_INFO_SUBMITTED_FORM, result) - result = { - success: false, - errors: { timed_out: ['Timed out waiting for vendor response'] }, - idv_attempts_exceeded: false, - vendor: { messages: [], context: {}, exception: nil }, - } + post :create, params: { profile: user_attrs.merge(ssn: '666-66-1234') } - expect(@analytics).to have_received(:track_event).with( - Analytics::IDV_BASIC_INFO_SUBMITTED_VENDOR, result - ) - end - end + expect(response).to redirect_to(idv_session_failure_url(:dupe_ssn)) + expect(idv_session.profile_confirmation).to be_falsy + expect(idv_session.resolution_successful).to be_falsy + end - context 'when the background job has completed' do - let(:result_id) { SecureRandom.uuid } - let(:params) { user_attrs } + it 'renders the forms if there are missing fields' do + partial_attrs = user_attrs.tap { |attrs| attrs.delete :first_name } - before do - controller.idv_session.async_result_id = result_id - VendorValidatorResultStorage.new.store(result_id: result_id, result: result) + result = { + success: false, + errors: { first_name: [t('errors.messages.blank')] }, + } - controller.idv_session.params = params - end + expect(@analytics).to receive(:track_event). + with(Analytics::IDV_BASIC_INFO_SUBMITTED_FORM, result) - context 'un-resolvable attributes' do - let(:params) { user_attrs.dup.merge(first_name: 'Bad') } - - let(:result) do - Idv::VendorResult.new( - success: false, - errors: { first_name: ['Unverified first name.'] }, - messages: ['The name was suspicious'] - ) - end - - it 'redirects to the failure page' do - get :show - - expect(response).to redirect_to(idv_session_failure_url(:warning)) - end - - it 'creates analytics event' do - get :show - - result = { - success: false, - idv_attempts_exceeded: false, - errors: { - first_name: ['Unverified first name.'], - }, - vendor: { messages: ['The name was suspicious'], context: {}, exception: nil }, - } - - expect(@analytics).to have_received(:track_event). - with(Analytics::IDV_BASIC_INFO_SUBMITTED_VENDOR, result) - end - end + expect { post :create, params: { profile: partial_attrs } }. + to_not change(user, :idv_attempts) - context 'vendor agent throws exception' do - let(:params) { user_attrs.dup.merge(first_name: 'Fail') } - let(:exception_msg) { 'Failed to contact proofing vendor' } - let(:result) do - Idv::VendorResult.new( - success: false, - errors: { agent: [exception_msg] }, - messages: [exception_msg] - ) - end - - it 'logs failure and redirects to the failure page' do - get :show - - result = { - success: false, - idv_attempts_exceeded: false, - errors: { - agent: [exception_msg], - }, - vendor: { messages: [exception_msg], context: {}, exception: nil }, - } - - expect(@analytics).to have_received(:track_event). - with(Analytics::IDV_BASIC_INFO_SUBMITTED_VENDOR, result) - expect(response).to redirect_to(idv_session_failure_url(:warning)) - end - end + expect(response).to render_template(:new) + expect(flash[:warning]).to be_nil + expect(idv_session.profile_confirmation).to be_falsy + expect(idv_session.resolution_successful).to be_falsy + end - context 'success' do - let(:result) do - Idv::VendorResult.new( - success: true, - messages: ['Everything looks good'], - applicant: applicant - ) - end - - it 'creates analytics event' do - get :show - - result = { - success: true, - idv_attempts_exceeded: false, - errors: {}, - vendor: { messages: ['Everything looks good'], context: {}, exception: nil }, - } - - expect(@analytics).to have_received(:track_event). - with(Analytics::IDV_BASIC_INFO_SUBMITTED_VENDOR, result) - end - - it 'increments attempts count' do - expect { get :show }.to change(user, :idv_attempts).by(1) - end - end + it 'redirects to the warning page and increments attempts when verification fails' do + user_attrs[:first_name] = 'Bad' + + context = { stages: [{ resolution: 'ResolutionMock' }] } + result = { + success: false, + idv_attempts_exceeded: false, + errors: { + first_name: ['Unverified first name.'], + }, + vendor: { messages: [], context: context, exception: nil, timed_out: false }, + } + + expect(@analytics).to receive(:track_event).ordered. + with(Analytics::IDV_BASIC_INFO_SUBMITTED_FORM, hash_including(success: true)) + expect(@analytics).to receive(:track_event).ordered. + with(Analytics::IDV_BASIC_INFO_SUBMITTED_VENDOR, result) + + expect { post :create, params: { profile: user_attrs } }. + to change(user, :idv_attempts).by(1) + + expect(response).to redirect_to(idv_session_failure_url(:warning)) + expect(idv_session.profile_confirmation).to be_falsy + expect(idv_session.resolution_successful).to be_falsy + end - context 'max attempts exceeded' do - let(:result) { Idv::VendorResult.new(success: true) } + it 'redirects to the success page when verification succeeds' do + context = { stages: [{ resolution: 'ResolutionMock' }, { state_id: 'StateIdMock' }] } + result = { + success: true, + idv_attempts_exceeded: false, + errors: {}, + vendor: { messages: [], context: context, exception: nil, timed_out: false }, + } + + expect(@analytics).to receive(:track_event).ordered. + with(Analytics::IDV_BASIC_INFO_SUBMITTED_FORM, hash_including(success: true)) + expect(@analytics).to receive(:track_event).ordered. + with(Analytics::IDV_BASIC_INFO_SUBMITTED_VENDOR, result) + + expect { post :create, params: { profile: user_attrs } }. + to change(user, :idv_attempts).by(1) + + expect(response).to redirect_to(idv_session_success_url) + expect(idv_session.profile_confirmation).to eq(true) + expect(idv_session.resolution_successful).to eq(true) + end - before do - user.idv_attempts = max_attempts - user.idv_attempted_at = Time.zone.now - end + it 'redirects to the fail page when max attempts are exceeded' do + user.idv_attempts = max_attempts + user.idv_attempted_at = Time.zone.now - it 'redirects to fail' do - get :show + post :create, params: { profile: user_attrs } - result = { - request_path: idv_session_result_path, - } + result = { + request_path: idv_session_path, + } - expect(@analytics).to have_received(:track_event). - with(Analytics::IDV_MAX_ATTEMPTS_EXCEEDED, result) - expect(response).to redirect_to idv_session_failure_url(:fail) - end - end + expect(@analytics).to have_received(:track_event). + with(Analytics::IDV_MAX_ATTEMPTS_EXCEEDED, result) + expect(response).to redirect_to idv_session_failure_url(:fail) + expect(idv_session.profile_confirmation).to be_falsy + expect(idv_session.resolution_successful).to be_falsy + end + end - context 'attempt window has expired, previous attempts == max-1' do - let(:result) do - Idv::VendorResult.new(success: true, applicant: applicant) - end + describe '#failure' do + it 'renders the dup ssn error if the failure reason is dupe ssn' do + get :failure, params: { reason: :dupe_ssn } - before do - user.idv_attempts = max_attempts - 1 - user.idv_attempted_at = Time.zone.now - 2.days - end + expect(response).to render_template('shared/_failure') + end - it 'allows and resets attempt counter' do - get :show + it 'renders the error for the the given error case if the failure reason is not dupe ssn' do + expect(controller).to receive(:render_idv_step_failure).with(:sessions, :fail) - expect(response).to redirect_to idv_session_success_path - expect(user.idv_attempts).to eq 1 - end - end - end + get :failure, params: { reason: :fail } end + end + context 'user has created account' do describe '#failure' do context 'reason == :dupe_ssn' do it 'renders the dupe_ssn failure screen' do diff --git a/spec/controllers/idv_controller_spec.rb b/spec/controllers/idv_controller_spec.rb index ec974cfab0a..a4c1231288c 100644 --- a/spec/controllers/idv_controller_spec.rb +++ b/spec/controllers/idv_controller_spec.rb @@ -2,6 +2,10 @@ describe IdvController do describe '#index' do + before do + allow(FeatureManagement).to receive(:doc_auth_enabled?).and_return(false) + end + it 'tracks page visit' do stub_sign_in stub_analytics @@ -44,6 +48,16 @@ expect(response).to redirect_to reactivate_account_url end + + it 'redirects to doc auth if doc auth is enabled and exclusive' do + stub_sign_in + allow(FeatureManagement).to receive(:doc_auth_enabled?).and_return(true) + allow(FeatureManagement).to receive(:doc_auth_exclusive?).and_return(true) + + get :index + + expect(response).to redirect_to idv_doc_auth_path + end end describe '#activated' do diff --git a/spec/controllers/test/piv_cac_authentication_test_subject_controller_spec.rb b/spec/controllers/test/piv_cac_authentication_test_subject_controller_spec.rb index c691bd87d70..ff87a1acbfa 100644 --- a/spec/controllers/test/piv_cac_authentication_test_subject_controller_spec.rb +++ b/spec/controllers/test/piv_cac_authentication_test_subject_controller_spec.rb @@ -5,12 +5,11 @@ before(:each) do allow(Rails.env).to receive(:development?) { false } allow(Figaro.env).to receive(:enable_test_routes) { 'true' } - allow(Figaro.env).to receive(:piv_cac_enabled) { 'true' } end - describe 'FeatureManagement#development_and_piv_cac_entry_enabled?' do + describe 'FeatureManagement#development_and_identity_pki_disabled?' do it 'is disabled' do - expect(FeatureManagement.development_and_piv_cac_entry_enabled?).to be_falsey + expect(FeatureManagement.development_and_identity_pki_disabled?).to be_falsey end end @@ -39,12 +38,11 @@ before(:each) do allow(Rails.env).to receive(:development?) { true } allow(Figaro.env).to receive(:enable_test_routes) { 'true' } - allow(Figaro.env).to receive(:piv_cac_enabled) { 'true' } end - describe 'FeatureManagement#development_and_piv_cac_entry_enabled?' do + describe 'FeatureManagement#development_and_identity_pki_disabled?' do it 'is enabled' do - expect(FeatureManagement.development_and_piv_cac_entry_enabled?).to be_truthy + expect(FeatureManagement.development_and_identity_pki_disabled?).to be_truthy end end diff --git a/spec/controllers/two_factor_authentication/options_controller_spec.rb b/spec/controllers/two_factor_authentication/options_controller_spec.rb index a2ad7631828..812bb3d4fc8 100644 --- a/spec/controllers/two_factor_authentication/options_controller_spec.rb +++ b/spec/controllers/two_factor_authentication/options_controller_spec.rb @@ -24,6 +24,18 @@ describe '#create' do before { sign_in_before_2fa } + it 'redirects to login_two_factor_url for sms with piv/cac and webauthn disabled' do + piv_cac_webauthn_enabled('false') + + post :create, params: { two_factor_options_form: { selection: 'sms' } } + + expect(response).to redirect_to otp_send_url( \ + otp_delivery_selection_form: { otp_delivery_preference: 'sms' } + ) + + piv_cac_webauthn_enabled('true') + end + it 'redirects to login_two_factor_url if user selects sms' do post :create, params: { two_factor_options_form: { selection: 'sms' } } @@ -52,6 +64,12 @@ expect(response).to redirect_to login_two_factor_authenticator_url end + it 'redirects to login_two_factor_webauthn_url if user selects webauthn' do + post :create, params: { two_factor_options_form: { selection: 'webauthn' } } + + expect(response).to redirect_to login_two_factor_webauthn_url + end + it 'rerenders the page with errors on failure' do post :create, params: { two_factor_options_form: { selection: 'foo' } } @@ -74,4 +92,9 @@ post :create, params: { two_factor_options_form: { selection: 'sms' } } end end + + def piv_cac_webauthn_enabled(bool) + allow(Figaro.env).to receive(:webauthn_enabled) { bool } + Rails.application.reload_routes! + end end diff --git a/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb b/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb index da6606f3cb7..d04dc55eb49 100644 --- a/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb +++ b/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb @@ -100,7 +100,7 @@ end it 'displays flash error message' do - expect(flash[:error]).to eq t('devise.two_factor_authentication.invalid_otp') + expect(flash[:error]).to eq t('two_factor_authentication.invalid_otp') end end @@ -264,7 +264,8 @@ sign_in_as_user subject.user_session[:unconfirmed_phone] = '+1 (703) 555-5555' subject.user_session[:context] = 'confirmation' - @previous_phone_confirmed_at = subject.current_user.phone_configurations.first&.confirmed_at + @previous_phone_confirmed_at = + MfaContext.new(subject.current_user).phone_configurations.first&.confirmed_at subject.current_user.create_direct_otp stub_analytics allow(@analytics).to receive(:track_event) @@ -272,7 +273,7 @@ @mailer = instance_double(ActionMailer::MessageDelivery, deliver_later: true) allow(UserMailer).to receive(:phone_changed).with(subject.current_user). and_return(@mailer) - @previous_phone = subject.current_user.phone_configurations.first&.phone + @previous_phone = MfaContext.new(subject.current_user).phone_configurations.first&.phone end context 'user has an existing phone number' do @@ -322,10 +323,9 @@ end it 'does not update user phone or phone_confirmed_at attributes' do - expect(subject.current_user.phone_configurations.first.phone).to eq('+1 202-555-1212') - expect( - subject.current_user.phone_configurations.first.confirmed_at - ).to eq(@previous_phone_confirmed_at) + first_configuration = MfaContext.new(subject.current_user).phone_configurations.first + expect(first_configuration.phone).to eq('+1 202-555-1212') + expect(first_configuration.confirmed_at).to eq(@previous_phone_confirmed_at) end it 'renders :show' do @@ -333,7 +333,7 @@ end it 'displays error flash notice' do - expect(flash[:error]).to eq t('devise.two_factor_authentication.invalid_otp') + expect(flash[:error]).to eq t('two_factor_authentication.invalid_otp') end it 'tracks an event' do @@ -353,7 +353,7 @@ context 'when user does not have an existing phone number' do before do - subject.current_user.phone_configurations.clear + MfaContext.new(subject.current_user).phone_configurations.clear subject.current_user.create_direct_otp end diff --git a/spec/controllers/two_factor_authentication/personal_key_verification_controller_spec.rb b/spec/controllers/two_factor_authentication/personal_key_verification_controller_spec.rb index 84ee076916e..f011714cde1 100644 --- a/spec/controllers/two_factor_authentication/personal_key_verification_controller_spec.rb +++ b/spec/controllers/two_factor_authentication/personal_key_verification_controller_spec.rb @@ -94,7 +94,7 @@ post :create, params: payload expect(response).to render_template(:show) - expect(flash[:error]).to eq t('devise.two_factor_authentication.invalid_personal_key') + expect(flash[:error]).to eq t('two_factor_authentication.invalid_personal_key') end end @@ -120,7 +120,7 @@ post :create, params: payload expect(response).to render_template(:show) - expect(flash[:error]).to eq t('devise.two_factor_authentication.invalid_personal_key') + expect(flash[:error]).to eq t('two_factor_authentication.invalid_personal_key') end it 'tracks the max attempts event' do diff --git a/spec/controllers/two_factor_authentication/piv_cac_verification_controller_spec.rb b/spec/controllers/two_factor_authentication/piv_cac_verification_controller_spec.rb index 3f7dbc95674..d4ee8d27217 100644 --- a/spec/controllers/two_factor_authentication/piv_cac_verification_controller_spec.rb +++ b/spec/controllers/two_factor_authentication/piv_cac_verification_controller_spec.rb @@ -54,7 +54,13 @@ end it 'redirects to the profile' do - expect(subject.current_user).to receive(:confirm_piv_cac?).and_return(true) + mock_mfa = MfaContext.new(subject.current_user) + mock_piv_cac_configuration = mock_mfa.piv_cac_configuration + allow(mock_piv_cac_configuration).to receive(:mfa_confirmed?).and_return(true) + allow(mock_mfa).to receive(:piv_cac_configuration).and_return( + mock_piv_cac_configuration + ) + allow(MfaContext).to receive(:new).with(subject.current_user).and_return(mock_mfa) expect(subject.current_user.reload.second_factor_attempts_count).to eq 0 get :show, params: { token: 'good-token' } @@ -107,7 +113,7 @@ end it 'displays flash error message' do - expect(flash[:error]).to eq t('devise.two_factor_authentication.invalid_piv_cac') + expect(flash[:error]).to eq t('two_factor_authentication.invalid_piv_cac') end it 'resets the piv/cac session information' do @@ -131,7 +137,7 @@ end it 'displays flash error message' do - expect(flash[:error]).to eq t('devise.two_factor_authentication.invalid_piv_cac') + expect(flash[:error]).to eq t('two_factor_authentication.invalid_piv_cac') end it 'resets the piv/cac session information' do diff --git a/spec/controllers/two_factor_authentication/totp_verification_controller_spec.rb b/spec/controllers/two_factor_authentication/totp_verification_controller_spec.rb index 519dba532ac..d1323bd6558 100644 --- a/spec/controllers/two_factor_authentication/totp_verification_controller_spec.rb +++ b/spec/controllers/two_factor_authentication/totp_verification_controller_spec.rb @@ -59,7 +59,7 @@ end it 'displays flash error message' do - expect(flash[:error]).to eq t('devise.two_factor_authentication.invalid_otp') + expect(flash[:error]).to eq t('two_factor_authentication.invalid_otp') end end diff --git a/spec/controllers/two_factor_authentication/webauthn_verification_controller_spec.rb b/spec/controllers/two_factor_authentication/webauthn_verification_controller_spec.rb new file mode 100644 index 00000000000..3fb40cfa2ad --- /dev/null +++ b/spec/controllers/two_factor_authentication/webauthn_verification_controller_spec.rb @@ -0,0 +1,91 @@ +require 'rails_helper' + +describe TwoFactorAuthentication::WebauthnVerificationController do + include WebauthnVerificationHelper + + describe 'before_actions' do + it 'includes appropriate before_actions' do + expect(subject).to have_actions(:before, :confirm_webauthn_enabled) + end + end + + describe 'when not signed in' do + describe 'GET show' do + it 'redirects to root url' do + get :show + + expect(response).to redirect_to(root_url) + end + end + + describe 'patch confirm' do + it 'redirects to root url' do + patch :confirm + + expect(response).to redirect_to(root_url) + end + end + end + + describe 'when signed in before 2fa' do + before do + stub_analytics + sign_in_before_2fa + end + + describe 'GET show' do + it 'saves challenge in session and renders show' do + create_webauthn_configuration(controller.current_user) + get :show + + expect(subject.user_session[:webauthn_challenge].length).to eq(16) + expect(response).to render_template(:show) + end + + it 'redirects if no webauthn configured' do + get :show + + expect(response).to redirect_to(user_two_factor_authentication_url) + end + end + + describe 'patch confirm' do + let(:params) do + { + authenticator_data: authenticator_data, + client_data_json: client_data_json, + signature: signature, + credential_id: credential_id, + } + end + before do + controller.user_session[:webauthn_challenge] = challenge + create_webauthn_configuration(controller.current_user) + end + + it 'processes an invalid webauthn' do + # the wrong domain name is embedded in the assertion test data + patch :confirm, params: params + + expect(response).to redirect_to(login_two_factor_webauthn_url) + end + + it 'processes a valid webauthn' do + allow(WebauthnVerificationForm).to receive(:domain_name).and_return('localhost:3000') + patch :confirm, params: params + + expect(response).to redirect_to(account_url) + end + + it 'tracks the submission' do + allow(WebauthnVerificationForm).to receive(:domain_name).and_return('localhost:3000') + result = { context: 'authentication', errors: {}, multi_factor_auth_method: 'webauthn', + success: true } + expect(@analytics).to receive(:track_event). + with(Analytics::MULTI_FACTOR_AUTH, result) + + patch :confirm, params: params + end + end + end +end diff --git a/spec/controllers/users/personal_keys_controller_spec.rb b/spec/controllers/users/personal_keys_controller_spec.rb index 2eaab123ecd..386fd0a1608 100644 --- a/spec/controllers/users/personal_keys_controller_spec.rb +++ b/spec/controllers/users/personal_keys_controller_spec.rb @@ -129,14 +129,6 @@ expect(response).to redirect_to manage_personal_key_path end - it 'populates the flash when resending code' do - stub_sign_in - expect(flash[:success]).to be_nil - - post :create, params: { resend: true } - expect(flash[:success]).to eq t('notices.send_code.personal_key') - end - it 'tracks CSRF errors' do stub_sign_in stub_analytics diff --git a/spec/controllers/users/phone_setup_controller_spec.rb b/spec/controllers/users/phone_setup_controller_spec.rb index d96c2364cd6..a1afa5f8e90 100644 --- a/spec/controllers/users/phone_setup_controller_spec.rb +++ b/spec/controllers/users/phone_setup_controller_spec.rb @@ -21,7 +21,7 @@ expect(@analytics).to receive(:track_event). with(Analytics::USER_REGISTRATION_PHONE_SETUP_VISIT) expect(PhoneSetupPresenter).to receive(:new).with(user.otp_delivery_preference) - expect(UserPhoneForm).to receive(:new).with(user) + expect(UserPhoneForm).to receive(:new).with(user, nil) get :index diff --git a/spec/controllers/users/phones_controller_spec.rb b/spec/controllers/users/phones_controller_spec.rb index fbb5b11a0c9..a10ee8b3fb2 100644 --- a/spec/controllers/users/phones_controller_spec.rb +++ b/spec/controllers/users/phones_controller_spec.rb @@ -25,7 +25,9 @@ it 'lets user know they need to confirm their new phone' do expect(flash[:notice]).to eq t('devise.registrations.phone_update_needs_confirmation') - expect(user.phone_configurations.reload.first.phone).to_not eq '+1 202-555-4321' + expect( + MfaContext.new(user).phone_configurations.reload.first.phone + ).to_not eq '+1 202-555-4321' expect(@analytics).to have_received(:track_event). with(Analytics::PHONE_CHANGE_REQUESTED) expect(response).to redirect_to( @@ -47,7 +49,7 @@ otp_delivery_preference: 'sms' }, } - expect(user.phone_configurations.reload.first).to be_present + expect(MfaContext.new(user).phone_configurations.reload.first).to be_present expect(response).to render_template(:edit) end end @@ -60,7 +62,7 @@ allow(@analytics).to receive(:track_event) put :update, params: { - user_phone_form: { phone: second_user.phone_configurations.first.phone, + user_phone_form: { phone: MfaContext.new(second_user).phone_configurations.first.phone, international_code: 'US', otp_delivery_preference: 'sms' }, } @@ -68,8 +70,8 @@ it 'processes successfully and informs user' do expect(flash[:notice]).to eq t('devise.registrations.phone_update_needs_confirmation') - expect(user.phone_configurations.reload.first.phone).to_not eq( - second_user.phone_configurations.first.phone + expect(MfaContext.new(user).phone_configurations.reload.first.phone).to_not eq( + MfaContext.new(second_user).phone_configurations.first.phone ) expect(@analytics).to have_received(:track_event). with(Analytics::PHONE_CHANGE_REQUESTED) @@ -94,7 +96,7 @@ otp_delivery_preference: 'sms' }, } - expect(user.phone_configurations.first.phone).not_to eq invalid_phone + expect(MfaContext.new(user).phone_configurations.first.phone).not_to eq invalid_phone expect(response).to render_template(:edit) end end @@ -104,7 +106,7 @@ stub_sign_in(user) put :update, params: { - user_phone_form: { phone: user.phone_configurations.first.phone, + user_phone_form: { phone: MfaContext.new(user).phone_configurations.first.phone, international_code: 'US', otp_delivery_preference: 'sms' }, } diff --git a/spec/controllers/users/two_factor_authentication_controller_spec.rb b/spec/controllers/users/two_factor_authentication_controller_spec.rb index 2287da46a7f..d5dda6d4240 100644 --- a/spec/controllers/users/two_factor_authentication_controller_spec.rb +++ b/spec/controllers/users/two_factor_authentication_controller_spec.rb @@ -71,8 +71,12 @@ def index describe '#show' do context 'when user is piv/cac enabled' do it 'renders the piv/cac entry screen' do - stub_sign_in_before_2fa(build(:user)) - allow(subject.current_user).to receive(:piv_cac_enabled?).and_return(true) + user = build(:user) + stub_sign_in_before_2fa(user) + allow_any_instance_of( + TwoFactorAuthentication::PivCacPolicy + ).to receive(:enabled?).and_return(true) + get :show expect(response).to redirect_to login_two_factor_piv_cac_path @@ -81,14 +85,32 @@ def index context 'when user is TOTP enabled' do it 'renders the :confirm_totp view' do - stub_sign_in_before_2fa(build(:user)) - allow(subject.current_user).to receive(:totp_enabled?).and_return(true) + user = build(:user) + stub_sign_in_before_2fa(user) + allow_any_instance_of( + TwoFactorAuthentication::AuthAppPolicy + ).to receive(:enabled?).and_return(true) + get :show expect(response).to redirect_to login_two_factor_authenticator_path end end + context 'when user is webauthn enabled' do + it 'renders the :webauthn view' do + stub_sign_in_before_2fa(build(:user, :with_webauthn)) + + allow_any_instance_of( + TwoFactorAuthentication::WebauthnPolicy + ).to receive(:enabled?).and_return(true) + + get :show + + expect(response).to redirect_to login_two_factor_webauthn_path + end + end + context 'when there is no session (signed out or locked out), and the user reloads the page' do it 'redirects to the home page' do expect(controller.user_session).to be_nil @@ -134,7 +156,7 @@ def index expect(SmsOtpSenderJob).to have_received(:perform_later).with( code: subject.current_user.direct_otp, - phone: subject.current_user.phone_configurations.first.phone, + phone: MfaContext.new(subject.current_user).phone_configurations.first.phone, otp_created_at: subject.current_user.direct_otp_sent_at.to_s, message: 'jobs.sms_otp_sender_job.login_message', locale: nil @@ -151,7 +173,7 @@ def index expect(SmsOtpSenderJob).to have_received(:perform_later).with( code: subject.current_user.direct_otp, - phone: subject.current_user.phone_configurations.first.phone, + phone: MfaContext.new(subject.current_user).phone_configurations.first.phone, otp_created_at: subject.current_user.direct_otp_sent_at.to_s, message: 'jobs.sms_otp_sender_job.login_message', locale: nil @@ -180,7 +202,7 @@ def index it 'calls OtpRateLimiter#exceeded_otp_send_limit? and #increment' do otp_rate_limiter = instance_double(OtpRateLimiter) allow(OtpRateLimiter).to receive(:new).with( - phone: @user.phone_configurations.first.phone, + phone: MfaContext.new(@user).phone_configurations.first.phone, user: @user ).and_return(otp_rate_limiter) @@ -218,7 +240,7 @@ def index expect(VoiceOtpSenderJob).to have_received(:perform_later).with( code: subject.current_user.direct_otp, - phone: subject.current_user.phone_configurations.first.phone, + phone: MfaContext.new(subject.current_user).phone_configurations.first.phone, otp_created_at: subject.current_user.direct_otp_sent_at.to_s, locale: nil ) diff --git a/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb b/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb index e5af8d4b2e5..a722d4bd861 100644 --- a/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb +++ b/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb @@ -138,6 +138,20 @@ end end + context 'when the selection is webauthn' do + it 'redirects to webauthn setup page' do + stub_sign_in_before_2fa + + patch :create, params: { + two_factor_options_form: { + selection: 'webauthn', + }, + } + + expect(response).to redirect_to webauthn_setup_url + end + end + context 'when the selection is piv_cac' do it 'redirects to piv/cac setup page' do stub_sign_in_before_2fa diff --git a/spec/controllers/users/webauthn_setup_controller_spec.rb b/spec/controllers/users/webauthn_setup_controller_spec.rb index 2ac17d1fe70..eb26e3390d5 100644 --- a/spec/controllers/users/webauthn_setup_controller_spec.rb +++ b/spec/controllers/users/webauthn_setup_controller_spec.rb @@ -31,10 +31,11 @@ end end - describe 'when signed in' do + describe 'when signed in and not account creation' do before do stub_analytics - stub_sign_in + user = build(:user, personal_key: 'ABCD-DEFG-HIJK-LMNO') + stub_sign_in(user) end describe 'GET new' do @@ -67,7 +68,7 @@ controller.user_session[:webauthn_challenge] = challenge end - it 'processes a valid webauthn' do + it 'processes a valid webauthn and redirects to account page' do patch :confirm, params: params expect(response).to redirect_to(account_url) @@ -85,7 +86,14 @@ describe 'delete' do before do - allow(controller.current_user).to receive(:total_mfa_options_enabled).and_return(2) + mock_mfa = MfaContext.new(controller.current_user) + allow(mock_mfa).to receive(:enabled_two_factor_configurations_count).and_return(2) + allow(MfaContext).to receive(:new).with(controller.current_user).and_return(mock_mfa) + mock_mfa_policy = MfaPolicy.new(controller.current_user) + allow(mock_mfa_policy).to receive(:multiple_factors_enabled?).and_return(true) + allow(MfaPolicy).to receive(:new).with( + controller.current_user + ).and_return(mock_mfa_policy) end it 'deletes a webauthn configuration' do @@ -108,6 +116,33 @@ end end + describe 'when signed in and account creation' do + before do + user = build(:user) + stub_sign_in(user) + end + + describe 'patch confirm' do + let(:params) do + { + attestation_object: attestation_object, + client_data_json: client_data_json, + name: 'mykey', + } + end + before do + allow(Figaro.env).to receive(:domain_name).and_return('localhost:3000') + controller.user_session[:webauthn_challenge] = challenge + end + + it 'processes a valid webauthn and redirects to personal key page' do + patch :confirm, params: params + + expect(response).to redirect_to(sign_up_personal_key_url) + end + end + end + def create_webauthn_configuration(user, name, id, key) WebauthnConfiguration.create(user_id: user.id, credential_public_key: key, diff --git a/spec/decorators/mfa_context_spec.rb b/spec/decorators/mfa_context_spec.rb new file mode 100644 index 00000000000..e3856d3dc70 --- /dev/null +++ b/spec/decorators/mfa_context_spec.rb @@ -0,0 +1,120 @@ +require 'rails_helper' + +describe MfaContext do + let(:mfa) { MfaContext.new(user) } + + context 'with no user' do + let(:user) {} + + describe '#auth_app_configuration' do + it 'returns a AuthAppConfiguration object' do + expect(mfa.auth_app_configuration).to be_a AuthAppConfiguration + end + end + + describe '#piv_cac_configuration' do + it 'returns a PivCacConfiguration object' do + expect(mfa.piv_cac_configuration).to be_a PivCacConfiguration + end + end + + describe '#phone_configurations' do + it 'is empty' do + expect(mfa.phone_configurations).to be_empty + end + end + + describe '#webauthn_configurations' do + it 'is empty' do + expect(mfa.webauthn_configurations).to be_empty + end + + it 'has #selection_presenters defined' do + expect(mfa.webauthn_configurations).to respond_to(:selection_presenters) + end + + it 'has no selection presenters' do + expect(mfa.webauthn_configurations.selection_presenters).to be_empty + end + end + end + + context 'with a user' do + let(:user) { create(:user) } + + describe '#auth_app_configuration' do + it 'returns a AuthAppConfiguration object' do + expect(mfa.auth_app_configuration).to be_a AuthAppConfiguration + end + end + + describe '#piv_cac_configuration' do + it 'returns a PivCacConfiguration object' do + expect(mfa.piv_cac_configuration).to be_a PivCacConfiguration + end + end + + describe '#phone_configurations' do + it 'mirrors the user relationship' do + expect(mfa.phone_configurations).to eq user.phone_configurations + end + end + + describe '#webauthn_configurations' do + context 'with no user' do + let(:user) {} + + it 'is empty' do + expect(mfa.webauthn_configurations).to be_empty + end + + it 'has #selection_presenters defined' do + expect(mfa.webauthn_configurations).to respond_to(:selection_presenters) + end + + it 'has no selection presenters' do + expect(mfa.webauthn_configurations.selection_presenters).to be_empty + end + end + + describe '#selection_presenters' do + it 'is defined' do + expect(mfa.webauthn_configurations).to respond_to(:selection_presenters) + end + + context 'with no webauthn_configurations' do + it 'is empty' do + expect(mfa.webauthn_configurations.selection_presenters).to be_empty + end + end + + context 'with webauthn enabled' do + before(:each) do + allow(FeatureManagement).to receive(:webauthn_enabled?).and_return(true) + end + + context 'with one webauthn_configuration' do + let(:user) { create(:user, :with_webauthn) } + + it 'has one element' do + expect(mfa.webauthn_configurations.selection_presenters.count).to eq 1 + end + end + + context 'with more than one webauthn_configuration' do + let(:user) do + record = create(:user) + create_list(:webauthn_configuration, 3, user: record) + record.webauthn_configurations.reload + record + end + + it 'has one element' do + expect(mfa.webauthn_configurations.selection_presenters.count).to eq 1 + end + end + end + end + end + end +end diff --git a/spec/factories/email_addresses.rb b/spec/factories/email_addresses.rb new file mode 100644 index 00000000000..d7f45cff689 --- /dev/null +++ b/spec/factories/email_addresses.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + Faker::Config.locale = :en + + factory :email_address do + confirmed_at { Time.zone.now } + email { 'jd@example.com' } + association :user + end +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index f9d0be59544..d8335391b33 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -10,6 +10,45 @@ email { Faker::Internet.safe_email } password { '!1a Z@6s' * 16 } # Maximum length password. + trait :with_webauthn do + after(:build) do |user, evaluator| + if user.webauthn_configurations.empty? + user.save! + if user.id.present? + create(:webauthn_configuration, + { user: user }.merge( + evaluator.with.slice(:name, :credential_id, :credential_public_key) + )) + user.webauthn_configurations.reload + else + user.webauthn_configurations << build( + :webauthn_configuration, + evaluator.with.slice(:name, :credential_id, :credential_public_key) + ) + end + end + end + + after(:create) do |user, evaluator| + if user.webauthn_configurations.empty? + create(:webauthn_configuration, + { user: user }.merge( + evaluator.with.slice(:name, :credential_id, :credential_public_key) + )) + user.webauthn_configurations.reload + end + end + + after(:stub) do |user, evaluator| + if user.webauthn_configurations.empty? + user.webauthn_configurations << build( + :webauthn_configuration, + evaluator.with.slice(:name, :credential_id, :credential_public_key) + ) + end + end + end + trait :with_phone do after(:build) do |user, evaluator| if user.phone_configurations.empty? @@ -19,7 +58,7 @@ { user: user, delivery_preference: user.otp_delivery_preference }.merge( evaluator.with.slice(:phone, :confirmed_at, :delivery_preference, :mfa_enabled) )) - user.reload + user.phone_configurations.reload else user.phone_configurations << build( :phone_configuration, @@ -37,7 +76,7 @@ { user: user, delivery_preference: user.otp_delivery_preference }.merge( evaluator.with.slice(:phone, :confirmed_at, :delivery_preference, :mfa_enabled) )) - user.reload + user.phone_configurations.reload end end diff --git a/spec/factories/webauthn_configurations.rb b/spec/factories/webauthn_configurations.rb new file mode 100644 index 00000000000..6fb2ce52aa9 --- /dev/null +++ b/spec/factories/webauthn_configurations.rb @@ -0,0 +1,10 @@ +FactoryBot.define do + Faker::Config.locale = :en + + factory :webauthn_configuration do + sequence(:name) { |n| "token #{n}" } + sequence(:credential_id) { |n| "credential #{n}" } + sequence(:credential_public_key) { |n| "key #{n}" } + user { association :user } + end +end diff --git a/spec/features/accessibility/idv_pages_spec.rb b/spec/features/accessibility/idv_pages_spec.rb index e3e1377736d..1d2cbb3f544 100644 --- a/spec/features/accessibility/idv_pages_spec.rb +++ b/spec/features/accessibility/idv_pages_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' require 'axe/rspec' -feature 'Accessibility on IDV pages', :js, idv_job: true do +feature 'Accessibility on IDV pages', :js do describe 'IDV pages' do include IdvHelper diff --git a/spec/features/account_reset/cancel_request_spec.rb b/spec/features/account_reset/cancel_request_spec.rb index d2524fac848..0596e0d54f7 100644 --- a/spec/features/account_reset/cancel_request_spec.rb +++ b/spec/features/account_reset/cancel_request_spec.rb @@ -7,14 +7,15 @@ user = create(:user, :signed_up) signin(user.email, user.password) click_link t('two_factor_authentication.login_options_link_text') - click_link t('devise.two_factor_authentication.account_reset.link') + click_link t('two_factor_authentication.account_reset.link') click_button t('account_reset.request.yes_continue') open_last_email click_email_link_matching(/cancel\?token/) + click_button t('account_reset.cancel_request.cancel_button') expect(page).to have_current_path new_user_session_path expect(page). - to have_content t('devise.two_factor_authentication.account_reset.successful_cancel') + to have_content t('two_factor_authentication.account_reset.successful_cancel') signin(user.email, user.password) @@ -29,18 +30,19 @@ user = create(:user, :signed_up) signin(user.email, user.password) click_link t('two_factor_authentication.login_options_link_text') - click_link t('devise.two_factor_authentication.account_reset.link') + click_link t('two_factor_authentication.account_reset.link') click_button t('account_reset.request.yes_continue') reset_email Timecop.travel(Time.zone.now + 2.days) do - AccountResetService.grant_tokens_and_send_notifications + AccountReset::GrantRequestsAndSendEmails.new.call open_last_email click_email_link_matching(/cancel\?token/) + click_button t('account_reset.cancel_request.cancel_button') expect(page).to have_current_path new_user_session_path expect(page). - to have_content t('devise.two_factor_authentication.account_reset.successful_cancel') + to have_content t('two_factor_authentication.account_reset.successful_cancel') signin(user.email, user.password) diff --git a/spec/features/account_reset/delete_account_spec.rb b/spec/features/account_reset/delete_account_spec.rb index 6d260c2c291..1439fe77fa5 100644 --- a/spec/features/account_reset/delete_account_spec.rb +++ b/spec/features/account_reset/delete_account_spec.rb @@ -11,7 +11,7 @@ it 'allows the user to delete their account after 24 hours' do signin(user.email, user.password) click_link t('two_factor_authentication.login_options_link_text') - click_link t('devise.two_factor_authentication.account_reset.link') + click_link t('two_factor_authentication.account_reset.link') click_button t('account_reset.request.yes_continue') expect(page). @@ -24,7 +24,7 @@ reset_email Timecop.travel(Time.zone.now + 2.days) do - AccountResetService.grant_tokens_and_send_notifications + AccountReset::GrantRequestsAndSendEmails.new.call open_last_email click_email_link_matching(/delete_account\?token/) @@ -58,7 +58,7 @@ user = create(:user, :with_authentication_app) signin(user.email, user.password) click_link t('two_factor_authentication.login_options_link_text') - click_link t('devise.two_factor_authentication.account_reset.link') + click_link t('two_factor_authentication.account_reset.link') click_button t('account_reset.request.yes_continue') expect(page). @@ -90,7 +90,7 @@ click_link t('two_factor_authentication.login_options_link_text') # Account reset link should not be present - expect(page).to_not have_content(t('devise.two_factor_authentication.account_reset.link')) + expect(page).to_not have_content(t('two_factor_authentication.account_reset.link')) # Visiting account reset directly should redirect to 2FA visit account_reset_request_path diff --git a/spec/features/flows/sp_authentication_flows_spec.rb b/spec/features/flows/sp_authentication_flows_spec.rb index 2d5f9f66598..3e213eb2a87 100644 --- a/spec/features/flows/sp_authentication_flows_spec.rb +++ b/spec/features/flows/sp_authentication_flows_spec.rb @@ -7,7 +7,7 @@ I18n.available_locales.each do |locale| context "with locale=#{locale}" do context 'with a valid SP' do - context 'when LOA3', :idv_job do + context 'when LOA3' do before do visit "#{loa3_authnrequest}&locale=#{locale}" end @@ -63,7 +63,7 @@ context 'with SMS delivery' do before do - choose t('devise.two_factor_authentication.otp_delivery_preference.sms') + choose t('two_factor_authentication.otp_delivery_preference.sms') click_send_security_code end @@ -241,7 +241,7 @@ context 'with SMS delivery' do before do - choose t('devise.two_factor_authentication.otp_delivery_preference.sms') + choose t('two_factor_authentication.otp_delivery_preference.sms') click_send_security_code end @@ -252,7 +252,7 @@ context 'with Voice delivery' do before do - choose t('devise.two_factor_authentication.otp_delivery_preference.voice') + choose t('two_factor_authentication.otp_delivery_preference.voice') click_send_security_code end diff --git a/spec/features/idv/account_creation_spec.rb b/spec/features/idv/account_creation_spec.rb index a76067bc3cd..5d07c25a61a 100644 --- a/spec/features/idv/account_creation_spec.rb +++ b/spec/features/idv/account_creation_spec.rb @@ -3,7 +3,11 @@ describe 'LOA3 account creation' do include IdvHelper include SamlAuthHelper + include WebauthnHelper it_behaves_like 'creating an LOA3 account using authenticator app for 2FA', :saml it_behaves_like 'creating an LOA3 account using authenticator app for 2FA', :oidc + + it_behaves_like 'creating an LOA3 account using webauthn for 2FA', :saml + it_behaves_like 'creating an LOA3 account using webauthn for 2FA', :oidc end diff --git a/spec/features/idv/actions/reset_action_spec.rb b/spec/features/idv/actions/reset_action_spec.rb new file mode 100644 index 00000000000..1b94670458b --- /dev/null +++ b/spec/features/idv/actions/reset_action_spec.rb @@ -0,0 +1,19 @@ +require 'rails_helper' + +feature 'doc auth reset action' do + include IdvStepHelper + include DocAuthHelper + + before do + enable_doc_auth + complete_doc_auth_steps_before_front_image_step + end + + it 'resets doc auth to the first step' do + expect(page).to have_current_path(idv_doc_auth_front_image_step) + + click_on t('doc_auth.buttons.start_over') + + expect(page).to have_current_path(idv_doc_auth_ssn_step) + end +end diff --git a/spec/features/idv/doc_auth/back_image_step_spec.rb b/spec/features/idv/doc_auth/back_image_step_spec.rb new file mode 100644 index 00000000000..31a221ccb1b --- /dev/null +++ b/spec/features/idv/doc_auth/back_image_step_spec.rb @@ -0,0 +1,51 @@ +require 'rails_helper' + +feature 'doc auth back image step' do + include IdvStepHelper + include DocAuthHelper + + before do + enable_doc_auth + complete_doc_auth_steps_before_back_image_step + mock_assure_id_ok + end + + it 'is on the correct page' do + expect(page).to have_current_path(idv_doc_auth_back_image_step) + expect(page).to have_content(t('doc_auth.headings.upload_back')) + end + + it 'proceeds to the next page with valid info' do + attach_image + click_idv_continue + + expect(page).to have_current_path(idv_doc_auth_doc_success_step) + end + + it 'does not proceed to the next page if resolution fails' do + allow_any_instance_of(Idv::Agent).to receive(:proof). + and_return(success: false, errors: {}) + attach_image + click_idv_continue + + expect(page).to have_current_path(idv_doc_auth_doc_failed_step) + end + + it 'does not proceed to the next page with invalid info' do + allow_any_instance_of(Idv::Acuant::AssureId).to receive(:post_back_image). + and_return([false, '']) + attach_image + click_idv_continue + + expect(page).to have_current_path(idv_doc_auth_back_image_step) + end + + it 'does not proceed to the next page with result=2' do + allow_any_instance_of(Idv::Acuant::AssureId).to receive(:results). + and_return([true, assure_id_results_with_result_2]) + attach_image + click_idv_continue + + expect(page).to have_current_path(idv_doc_auth_back_image_step) + end +end diff --git a/spec/features/idv/doc_auth/doc_failed_step_spec.rb b/spec/features/idv/doc_auth/doc_failed_step_spec.rb new file mode 100644 index 00000000000..52294fb8254 --- /dev/null +++ b/spec/features/idv/doc_auth/doc_failed_step_spec.rb @@ -0,0 +1,16 @@ +require 'rails_helper' + +feature 'doc auth fail step' do + include IdvStepHelper + include DocAuthHelper + + before do + enable_doc_auth + complete_doc_auth_steps_before_doc_failed_step + end + + it 'is on the correct page' do + expect(page).to have_current_path(idv_doc_auth_doc_failed_step) + expect(page).to have_content(t('doc_auth.errors.state_id_fail')) + end +end diff --git a/spec/features/idv/doc_auth/doc_success_step_spec.rb b/spec/features/idv/doc_auth/doc_success_step_spec.rb new file mode 100644 index 00000000000..7ae336cca41 --- /dev/null +++ b/spec/features/idv/doc_auth/doc_success_step_spec.rb @@ -0,0 +1,22 @@ +require 'rails_helper' + +feature 'doc auth success step' do + include IdvStepHelper + include DocAuthHelper + + before do + enable_doc_auth + complete_doc_auth_steps_before_doc_success_step + end + + it 'is on the correct page' do + expect(page).to have_current_path(idv_doc_auth_doc_success_step) + expect(page).to have_content(t('doc_auth.forms.doc_success')) + end + + it 'proceeds to the next page with valid info' do + click_idv_continue + + expect(page).to have_current_path(idv_doc_auth_self_image_step) + end +end diff --git a/spec/features/idv/doc_auth/front_image_step_spec.rb b/spec/features/idv/doc_auth/front_image_step_spec.rb new file mode 100644 index 00000000000..29eb2e4c064 --- /dev/null +++ b/spec/features/idv/doc_auth/front_image_step_spec.rb @@ -0,0 +1,32 @@ +require 'rails_helper' + +feature 'doc auth front image step' do + include IdvStepHelper + include DocAuthHelper + + before do + enable_doc_auth + complete_doc_auth_steps_before_front_image_step + mock_assure_id_ok + end + + it 'is on the correct page' do + expect(page).to have_current_path(idv_doc_auth_front_image_step) + expect(page).to have_content(t('doc_auth.headings.upload_front')) + end + + it 'proceeds to the next page with valid info' do + attach_image + click_idv_continue + + expect(page).to have_current_path(idv_doc_auth_back_image_step) + end + + it 'does not proceed to the next page with invalid info' do + mock_assure_id_fail + attach_image + click_idv_continue + + expect(page).to have_current_path(idv_doc_auth_front_image_step) + end +end diff --git a/spec/features/idv/doc_auth/self_image_step_spec.rb b/spec/features/idv/doc_auth/self_image_step_spec.rb new file mode 100644 index 00000000000..b3b42981153 --- /dev/null +++ b/spec/features/idv/doc_auth/self_image_step_spec.rb @@ -0,0 +1,40 @@ +require 'rails_helper' + +feature 'doc auth self image step' do + include IdvStepHelper + include DocAuthHelper + + before do + enable_doc_auth + complete_doc_auth_steps_before_self_image_step + mock_assure_id_ok + end + + it 'is on the correct page' do + expect(page).to have_current_path(idv_doc_auth_self_image_step) + expect(page).to have_content(t('doc_auth.headings.selfie')) + end + + it 'proceeds to the next page with valid info' do + first('input#_doc_auth_image', visible: false).set('data:image/png;base64,abc') + click_idv_continue + + expect(page).to have_current_path(idv_review_url) + end + + it 'does not proceed to the next page with invalid info' do + allow_any_instance_of(Idv::Acuant::AssureId).to receive(:face_image).and_return([false, '']) + + click_idv_continue + + expect(page).to have_current_path(idv_doc_auth_self_image_step) + end + + it 'creates a doc auth record' do + first('input#_doc_auth_image', visible: false).set('data:image/png;base64,abc') + click_idv_continue + + expect(DocAuth.count).to eq(1) + expect(DocAuth.all[0].license_confirmed_at).to be_present + end +end diff --git a/spec/features/idv/doc_auth/ssn_step_spec.rb b/spec/features/idv/doc_auth/ssn_step_spec.rb new file mode 100644 index 00000000000..7c969d9fff6 --- /dev/null +++ b/spec/features/idv/doc_auth/ssn_step_spec.rb @@ -0,0 +1,33 @@ +require 'rails_helper' + +feature 'doc auth ssn step' do + include IdvStepHelper + include DocAuthHelper + + before do + enable_doc_auth + complete_doc_auth_steps_before_ssn_step + end + + it 'is on the correct page' do + expect(page).to have_current_path(idv_doc_auth_ssn_step) + expect(page).to have_content(t('doc_auth.headings.ssn')) + end + + it 'proceeds to the next page with valid info' do + fill_out_ssn_form_ok + click_idv_continue + + expect(page).to have_current_path(idv_doc_auth_front_image_step) + end + + it 'does not proceed to the next page with invalid info' do + fill_out_ssn_form_fail + click_idv_continue + + expect(page).to have_current_path(idv_doc_auth_ssn_step) + end + + # it 'prevents a duplicate ssn' do + # end +end diff --git a/spec/features/idv/phone_input_spec.rb b/spec/features/idv/phone_input_spec.rb index 8deb53e9abc..6b39470949e 100644 --- a/spec/features/idv/phone_input_spec.rb +++ b/spec/features/idv/phone_input_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -feature 'IdV phone number input', :idv_job, :js do +feature 'IdV phone number input', :js do include IdvStepHelper before do diff --git a/spec/features/idv/phone_otp_rate_limiting_spec.rb b/spec/features/idv/phone_otp_rate_limiting_spec.rb index 713333e2596..925e4e34f2d 100644 --- a/spec/features/idv/phone_otp_rate_limiting_spec.rb +++ b/spec/features/idv/phone_otp_rate_limiting_spec.rb @@ -61,7 +61,7 @@ def expect_max_otp_request_rate_limiting expect(page).to have_content t('titles.account_locked') expect(page).to have_content t( - 'devise.two_factor_authentication.max_otp_requests_reached' + 'two_factor_authentication.max_otp_requests_reached' ) expect_rate_limit_circumvention_to_be_disallowed(user) @@ -83,7 +83,7 @@ def expect_max_otp_request_rate_limiting expect(page).to have_content t('titles.account_locked') expect(page). - to have_content t('devise.two_factor_authentication.max_otp_login_attempts_reached') + to have_content t('two_factor_authentication.max_otp_login_attempts_reached') expect_rate_limit_circumvention_to_be_disallowed(user) expect_rate_limit_to_expire(user) diff --git a/spec/features/idv/sp_handoff_spec.rb b/spec/features/idv/sp_handoff_spec.rb index 64f68dcb864..62ed8302980 100644 --- a/spec/features/idv/sp_handoff_spec.rb +++ b/spec/features/idv/sp_handoff_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -feature 'IdV SP handoff', :idv_job, :email do +feature 'IdV SP handoff', :email do include SamlAuthHelper include IdvHelper diff --git a/spec/features/idv/sp_requested_attributes_spec.rb b/spec/features/idv/sp_requested_attributes_spec.rb index bf383f07f01..f070653f0a6 100644 --- a/spec/features/idv/sp_requested_attributes_spec.rb +++ b/spec/features/idv/sp_requested_attributes_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -feature 'sp requested IdV attributes', :idv_job, :email do +feature 'sp requested IdV attributes', :email do context 'oidc' do it_behaves_like 'sp requesting attributes', :oidc end diff --git a/spec/features/idv/state_id_data_spec.rb b/spec/features/idv/state_id_data_spec.rb index b7b973727d6..61d502f8f42 100644 --- a/spec/features/idv/state_id_data_spec.rb +++ b/spec/features/idv/state_id_data_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -feature 'idv state id data entry', :idv_job do +feature 'idv state id data entry' do include IdvStepHelper let(:locale) { LinkLocaleResolver.locale } @@ -19,9 +19,8 @@ expect(current_path).to eq(idv_session_failure_path(:warning, locale: locale)) end - it 'renders an error for blank state id number and does not submit a job', :email do - expect(Idv::ProoferJob).to_not receive(:perform_now) - expect(Idv::ProoferJob).to_not receive(:perform_later) + it 'renders an error for blank state id number and does not attempt to proof', :email do + expect(Idv::Proofer).to_not receive(:get_vendor) fill_in :profile_state_id_number, with: '' click_idv_continue @@ -31,8 +30,7 @@ end it 'renders an error for unsupported jurisdiction and does not submit a job', :email do - expect(Idv::ProoferJob).to_not receive(:perform_now) - expect(Idv::ProoferJob).to_not receive(:perform_later) + expect(Idv::Proofer).to_not receive(:get_vendor) select 'Alabama', from: 'profile_state' click_idv_continue @@ -41,6 +39,16 @@ expect(current_path).to eq(idv_session_path) end + it 'renders an error for a state id that is too long and does not submit a job', :email do + expect(Idv::Proofer).to_not receive(:get_vendor) + + fill_in 'profile_state_id_number', with: '8' * 26 + click_idv_continue + + expect(page).to have_content t('idv.errors.pattern_mismatch.state_id_number') + expect(current_path).to eq(idv_session_path) + end + it 'allows selection of different state id types', :email do choose 'profile_state_id_type_drivers_permit' click_idv_continue diff --git a/spec/features/idv/steps/confirmation_step_spec.rb b/spec/features/idv/steps/confirmation_step_spec.rb index 0d24b7d25f8..0d11ab90b9a 100644 --- a/spec/features/idv/steps/confirmation_step_spec.rb +++ b/spec/features/idv/steps/confirmation_step_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -feature 'idv confirmation step', :idv_job do +feature 'idv confirmation step' do include IdvStepHelper it_behaves_like 'idv confirmation step' diff --git a/spec/features/idv/steps/jurisdiction_step_spec.rb b/spec/features/idv/steps/jurisdiction_step_spec.rb index 75b51f2f9cd..265228dac17 100644 --- a/spec/features/idv/steps/jurisdiction_step_spec.rb +++ b/spec/features/idv/steps/jurisdiction_step_spec.rb @@ -45,6 +45,23 @@ end end + it 'is not re-entrant' do + start_idv_from_sp + complete_idv_steps_before_jurisdiction_step + + select 'Virginia', from: 'jurisdiction_state' + click_idv_continue + visit idv_jurisdiction_path + + expect(page).to have_current_path(idv_session_path) + + fill_out_idv_form_ok + click_idv_continue + visit idv_jurisdiction_path + + expect(page).to have_current_path(idv_session_success_path) + end + context 'cancelling idv' do it_behaves_like 'cancel at idv step', :jurisdiction it_behaves_like 'cancel at idv step', :jurisdiction, :oidc diff --git a/spec/features/idv/steps/phone_otp_delivery_selection_step_spec.rb b/spec/features/idv/steps/phone_otp_delivery_selection_step_spec.rb index f765761b47c..cd86d0cc5ee 100644 --- a/spec/features/idv/steps/phone_otp_delivery_selection_step_spec.rb +++ b/spec/features/idv/steps/phone_otp_delivery_selection_step_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -feature 'IdV phone OTP delivery method selection', :idv_job do +feature 'IdV phone OTP deleivery method selection' do include IdvStepHelper context 'the users chooses sms' do @@ -12,7 +12,7 @@ complete_idv_steps_before_phone_otp_delivery_selection_step choose_idv_otp_delivery_method_sms - expect(page).to have_content(t('devise.two_factor_authentication.header_text')) + expect(page).to have_content(t('two_factor_authentication.header_text')) expect(current_path).to eq(idv_otp_verification_path) end end @@ -26,11 +26,25 @@ complete_idv_steps_before_phone_otp_delivery_selection_step choose_idv_otp_delivery_method_voice - expect(page).to have_content(t('devise.two_factor_authentication.header_text')) + expect(page).to have_content(t('two_factor_authentication.header_text')) expect(current_path).to eq(idv_otp_verification_path) end end + context 'the user does not make a selection' do + it 'does not send a voice call or sms and renders an error' do + expect(VoiceOtpSenderJob).to_not receive(:perform_later) + expect(SmsOtpSenderJob).to_not receive(:perform_later) + + start_idv_from_sp + complete_idv_steps_before_phone_otp_delivery_selection_step + click_on t('idv.buttons.send_confirmation_code') + + expect(page).to have_content(t('idv.errors.unsupported_otp_delivery_method')) + expect(current_path).to eq(idv_otp_delivery_method_path) + end + end + context 'with a non-US number' do let(:bahamas_phone) { '+12423270143' } diff --git a/spec/features/idv/steps/phone_otp_verification_step_spec.rb b/spec/features/idv/steps/phone_otp_verification_step_spec.rb index 80302062dd1..e747892221c 100644 --- a/spec/features/idv/steps/phone_otp_verification_step_spec.rb +++ b/spec/features/idv/steps/phone_otp_verification_step_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -feature 'phone otp verification step spec', :idv_job do +feature 'phone otp verification step spec' do include IdvStepHelper let(:otp_code) { '777777' } @@ -23,7 +23,7 @@ fill_in 'code', with: '000000' click_submit_default - expect(page).to have_content(t('devise.two_factor_authentication.invalid_otp')) + expect(page).to have_content(t('two_factor_authentication.invalid_otp')) expect(current_path).to eq(idv_otp_verification_path) # Enter the correct code @@ -44,7 +44,7 @@ fill_in(:code, with: otp_code) click_button t('forms.buttons.submit.default') - expect(page).to have_content(t('devise.two_factor_authentication.invalid_otp')) + expect(page).to have_content(t('two_factor_authentication.invalid_otp')) expect(page).to have_current_path(idv_otp_verification_path) end end diff --git a/spec/features/idv/steps/phone_step_spec.rb b/spec/features/idv/steps/phone_step_spec.rb index 7a3a0bbb20b..3a494fb2e4b 100644 --- a/spec/features/idv/steps/phone_step_spec.rb +++ b/spec/features/idv/steps/phone_step_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -feature 'idv phone step', :idv_job do +feature 'idv phone step' do include IdvStepHelper include IdvHelper @@ -19,7 +19,7 @@ user = user_with_2fa start_idv_from_sp complete_idv_steps_before_phone_step(user) - fill_out_phone_form_ok(user.phone_configurations.first.phone) + fill_out_phone_form_ok(MfaContext.new(user).phone_configurations.first.phone) click_idv_continue expect(page).to have_content(t('idv.titles.session.review')) @@ -39,8 +39,8 @@ choose_idv_otp_delivery_method_sms - expect(page).to have_content(t('devise.two_factor_authentication.header_text')) - expect(page).to_not have_content(t('devise.two_factor_authentication.totp_header_text')) + expect(page).to have_content(t('two_factor_authentication.header_text')) + expect(page).to_not have_content(t('two_factor_authentication.totp_header_text')) expect(page).to_not have_content(t('two_factor_authentication.login_options_link_text')) end end diff --git a/spec/features/idv/steps/profile_step_spec.rb b/spec/features/idv/steps/profile_step_spec.rb index e68cdfcd6e3..6e62ceb570d 100644 --- a/spec/features/idv/steps/profile_step_spec.rb +++ b/spec/features/idv/steps/profile_step_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -feature 'idv profile step', :idv_job do +feature 'idv profile step' do include IdvStepHelper context 'with valid information' do diff --git a/spec/features/idv/steps/review_step_spec.rb b/spec/features/idv/steps/review_step_spec.rb index cb42d043f6c..a310544b005 100644 --- a/spec/features/idv/steps/review_step_spec.rb +++ b/spec/features/idv/steps/review_step_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -feature 'idv review step', :idv_job do +feature 'idv review step' do include IdvStepHelper it 'requires the user to enter the correct password to redirect to confirmation step' do diff --git a/spec/features/idv/steps/usps_step_spec.rb b/spec/features/idv/steps/usps_step_spec.rb index fdd31b3cb4b..c8035a070fe 100644 --- a/spec/features/idv/steps/usps_step_spec.rb +++ b/spec/features/idv/steps/usps_step_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -feature 'idv usps step', :idv_job do +feature 'idv usps step' do include IdvStepHelper it 'redirects to the review step when the user chooses to verify by letter' do diff --git a/spec/features/idv/usps_disabled_spec.rb b/spec/features/idv/usps_disabled_spec.rb index c5f59e2ae72..42b6b566bff 100644 --- a/spec/features/idv/usps_disabled_spec.rb +++ b/spec/features/idv/usps_disabled_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -feature 'disabling USPS address verification', :idv_job do +feature 'disabling USPS address verification' do include IdvStepHelper context 'with USPS address verification disabled' do diff --git a/spec/features/openid_connect/openid_connect_spec.rb b/spec/features/openid_connect/openid_connect_spec.rb index 314a4ca0a65..f819872be7c 100644 --- a/spec/features/openid_connect/openid_connect_spec.rb +++ b/spec/features/openid_connect/openid_connect_spec.rb @@ -119,7 +119,7 @@ class MockSession; end fill_in :code, with: 'wrong otp' click_submit_default - expect(page).to have_content(t('devise.two_factor_authentication.invalid_otp')) + expect(page).to have_content(t('two_factor_authentication.invalid_otp')) expect(page.response_headers['Content-Security-Policy']). to(include('form-action \'self\' http://localhost:7654')) click_submit_default diff --git a/spec/features/saml/loa1_sso_spec.rb b/spec/features/saml/loa1_sso_spec.rb index 4aa7596082e..daf4fa484b0 100644 --- a/spec/features/saml/loa1_sso_spec.rb +++ b/spec/features/saml/loa1_sso_spec.rb @@ -114,22 +114,6 @@ expect(current_path).to eq sign_up_completed_path end - it 'redirects user to SP after asking for new personal key during sign up' do - allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) - - saml_authn_request = auth_request.create(saml_settings) - visit saml_authn_request - register_user - click_on t('users.personal_key.get_another') - click_acknowledge_personal_key - - expect(current_path).to eq sign_up_completed_path - - click_on t('forms.buttons.continue') - - expect(current_url).to eq saml_authn_request - end - it 'after session timeout, signing in takes user back to SP' do allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) diff --git a/spec/features/saml/loa3_sso_spec.rb b/spec/features/saml/loa3_sso_spec.rb index c1f00f5b9bb..1006943c9d6 100644 --- a/spec/features/saml/loa3_sso_spec.rb +++ b/spec/features/saml/loa3_sso_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -feature 'LOA3 Single Sign On', idv_job: true do +feature 'LOA3 Single Sign On' do include SamlAuthHelper include IdvHelper diff --git a/spec/features/two_factor_authentication/change_factor_spec.rb b/spec/features/two_factor_authentication/change_factor_spec.rb index 3d2cf3aa677..f6935ceb28b 100644 --- a/spec/features/two_factor_authentication/change_factor_spec.rb +++ b/spec/features/two_factor_authentication/change_factor_spec.rb @@ -20,7 +20,8 @@ mailer = instance_double(ActionMailer::MessageDelivery, deliver_later: true) allow(UserMailer).to receive(:phone_changed).with(user).and_return(mailer) - @previous_phone_confirmed_at = user.phone_configurations.reload.first.confirmed_at + @previous_phone_confirmed_at = + MfaContext.new(user).phone_configurations.reload.first.confirmed_at new_phone = '+1 703-555-0100' visit manage_phone_path @@ -33,13 +34,13 @@ expect(page).to have_link t('links.cancel'), href: account_path expect(page).to have_link t('forms.two_factor.try_again'), href: manage_phone_path expect(page).not_to have_content( - t('devise.two_factor_authentication.personal_key_fallback.text_html') + t('two_factor_authentication.personal_key_fallback.text_html') ) enter_incorrect_otp_code - expect(page).to have_content t('devise.two_factor_authentication.invalid_otp') - expect(user.phone_configurations.reload.first.phone).to_not eq new_phone + expect(page).to have_content t('two_factor_authentication.invalid_otp') + expect(MfaContext.new(user).phone_configurations.reload.first.phone).to_not eq new_phone expect(page).to have_link t('forms.two_factor.try_again'), href: manage_phone_path submit_correct_otp @@ -49,7 +50,7 @@ expect(mailer).to have_received(:deliver_later) expect(page).to have_content new_phone expect( - user.phone_configurations.reload.first.confirmed_at + MfaContext.new(user).phone_configurations.reload.first.confirmed_at ).to_not eq(@previous_phone_confirmed_at) visit login_two_factor_path(otp_delivery_preference: 'sms') @@ -58,7 +59,7 @@ scenario 'editing phone number with no voice otp support only allows sms delivery' do user.update(otp_delivery_preference: 'voice') - user.phone_configurations.first.update(delivery_preference: 'voice') + MfaContext.new(user).phone_configurations.first.update(delivery_preference: 'voice') unsupported_phone = '242-327-0143' visit manage_phone_path @@ -81,7 +82,7 @@ allow(SmsOtpSenderJob).to receive(:perform_later) user = sign_in_and_2fa_user - old_phone = user.phone_configurations.first.phone + old_phone = MfaContext.new(user).phone_configurations.first.phone visit manage_phone_path update_phone_number @@ -108,7 +109,7 @@ allow(SmsOtpSenderJob).to receive(:perform_later) user = sign_in_and_2fa_user - old_phone = user.phone_configurations.first.phone + old_phone = MfaContext.new(user).phone_configurations.first.phone Timecop.travel(Figaro.env.reauthn_window.to_i + 1) do visit manage_phone_path diff --git a/spec/features/two_factor_authentication/remember_device_spec.rb b/spec/features/two_factor_authentication/remember_device_spec.rb index 888ad1d5a4d..06c166dd3e3 100644 --- a/spec/features/two_factor_authentication/remember_device_spec.rb +++ b/spec/features/two_factor_authentication/remember_device_spec.rb @@ -72,7 +72,7 @@ def remember_device_and_sign_out_user end end - context 'identity verification', :idv_job do + context 'identity verification' do let(:user) { user_with_2fa } before do diff --git a/spec/features/two_factor_authentication/sign_in_spec.rb b/spec/features/two_factor_authentication/sign_in_spec.rb index 31ce0e50b10..be155f1b85b 100644 --- a/spec/features/two_factor_authentication/sign_in_spec.rb +++ b/spec/features/two_factor_authentication/sign_in_spec.rb @@ -34,7 +34,7 @@ expect(page).to_not have_content invalid_phone_message expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'sms') - expect(user.phone_configurations).to be_empty + expect(MfaContext.new(user).phone_configurations).to be_empty expect(user.sms?).to eq true end @@ -51,7 +51,7 @@ expect(page).to have_content t('titles.account_locked') expect(page). - to have_content t('devise.two_factor_authentication.max_otp_login_attempts_reached') + to have_content t('two_factor_authentication.max_otp_login_attempts_reached') end end @@ -67,7 +67,7 @@ expect(current_path).to eq phone_setup_path expect(page).to have_content t( - 'devise.two_factor_authentication.otp_delivery_preference.phone_unsupported', + 'two_factor_authentication.otp_delivery_preference.phone_unsupported', location: 'Bahamas' ) @@ -181,7 +181,7 @@ def submit_2fa_setup_form_with_valid_phone expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'sms') expect(page). - to have_content t('devise.two_factor_authentication.header_text') + to have_content t('two_factor_authentication.header_text') attempt_to_bypass_2fa @@ -293,7 +293,7 @@ def submit_prefilled_otp_code expect(page).to have_content t('titles.account_locked') expect(page).to have_content(five_minute_countdown_regex) - expect(page).to have_content t('devise.two_factor_authentication.max_otp_requests_reached') + expect(page).to have_content t('two_factor_authentication.max_otp_requests_reached') visit root_path signin(user.email, user.password) @@ -301,7 +301,7 @@ def submit_prefilled_otp_code expect(page).to have_content t('titles.account_locked') expect(page).to have_content(five_minute_countdown_regex) expect(page). - to have_content t('devise.two_factor_authentication.max_generic_login_attempts_reached') + to have_content t('two_factor_authentication.max_generic_login_attempts_reached') # let lockout period expire Timecop.travel(lockout_period) do @@ -349,7 +349,9 @@ def submit_prefilled_otp_code expect(current_path).to eq account_path - phone_fingerprint = Pii::Fingerprinter.fingerprint(user.phone_configurations.first.phone) + phone_fingerprint = Pii::Fingerprinter.fingerprint( + MfaContext.new(user).phone_configurations.first.phone + ) rate_limited_phone = OtpRequestsTracker.find_by(phone_fingerprint: phone_fingerprint) # let findtime period expire @@ -387,7 +389,7 @@ def submit_prefilled_otp_code sign_in_before_2fa(second_user) click_link t('links.two_factor_authentication.get_another_code') phone_fingerprint = Pii::Fingerprinter.fingerprint( - first_user.phone_configurations.first.phone + MfaContext.new(first_user).phone_configurations.first.phone ) rate_limited_phone = OtpRequestsTracker.find_by(phone_fingerprint: phone_fingerprint) @@ -402,7 +404,7 @@ def submit_prefilled_otp_code signin(first_user.email, first_user.password) - expect(page).to have_content t('devise.two_factor_authentication.max_otp_requests_reached') + expect(page).to have_content t('two_factor_authentication.max_otp_requests_reached') visit account_path expect(current_path).to eq root_path @@ -438,7 +440,7 @@ def submit_prefilled_otp_code allow_any_instance_of(User).to receive(:max_login_attempts?).and_return(true) signin(user.email, user.password) - expect(page).to have_content t('devise.two_factor_authentication.' \ + expect(page).to have_content t('two_factor_authentication.' \ 'max_generic_login_attempts_reached') visit account_path @@ -472,7 +474,7 @@ def submit_prefilled_otp_code click_button t('forms.buttons.submit.default') expect(page). - to_not have_content t('devise.two_factor_authentication.invalid_otp') + to_not have_content t('two_factor_authentication.invalid_otp') end end end @@ -544,7 +546,7 @@ def submit_prefilled_otp_code dn: 'C=US, O=U.S. Government, OU=DoD, OU=PKI, CN=DOE.JOHN.12345', nonce: nonce) expect(current_path).to eq login_two_factor_piv_cac_path - expect(page).to have_content(t('devise.two_factor_authentication.invalid_piv_cac')) + expect(page).to have_content(t('two_factor_authentication.invalid_piv_cac')) end context 'with SMS, international number, and locale header' do @@ -614,7 +616,7 @@ def submit_prefilled_otp_code user = create(:user, :signed_up) sign_in_before_2fa(user) - expect(page).not_to have_link(t('devise.two_factor_authentication.piv_cac_fallback.link')) + expect(page).not_to have_link(t('two_factor_authentication.piv_cac_fallback.link')) end end @@ -660,7 +662,7 @@ def submit_prefilled_otp_code fill_in 'code', with: otp click_submit_default - expect(page).to have_content(t('devise.two_factor_authentication.invalid_otp')) + expect(page).to have_content(t('two_factor_authentication.invalid_otp')) expect(current_path).to eq login_two_factor_authenticator_path end end diff --git a/spec/features/two_factor_authentication/sign_in_via_personal_key_spec.rb b/spec/features/two_factor_authentication/sign_in_via_personal_key_spec.rb index fa68d848a1a..d28fede5c2f 100644 --- a/spec/features/two_factor_authentication/sign_in_via_personal_key_spec.rb +++ b/spec/features/two_factor_authentication/sign_in_via_personal_key_spec.rb @@ -29,7 +29,7 @@ click_submit_default expect(page).to have_content( - t('devise.two_factor_authentication.max_personal_key_login_attempts_reached') + t('two_factor_authentication.max_personal_key_login_attempts_reached') ) end end diff --git a/spec/features/users/password_recovery_via_recovery_code_spec.rb b/spec/features/users/password_recovery_via_recovery_code_spec.rb index 410e5e4c954..4b8a5d39ddc 100644 --- a/spec/features/users/password_recovery_via_recovery_code_spec.rb +++ b/spec/features/users/password_recovery_via_recovery_code_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -feature 'Password recovery via personal key', idv_job: true do +feature 'Password recovery via personal key' do include PersonalKeyHelper include IdvHelper include SamlAuthHelper diff --git a/spec/features/users/piv_cac_management_spec.rb b/spec/features/users/piv_cac_management_spec.rb index ceec1f6ca59..0fde0d7782c 100644 --- a/spec/features/users/piv_cac_management_spec.rb +++ b/spec/features/users/piv_cac_management_spec.rb @@ -7,10 +7,6 @@ def find_form(page, attributes) end end - before(:each) do - allow(Figaro.env).to receive(:piv_cac_enabled).and_return('true') - end - context 'with no piv/cac associated yet' do let(:uuid) { SecureRandom.uuid } let(:user) { create(:user, :signed_up, :with_phone, with: { phone: '+1 202-555-1212' }) } @@ -107,8 +103,7 @@ def find_form(page, attributes) stub_piv_cac_service user.update(otp_secret_key: 'secret') - user.phone_configurations.clear - expect(user.phone_configurations).to be_empty + MfaContext.new(user).phone_configurations.clear sign_in_and_2fa_user(user) visit account_path click_link t('forms.buttons.enable'), href: setup_piv_cac_url diff --git a/spec/features/users/regenerate_personal_key_spec.rb b/spec/features/users/regenerate_personal_key_spec.rb index 0e44a04223d..88f9887669c 100644 --- a/spec/features/users/regenerate_personal_key_spec.rb +++ b/spec/features/users/regenerate_personal_key_spec.rb @@ -76,19 +76,12 @@ context 'with javascript enabled', js: true do let(:invisible_selector) { generate_class_selector('invisible') } - let(:accordion_control_selector) { generate_class_selector('accordion-header-controls') } it 'prompts the user to enter their personal key to confirm they have it' do Capybara.current_session.current_window.resize_to(2560, 1600) sign_in_and_2fa_user click_button t('account.links.regenerate_personal_key') - expect_accordion_content_to_be_hidden_by_default - - expand_accordion - - expect_accordion_content_to_become_visible - click_acknowledge_personal_key expect_confirmation_modal_to_appear_with_first_code_field_in_focus @@ -137,9 +130,6 @@ expect(current_path).to eq account_path end end - - it_behaves_like 'csrf error when asking for new personal key', :saml - it_behaves_like 'csrf error when asking for new personal key', :oidc end def sign_up_and_view_personal_key @@ -151,23 +141,6 @@ def sign_up_and_view_personal_key click_submit_default end -def expect_accordion_content_to_be_hidden_by_default - expect(page).to have_xpath("//#{accordion_control_selector}") - expect(page).not_to have_content(t('users.personal_key.help_text')) - expect(page).to have_xpath( - "//div[@id='personal-key-confirm'][@class='display-none']", visible: false - ) -end - -def expand_accordion - page.find('.accordion-header-controls').click -end - -def expect_accordion_content_to_become_visible - expect(page).to have_xpath("//#{accordion_control_selector}[@aria-expanded='true']") - expect(page).to have_content(t('users.personal_key.help_text')) -end - def expect_confirmation_modal_to_appear_with_first_code_field_in_focus expect(page).not_to have_xpath("//div[@id='personal-key-confirm'][@class='display-none']") expect(page).not_to have_xpath("//#{invisible_selector}[@id='personal-key']") diff --git a/spec/features/users/sign_in_spec.rb b/spec/features/users/sign_in_spec.rb index 3e27ae5d831..158ff451607 100644 --- a/spec/features/users/sign_in_spec.rb +++ b/spec/features/users/sign_in_spec.rb @@ -330,7 +330,7 @@ expect(page). to have_current_path(login_two_factor_path(otp_delivery_preference: 'sms', reauthn: false)) expect(page).to have_content t( - 'devise.two_factor_authentication.otp_delivery_preference.phone_unsupported', + 'two_factor_authentication.otp_delivery_preference.phone_unsupported', location: 'Bermuda' ) expect(user.reload.otp_delivery_preference).to eq 'sms' @@ -351,7 +351,7 @@ expect(page). to have_current_path(login_two_factor_path(otp_delivery_preference: 'sms', reauthn: false)) expect(page).to have_content t( - 'devise.two_factor_authentication.otp_delivery_preference.phone_unsupported', + 'two_factor_authentication.otp_delivery_preference.phone_unsupported', location: 'India' ) expect(user.reload.otp_delivery_preference).to eq 'sms' @@ -374,7 +374,7 @@ expect(page). to have_current_path(login_two_factor_path(otp_delivery_preference: 'sms')) expect(page).to have_content t( - 'devise.two_factor_authentication.otp_delivery_preference.phone_unsupported', + 'two_factor_authentication.otp_delivery_preference.phone_unsupported', location: 'India' ) expect(user.reload.otp_delivery_preference).to eq 'sms' @@ -397,7 +397,7 @@ expect(page). to have_current_path(login_two_factor_path(otp_delivery_preference: 'sms')) expect(page).to have_content t( - 'devise.two_factor_authentication.otp_delivery_preference.phone_unsupported', + 'two_factor_authentication.otp_delivery_preference.phone_unsupported', location: 'India' ) expect(user.reload.otp_delivery_preference).to eq 'sms' @@ -441,7 +441,7 @@ click_submit_default expect(page).to have_current_path(login_two_factor_personal_key_path) - expect(page).to have_content t('devise.two_factor_authentication.invalid_personal_key') + expect(page).to have_content t('two_factor_authentication.invalid_personal_key') end end @@ -478,7 +478,7 @@ it 'does not display OTP Fallback text and links' do expect(page). - to_not have_content t('devise.two_factor_authentication.totp_fallback.sms_link_text') + to_not have_content t('two_factor_authentication.totp_fallback.sms_link_text') end end end diff --git a/spec/features/users/sign_up_spec.rb b/spec/features/users/sign_up_spec.rb index 575ded876b6..c18316701b4 100644 --- a/spec/features/users/sign_up_spec.rb +++ b/spec/features/users/sign_up_spec.rb @@ -168,12 +168,11 @@ it 'does not allow a user to choose piv/cac as 2FA method during sign up' do allow(PivCacService).to receive(:piv_cac_available_for_agency?).and_return(false) - allow(FeatureManagement).to receive(:piv_cac_enabled?).and_return(true) begin_sign_up_with_sp_and_loa(loa3: false) expect(page).to have_current_path two_factor_options_path expect(page).not_to have_content( - t('devise.two_factor_authentication.two_factor_choice_options.piv_cac') + t('two_factor_authentication.two_factor_choice_options.piv_cac') ) end diff --git a/spec/features/users/user_profile_spec.rb b/spec/features/users/user_profile_spec.rb index c3288adbaab..18e53724956 100644 --- a/spec/features/users/user_profile_spec.rb +++ b/spec/features/users/user_profile_spec.rb @@ -117,7 +117,7 @@ expect(page).to have_content(t('idv.messages.personal_key')) end - it 'allows the user reactivate their profile by reverifying', idv_job: true do + it 'allows the user reactivate their profile by reverifying' do profile = create(:profile, :active, :verified, pii: { ssn: '1234', dob: '1920-01-01' }) user = profile.user diff --git a/spec/features/users/webauthn_management_spec.rb b/spec/features/users/webauthn_management_spec.rb index 1b7c89156d0..866096dac63 100644 --- a/spec/features/users/webauthn_management_spec.rb +++ b/spec/features/users/webauthn_management_spec.rb @@ -15,7 +15,7 @@ click_link t('account.index.webauthn_add'), href: webauthn_setup_url expect(current_path).to eq webauthn_setup_path - mock_press_button_on_hardware_key + mock_press_button_on_hardware_key_and_fill_in_name_field click_submit_default expect(current_path).to eq account_path @@ -30,7 +30,7 @@ click_link t('account.index.webauthn_add'), href: webauthn_setup_url expect(current_path).to eq webauthn_setup_path - mock_press_button_on_hardware_key + mock_press_button_on_hardware_key_and_fill_in_name_field click_submit_default expect(current_path).to eq account_path @@ -62,7 +62,7 @@ click_link t('account.index.webauthn_add'), href: webauthn_setup_url expect(current_path).to eq webauthn_setup_path - mock_press_button_on_hardware_key + mock_press_button_on_hardware_key_and_fill_in_name_field click_submit_default expect(current_path).to eq account_path @@ -71,7 +71,7 @@ click_link t('account.index.webauthn_add'), href: webauthn_setup_url expect(current_path).to eq webauthn_setup_path - mock_press_button_on_hardware_key + mock_press_button_on_hardware_key_and_fill_in_name_field click_submit_default expect(current_path).to eq webauthn_setup_path @@ -128,25 +128,6 @@ end end - def mock_challenge - allow(WebAuthn).to receive(:credential_creation_options).and_return( - challenge: challenge.pack('c*') - ) - end - - def mock_press_button_on_hardware_key - # this is required because the domain is embedded in the supplied attestation object - allow(WebauthnSetupForm).to receive(:domain_name).and_return('localhost:3000') - - set_hidden_field('attestation_object', attestation_object) - set_hidden_field('client_data_json', client_data_json) - set_hidden_field('name', 'mykey') - end - - def set_hidden_field(id, value) - first("input##{id}", visible: false).set(value) - end - def create_webauthn_configuration(user, name, id, key) WebauthnConfiguration.create(user_id: user.id, credential_public_key: key, diff --git a/spec/features/visitors/phone_confirmation_spec.rb b/spec/features/visitors/phone_confirmation_spec.rb index 4915e1a9462..0e07b7db6c7 100644 --- a/spec/features/visitors/phone_confirmation_spec.rb +++ b/spec/features/visitors/phone_confirmation_spec.rb @@ -22,7 +22,7 @@ it 'updates phone_confirmed_at and redirects to acknowledge personal key' do click_button t('forms.buttons.submit.default') - expect(@user.phone_configurations.reload.first.confirmed_at).to be_present + expect(MfaContext.new(@user).phone_configurations.reload.first.confirmed_at).to be_present expect(current_path).to eq sign_up_personal_key_path click_acknowledge_personal_key @@ -40,7 +40,7 @@ fill_in 'code', with: '12345678' click_button t('forms.buttons.submit.default') - expect(@user.reload.two_factor_enabled?).to be false + expect(MfaPolicy.new(@user.reload).two_factor_enabled?).to be false end it 'provides user with link to type in a phone number so they are not locked out' do @@ -61,7 +61,7 @@ @user = sign_in_before_2fa select_2fa_option('sms') fill_in 'user_phone_form_phone', - with: @existing_user.phone_configurations.detect(&:mfa_enabled?).phone + with: MfaContext.new(@existing_user).phone_configurations.detect(&:mfa_enabled?).phone click_send_security_code end @@ -76,8 +76,8 @@ fill_in 'code', with: 'foobar' click_submit_default - expect(@user.phone_configurations.reload).to be_empty - expect(page).to have_content t('devise.two_factor_authentication.invalid_otp') + expect(MfaContext.new(@user).phone_configurations.reload).to be_empty + expect(page).to have_content t('two_factor_authentication.invalid_otp') expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'sms') end end diff --git a/spec/forms/idv/image_upload_form_spec.rb b/spec/forms/idv/image_upload_form_spec.rb new file mode 100644 index 00000000000..81517b996b5 --- /dev/null +++ b/spec/forms/idv/image_upload_form_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' + +describe Idv::ImageUploadForm do + let(:user) { create(:user) } + let(:subject) { Idv::ImageUploadForm.new(user) } + let(:image_data) { 'abc' } + + describe '#submit' do + context 'when the form is valid' do + it 'returns a successful form response' do + result = subject.submit(image: image_data) + + expect(result).to be_kind_of(FormResponse) + expect(result.success?).to eq(true) + expect(result.errors).to be_empty + end + end + + context 'when the form has invalid attributes' do + it 'raises an error' do + expect { subject.submit(image: image_data, foo: 1) }. + to raise_error(ArgumentError, 'foo is an invalid image attribute') + end + end + end + + describe 'presence validations' do + it 'is invalid when required attribute is not present' do + subject.submit(image: nil) + + expect(subject).to_not be_valid + end + end +end diff --git a/spec/forms/idv/phone_form_spec.rb b/spec/forms/idv/phone_form_spec.rb index 22667076b30..f9d039aa3e4 100644 --- a/spec/forms/idv/phone_form_spec.rb +++ b/spec/forms/idv/phone_form_spec.rb @@ -3,8 +3,9 @@ describe Idv::PhoneForm do let(:user) { build_stubbed(:user, :signed_up) } let(:params) { { phone: '703-555-5000' } } + let(:previous_params) { nil } - subject { Idv::PhoneForm.new({}, user) } + subject { Idv::PhoneForm.new(user: user, previous_params: previous_params) } it_behaves_like 'a phone form' @@ -17,17 +18,6 @@ expect(result.success?).to eq(true) expect(result.errors).to be_empty end - - it 'adds phone key to idv_params' do - subject.submit(phone: '703-555-1212') - - expected_params = { - phone: '7035551212', - phone_confirmed_at: nil, - } - - expect(subject.idv_params).to eq expected_params - end end context 'when the form is invalid' do @@ -40,31 +30,27 @@ end end - it 'adds phone_confirmed_at key to idv_params when submitted phone equals user phone' do - subject.submit(phone: '+1 (202) 555-1212') - - expected_params = { - phone: '2025551212', - phone_confirmed_at: user.phone_configurations.first.confirmed_at, - } - - expect(subject.idv_params).to eq expected_params - end - it 'uses the user phone number as the initial phone value' do user = build_stubbed(:user, :signed_up, with: { phone: '7035551234' }) - subject = Idv::PhoneForm.new({}, user) + subject = Idv::PhoneForm.new(previous_params: {}, user: user) expect(subject.phone).to eq('+1 703-555-1234') end it 'does not use an international number as the initial phone value' do user = build_stubbed(:user, :signed_up, with: { phone: '+81 54 354 3643' }) - subject = Idv::PhoneForm.new({}, user) + subject = Idv::PhoneForm.new(previous_params: {}, user: user) expect(subject.phone).to eq(nil) end + it 'uses the previously submitted value as the initial phone value' do + user = build_stubbed(:user, :signed_up, with: { phone: '7035551234' }) + subject = Idv::PhoneForm.new(previous_params: { phone: '2255555000' }, user: user) + + expect(subject.phone).to eq('+1 225-555-5000') + end + it 'does not allow non-US numbers' do invalid_phones = ['+81 54 354 3643', '+12423270143'] invalid_phones.each do |phone| diff --git a/spec/forms/idv/profile_form_spec.rb b/spec/forms/idv/profile_form_spec.rb index 52847558953..456559263e8 100644 --- a/spec/forms/idv/profile_form_spec.rb +++ b/spec/forms/idv/profile_form_spec.rb @@ -4,7 +4,7 @@ let(:password) { 'a really long sekrit' } let(:ssn) { '123-11-1234' } let(:user) { create(:user, password: password) } - let(:subject) { Idv::ProfileForm.new({}, user) } + let(:subject) { Idv::ProfileForm.new(user: user, previous_params: {}) } let(:profile_attrs) do { first_name: 'Some', @@ -21,6 +21,17 @@ } end + describe '#initialize' do + context 'when there are params from a previous submission' do + it 'assigns those params to the form' do + form = Idv::ProfileForm.new(user: user, previous_params: profile_attrs) + + expect(form.first_name).to eq('Some') + expect(form.last_name).to eq('One') + end + end + end + describe '#submit' do context 'when the form is valid' do it 'returns a successful form response' do @@ -178,4 +189,12 @@ expect(subject.errors).to include(:state_id_type) end end + + describe 'state id number length validity' do + it 'populates error for invalid state id number length' do + subject.submit(profile_attrs.merge(state_id_number: '8' * 26)) + expect(subject.valid?).to eq false + expect(subject.errors).to include(:state_id_number) + end + end end diff --git a/spec/forms/idv/ssn_form_spec.rb b/spec/forms/idv/ssn_form_spec.rb new file mode 100644 index 00000000000..2736cb9841b --- /dev/null +++ b/spec/forms/idv/ssn_form_spec.rb @@ -0,0 +1,88 @@ +require 'rails_helper' + +describe Idv::SsnForm do + let(:user) { create(:user) } + let(:subject) { Idv::SsnForm.new(user) } + let(:ssn) { '111-11-1111' } + + describe '#submit' do + context 'when the form is valid' do + it 'returns a successful form response' do + result = subject.submit(ssn: '111111111') + + expect(result).to be_kind_of(FormResponse) + expect(result.success?).to eq(true) + expect(result.errors).to be_empty + end + end + + context 'when the form is invalid' do + it 'returns an unsuccessful form response' do + result = subject.submit(ssn: 'abc') + + expect(result).to be_kind_of(FormResponse) + expect(result.success?).to eq(false) + expect(result.errors).to include(:ssn) + end + end + + context 'when the form has invalid attributes' do + it 'raises an error' do + expect { subject.submit(ssn: '111111111', foo: 1) }. + to raise_error(ArgumentError, 'foo is an invalid ssn attribute') + end + end + end + + describe 'presence validations' do + it 'is invalid when required attribute is not present' do + subject.submit(ssn: nil) + + expect(subject).to_not be_valid + end + end + + describe 'ssn uniqueness' do + context 'when ssn is already taken by another profile' do + it 'is invalid' do + diff_user = create(:user) + create(:profile, pii: { ssn: ssn }, user: diff_user) + + subject.submit(ssn: ssn) + + expect(subject.valid?).to eq false + expect(subject.errors[:ssn]).to eq [t('idv.errors.duplicate_ssn')] + end + + it 'recognizes fingerprint regardless of HMAC key age' do + diff_user = create(:user) + create(:profile, pii: { ssn: ssn }, user: diff_user) + rotate_hmac_key + + subject.submit(ssn: ssn) + + expect(subject.valid?).to eq false + expect(subject.errors[:ssn]).to eq [t('idv.errors.duplicate_ssn')] + end + end + + context 'when ssn is already taken by same profile' do + it 'is valid' do + create(:profile, pii: { ssn: ssn }, user: user) + + subject.submit(ssn: ssn) + + expect(subject.valid?).to eq true + end + + it 'recognizes fingerprint regardless of HMAC key age' do + create(:profile, pii: { ssn: ssn }, user: user) + rotate_hmac_key + + subject.submit(ssn: ssn) + + expect(subject.valid?).to eq true + end + end + end +end diff --git a/spec/forms/user_phone_form_spec.rb b/spec/forms/user_phone_form_spec.rb index 5badd5ac3e8..b47e5eee323 100644 --- a/spec/forms/user_phone_form_spec.rb +++ b/spec/forms/user_phone_form_spec.rb @@ -11,7 +11,7 @@ otp_delivery_preference: 'sms', } end - subject { UserPhoneForm.new(user) } + subject { UserPhoneForm.new(user, MfaContext.new(user).phone_configurations.first) } it_behaves_like 'a phone form' @@ -21,16 +21,16 @@ with: { phone: '+1 (703) 500-5000' }, otp_delivery_preference: 'voice' ) - subject = UserPhoneForm.new(user) + subject = UserPhoneForm.new(user, MfaContext.new(user).phone_configurations.first) - expect(subject.phone).to eq(user.phone_configurations.first.phone) + expect(subject.phone).to eq(MfaContext.new(user).phone_configurations.first.phone) expect(subject.international_code).to eq('US') expect(subject.otp_delivery_preference).to eq(user.otp_delivery_preference) end it 'infers the international code from the user phone number' do user = build_stubbed(:user, :with_phone, with: { phone: '+81 744 21 1234' }) - subject = UserPhoneForm.new(user) + subject = UserPhoneForm.new(user, MfaContext.new(user).phone_configurations.first) expect(subject.international_code).to eq('JP') end @@ -72,13 +72,13 @@ it 'does not update the user phone attribute' do user = create(:user) - subject = UserPhoneForm.new(user) + subject = UserPhoneForm.new(user, MfaContext.new(user).phone_configurations.first) params[:phone] = '+1 504 444 1643' subject.submit(params) user.reload - expect(user.phone_configurations).to be_empty + expect(MfaContext.new(user).phone_configurations).to be_empty end it 'preserves the format of the submitted phone number if phone is invalid' do @@ -211,7 +211,7 @@ end it 'returns false if the user phone has not changed' do - params[:phone] = user.phone_configurations.first.phone + params[:phone] = MfaContext.new(user).phone_configurations.first.phone subject.submit(params) expect(subject.phone_changed?).to eq(false) @@ -219,7 +219,7 @@ context 'when a user has no phone' do it 'returns true' do - user.phone_configurations.clear + MfaContext.new(user).phone_configurations.clear params[:phone] = '+1 504 444 1643' subject.submit(params) diff --git a/spec/forms/user_piv_cac_setup_form_spec.rb b/spec/forms/user_piv_cac_setup_form_spec.rb index aa36dd2f055..ec5be960b35 100644 --- a/spec/forms/user_piv_cac_setup_form_spec.rb +++ b/spec/forms/user_piv_cac_setup_form_spec.rb @@ -32,7 +32,7 @@ with(user_id: user.id, event_type: :piv_cac_enabled) expect(form.submit).to eq result user.reload - expect(user.piv_cac_enabled?).to eq true + expect(TwoFactorAuthentication::PivCacPolicy.new(user).enabled?).to eq true expect(user.x509_dn_uuid).to eq x509_dn_uuid end @@ -46,7 +46,7 @@ with(success: false, errors: {}).and_return(result) expect(Event).to_not receive(:create) expect(form.submit).to eq result - expect(user.reload.piv_cac_enabled?).to eq true + expect(TwoFactorAuthentication::PivCacPolicy.new(user.reload).enabled?).to eq true expect(form.error_type).to eq 'user.piv_cac_associated' end end @@ -62,7 +62,7 @@ with(success: false, errors: {}).and_return(result) expect(Event).to_not receive(:create) expect(form.submit).to eq result - expect(user.reload.piv_cac_enabled?).to eq false + expect(TwoFactorAuthentication::PivCacPolicy.new(user.reload).enabled?).to eq false expect(form.error_type).to eq 'piv_cac.already_associated' end @@ -78,7 +78,7 @@ with(success: false, errors: {}).and_return(result) expect(Event).to_not receive(:create) expect(form.submit).to eq result - expect(user.reload.piv_cac_enabled?).to eq false + expect(TwoFactorAuthentication::PivCacPolicy.new(user.reload).enabled?).to eq false expect(form.error_type).to eq 'piv_cac.already_associated' end end @@ -98,7 +98,7 @@ with(success: false, errors: {}).and_return(result) expect(Event).to_not receive(:create) expect(form.submit).to eq result - expect(user.reload.piv_cac_enabled?).to eq false + expect(TwoFactorAuthentication::PivCacPolicy.new(user.reload).enabled?).to eq false expect(form.error_type).to eq 'token.bad' end end @@ -131,7 +131,7 @@ with(success: false, errors: {}).and_return(result) expect(Event).to_not receive(:create) expect(form.submit).to eq result - expect(user.reload.piv_cac_enabled?).to eq false + expect(TwoFactorAuthentication::PivCacPolicy.new(user.reload).enabled?).to eq false end end end diff --git a/spec/forms/webauthn_verification_form_spec.rb b/spec/forms/webauthn_verification_form_spec.rb new file mode 100644 index 00000000000..dd48dde7388 --- /dev/null +++ b/spec/forms/webauthn_verification_form_spec.rb @@ -0,0 +1,45 @@ +require 'rails_helper' + +describe WebauthnVerificationForm do + include WebauthnVerificationHelper + + let(:user) { create(:user) } + let(:user_session) { { webauthn_challenge: challenge } } + let(:subject) { WebauthnVerificationForm.new(user, user_session) } + + describe '#submit' do + before do + create_webauthn_configuration(user) + end + context 'when the input is valid' do + it 'returns FormResponse with success: true' do + allow(Figaro.env).to receive(:domain_name).and_return('localhost:3000') + result = instance_double(FormResponse) + params = { + authenticator_data: authenticator_data, + client_data_json: client_data_json, + signature: signature, + credential_id: credential_id, + } + expect(FormResponse).to receive(:new). + with(success: true, errors: {}).and_return(result) + expect(subject.submit(protocol, params)).to eq result + end + end + + context 'when the input is invalid' do + it 'returns FormResponse with success: false' do + result = instance_double(FormResponse) + params = { + authenticator_data: authenticator_data, + client_data_json: client_data_json, + signature: signature, + credential_id: credential_id, + } + expect(FormResponse).to receive(:new). + with(success: false, errors: {}).and_return(result) + expect(subject.submit(protocol, params)).to eq result + end + end + end +end diff --git a/spec/jobs/idv/proofer_job_spec.rb b/spec/jobs/idv/proofer_job_spec.rb deleted file mode 100644 index 58287c838af..00000000000 --- a/spec/jobs/idv/proofer_job_spec.rb +++ /dev/null @@ -1,115 +0,0 @@ -require 'rails_helper' - -describe Idv::ProoferJob do - describe '#perform' do - context 'without mocking the agent' do - let(:result_id) { SecureRandom.uuid } - let(:applicant) { { first_name: 'Jean-Luc', ssn: '123456789', zipcode: '11111' } } - let(:stages) { %i[resolution] } - - it 'works' do - Idv::ProoferJob.perform_now( - result_id: result_id, - applicant_json: applicant.to_json, - stages: stages.to_json - ) - - result = VendorValidatorResultStorage.new.load(result_id) - - expect(result.success?).to eq(true) - expect(result.timed_out?).to eq(false) - expect(result.job_failed?).to eq(false) - expect(result.messages).to be_empty - expect(result.errors).to be_empty - end - end - - context 'when mocking the agent' do - let(:result_id) { SecureRandom.uuid } - let(:applicant) { { first_name: 'Jean-Luc', last_name: 'Picard' } } - let(:stages) { %i[phone] } - let(:agent) { instance_double(Idv::Agent) } - let(:proofer_results) { {} } - - before do - allow(agent).to receive(:proof).and_return(proofer_results) - allow(Idv::Agent).to receive(:new).and_return(agent) - end - - subject do - Idv::ProoferJob.perform_now( - result_id: result_id, - applicant_json: applicant.to_json, - stages: stages.to_json - ) - end - - shared_examples 'a proofer job' do - it 'uses the Idv::Agent' do - subject - - expect(Idv::Agent).to have_received(:new).with(applicant) - expect(agent).to have_received(:proof).with(*stages) - end - end - - context 'when verification succeeds' do - let(:proofer_results) { { success: true, messages: ['a reason'] } } - - it_behaves_like 'a proofer job' - - it 'should save a successful result' do - subject - - result = VendorValidatorResultStorage.new.load(result_id) - - expect(result.success?).to eq(true) - expect(result.timed_out?).to eq(false) - expect(result.job_failed?).to eq(false) - expect(result.messages).to eq(['a reason']) - expect(result.errors).to be_empty - end - end - - context 'when verification fails' do - let(:proofer_results) do - { - success: false, - messages: ['Bad number'], - errors: { phone: 'The phone number could not be verified.' }, - } - end - - it_behaves_like 'a proofer job' - - it 'should save an unsuccessful result' do - subject - - result = VendorValidatorResultStorage.new.load(result_id) - - expect(result.success?).to eq(false) - expect(result.timed_out?).to eq(false) - expect(result.job_failed?).to eq(false) - expect(result.messages).to eq(['Bad number']) - expect(result.errors).to eq(phone: 'The phone number could not be verified.') - end - end - - context 'when the idv agent raises' do - before do - allow(agent).to receive(:proof).and_raise(RuntimeError, '🔥🔥🔥') - end - - it 'should rescue from errors and save a failed job result' do - expect { subject }.to raise_error(RuntimeError, '🔥🔥🔥') - - result = VendorValidatorResultStorage.new.load(result_id) - - expect(result.success?).to eq(false) - expect(result.timed_out?).to eq(false) - expect(result.job_failed?).to eq(true) - end - end - end - end -end diff --git a/spec/lib/encrypted_sidekiq_redis_spec.rb b/spec/lib/encrypted_sidekiq_redis_spec.rb deleted file mode 100644 index 3c2139143d3..00000000000 --- a/spec/lib/encrypted_sidekiq_redis_spec.rb +++ /dev/null @@ -1,51 +0,0 @@ -require 'rails_helper' - -describe EncryptedSidekiqRedis do - let(:key) { 'test-queue' } - let(:value) { 'some random string' } - - subject { EncryptedSidekiqRedis.new(url: Figaro.env.redis_url) } - - before do - subject.flushall - end - - describe '#new' do - it 'takes same options as Redis.new' do - expect(subject.ping).to eq 'PONG' - end - end - - describe 'encryption' do - it 'encrypts strings pushed to redis' do - subject.lpush(key, value) - - raw_value = subject.redis.blpop(key).last - - expect(raw_value).to_not eq value - expect(raw_value).to match 'cipher' - end - end - - describe 'decryption' do - it 'decrypts transparently' do - subject.lpush(key, value) - - pulled_value = subject.blpop(key).last - - expect(pulled_value).to eq value - end - end - - describe '#zrem' do - it 'modifies value string in-place' do - subject.zadd(key, 1, value) - - raw_value = subject.zrangebyscore(key, 0, 1, limit: [0, 1]).first - - expect(raw_value).to_not eq value - expect(subject.zrem(key, raw_value)).to eq true - expect(raw_value).to eq value - end - end -end diff --git a/spec/lib/feature_management_spec.rb b/spec/lib/feature_management_spec.rb index a9e61d67ba6..e16b4589792 100644 --- a/spec/lib/feature_management_spec.rb +++ b/spec/lib/feature_management_spec.rb @@ -214,28 +214,6 @@ end describe 'piv/cac feature' do - describe '#piv_cac_enabled?' do - context 'when enabled' do - before(:each) do - allow(Figaro.env).to receive(:piv_cac_enabled) { 'true' } - end - - it 'has the feature disabled' do - expect(FeatureManagement.piv_cac_enabled?).to be_truthy - end - end - - context 'when disabled' do - before(:each) do - allow(Figaro.env).to receive(:piv_cac_enabled) { 'false' } - end - - it 'has the feature disabled' do - expect(FeatureManagement.piv_cac_enabled?).to be_falsey - end - end - end - describe '#identity_pki_disabled?' do context 'when enabled' do before(:each) do @@ -258,29 +236,23 @@ end end - describe '#development_and_piv_cac_entry_enabled?' do + describe '#development_and_identity_pki_disabled?' do context 'in development environment' do before(:each) do allow(Rails.env).to receive(:development?).and_return(true) end - context 'has piv/cac enabled' do - before(:each) do - allow(Figaro.env).to receive(:piv_cac_enabled) { 'true' } - end - - it 'has piv/cac test entry enabled' do - expect(FeatureManagement.development_and_piv_cac_entry_enabled?).to be_truthy + context 'identity_pki disabled' do + it 'returns true' do + allow(Figaro.env).to receive(:identity_pki_disabled) { 'true' } + expect(FeatureManagement.development_and_identity_pki_disabled?).to be_truthy end end - context 'has piv/cac disabled' do - before(:each) do - allow(Figaro.env).to receive(:piv_cac_enabled) { 'false' } - end - - it 'has piv/cac test entry disabled' do - expect(FeatureManagement.development_and_piv_cac_entry_enabled?).to be_falsey + context 'identity_pki not disabled' do + it 'returns false' do + allow(Figaro.env).to receive(:identity_pki_disabled) { 'false' } + expect(FeatureManagement.development_and_identity_pki_disabled?).to be_falsey end end end @@ -291,23 +263,17 @@ allow(Rails.env).to receive(:development?).and_return(false) end - context 'has piv/cac enabled' do - before(:each) do - allow(Figaro.env).to receive(:piv_cac_enabled) { 'true' } - end - - it 'has piv/cac test entry disabled' do - expect(FeatureManagement.development_and_piv_cac_entry_enabled?).to be_falsey + context 'identity_pki disabled' do + it 'returns false' do + allow(Figaro.env).to receive(:identity_pki_disabled) { 'true' } + expect(FeatureManagement.development_and_identity_pki_disabled?).to be_falsey end end - context 'has piv/cac disabled' do - before(:each) do - allow(Figaro.env).to receive(:piv_cac_enabled) { 'false' } - end - - it 'has piv/cac test entry disabled' do - expect(FeatureManagement.development_and_piv_cac_entry_enabled?).to be_falsey + context 'identity_pki not disabled' do + it 'returns false' do + allow(Figaro.env).to receive(:identity_pki_disabled) { 'false' } + expect(FeatureManagement.development_and_identity_pki_disabled?).to be_falsey end end end @@ -461,4 +427,32 @@ end end end + + describe '#doc_auth_enabled?' do + it 'returns true when Figaro setting is true' do + allow(Figaro.env).to receive(:doc_auth_enabled) { 'true' } + + expect(FeatureManagement.doc_auth_enabled?).to eq(true) + end + + it 'returns false when Figaro setting is false' do + allow(Figaro.env).to receive(:doc_auth_enabled) { 'false' } + + expect(FeatureManagement.doc_auth_enabled?).to eq(false) + end + end + + describe '#doc_auth_exclusive?' do + it 'returns true when Figaro setting is true' do + allow(Figaro.env).to receive(:doc_auth_exclusive) { 'true' } + + expect(FeatureManagement.doc_auth_exclusive?).to eq(true) + end + + it 'returns false when Figaro setting is false' do + allow(Figaro.env).to receive(:doc_auth_exclusive) { 'false' } + + expect(FeatureManagement.doc_auth_exclusive?).to eq(false) + end + end end diff --git a/spec/lib/no_retry_jobs_spec.rb b/spec/lib/no_retry_jobs_spec.rb deleted file mode 100644 index 6ca3ee0f544..00000000000 --- a/spec/lib/no_retry_jobs_spec.rb +++ /dev/null @@ -1,33 +0,0 @@ -require 'rails_helper' - -RSpec.describe NoRetryJobs do - describe '#call' do - context 'when the queue is idv, sms, or voice' do - it 'runs' do - %w[idv sms voice].each do |queue| - count = 0 - NoRetryJobs.new.call(nil, nil, queue) { count += 1 } - - expect(count).to eq 1 - end - end - - it 'sets retry to false when rescuing StandardError then raises the error' do - %w[idv sms voice].each do |queue| - msg = {} - - expect { NoRetryJobs.new.call(nil, msg, queue) { raise StandardError } }. - to change { msg }.from({}).to('retry' => false).and raise_error(StandardError) - end - end - end - - context 'when the queue is not idv, sms, or voice' do - it 'does not set retry to false and raises StandardError' do - msg = {} - expect { NoRetryJobs.new.call(nil, msg, 'mailers') { raise StandardError } }. - to change(msg, :keys).by([]).and raise_error(StandardError) - end - end - end -end diff --git a/spec/lib/production_database_configuration_spec.rb b/spec/lib/production_database_configuration_spec.rb index acca56198a7..6d1f7d17f36 100644 --- a/spec/lib/production_database_configuration_spec.rb +++ b/spec/lib/production_database_configuration_spec.rb @@ -117,26 +117,6 @@ end end - context 'when the app is running on a worker host' do - before { stub_role_config('worker') } - - it 'returns the worker pool size' do - allow(Figaro.env).to receive(:database_pool_worker).and_return(8) - - expect(ProductionDatabaseConfiguration.pool).to eq(8) - end - - it 'defaults to 26' do - allow(Figaro.env).to receive(:database_pool_worker).and_return(nil) - - expect(ProductionDatabaseConfiguration.pool).to eq(26) - - allow(Figaro.env).to receive(:database_pool_worker).and_return('') - - expect(ProductionDatabaseConfiguration.pool).to eq(26) - end - end - context 'when the app is running on an host with an ambigous role' do before { stub_role_config('fake') } diff --git a/spec/lib/queue_config_spec.rb b/spec/lib/queue_config_spec.rb index 55ab9ed39b0..e9f229c11bc 100644 --- a/spec/lib/queue_config_spec.rb +++ b/spec/lib/queue_config_spec.rb @@ -15,13 +15,8 @@ end.to raise_error(ArgumentError, /Unknown queue adapter/) end - it 'handles sidekiq' do - expect(Figaro.env).to receive(:queue_adapter_weights).and_return('{"sidekiq": 1}') - expect(Upaya::QueueConfig.choose_queue_adapter).to eq :sidekiq - end - it 'handles async' do - expect(Figaro.env).to receive(:queue_adapter_weights).and_return('{"async": 1, "sidekiq": 0}') + expect(Figaro.env).to receive(:queue_adapter_weights).and_return('{"async": 1, "inline": 0}') expect(Upaya::QueueConfig.choose_queue_adapter).to eq :async end diff --git a/spec/lib/sidekiq_logging_formatter_spec.rb b/spec/lib/sidekiq_logging_formatter_spec.rb deleted file mode 100644 index c8af73cdbe5..00000000000 --- a/spec/lib/sidekiq_logging_formatter_spec.rb +++ /dev/null @@ -1,44 +0,0 @@ -require 'rails_helper' - -describe SidekiqLoggerFormatter do - let(:job_json) do - { - 'context' => 'Job raised exception', - 'job' => { - 'class' => 'ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper', - 'wrapped' => 'TestJob', - 'queue' => 'sms', - 'args' => [ - { - 'job_class' => 'TestJob', - 'job_id' => 'f1f1a7d1-b33a-4ce3-aa71-f3d74a1d99ae', - 'queue_name' => 'sms', - 'arguments' => ['sensitive pii'], - 'locale' => 'en', - }, - ], - 'retry' => true, - 'jid' => '5187f014c38c66d0840633c2', - 'error_message' => 'hello world', - 'error_class' => 'RuntimeError', - }, - 'jobstr' => '{"args":"sensitive pii"}', - } - end - - describe '#call' do - let(:now) { Time.zone.now } - - it 'redacts job arguments from JSON string' do - expect(subject.call(:WARN, now, 'job', job_json.to_json)).to_not match 'sensitive pii' - end - - it 'redacts job arguments from Hash' do - expect(subject.call(:WARN, now, 'job', job_json)).to_not match 'sensitive pii' - end - - it 'leaves non-JSON string alone' do - expect(subject.call(:WARN, now, 'job', 'sensitive pii')).to match 'sensitive pii' - end - end -end diff --git a/spec/lib/worker_health_checker_spec.rb b/spec/lib/worker_health_checker_spec.rb deleted file mode 100644 index 8ed30ec5884..00000000000 --- a/spec/lib/worker_health_checker_spec.rb +++ /dev/null @@ -1,89 +0,0 @@ -require 'rails_helper' - -RSpec.describe WorkerHealthChecker do - before do - ActiveJob::Base.queue_adapter = :sidekiq - Sidekiq.redis(&:flushdb) - end - - after do - Sidekiq.redis(&:flushdb) - ActiveJob::Base.queue_adapter = :test - end - - def create_sidekiq_queues(*queues) - Sidekiq.redis do |redis| - queues.each do |queue| - redis.sadd('queues', queue) - end - end - end - - describe '#enqueue_dummy_jobs' do - let(:queues) { YAML.load_file(Rails.root.join('config', 'sidekiq.yml'))[:queues] } - - subject(:enqueue_dummy_jobs) { WorkerHealthChecker.enqueue_dummy_jobs } - - it 'queues a dummy job per queue that updates health per job' do - queues.each do |queue| - expect(WorkerHealthChecker.status(queue).healthy?).to eq(false) - end - - enqueue_dummy_jobs - - queues.each do |queue| - expect(WorkerHealthChecker.status(queue).healthy?).to eq(true) - end - end - end - - describe '#mark_healthy!' do - let(:queue) { 'myqueue' } - let(:now) { Time.zone.now } - - it 'sets a key in redis' do - expect { WorkerHealthChecker.mark_healthy!(queue, now: now) }. - to change { WorkerHealthChecker.status(queue, now: now).healthy? }. - from(false).to(true) - end - end - - describe '.check' do - let(:now) { Time.zone.now } - subject(:check) { WorkerHealthChecker.check(now: now) } - - let(:queue1) { 'queue1' } - let(:queue2) { 'queue2' } - - before do - create_sidekiq_queues(queue1, queue2) - WorkerHealthChecker.mark_healthy!(queue1, now: now) - end - - it 'creates a snapshot check of the queues' do - expect(check.statuses.length).to eq(2) - - queue1_status, queue2_status = check.statuses.sort_by(&:queue) - - expect(queue1_status.queue).to eq('queue1') - expect(queue1_status.last_run_at.to_i).to eq(now.to_i) - expect(queue1_status.healthy).to eq(true) - - expect(queue2_status.queue).to eq('queue2') - expect(queue2_status.last_run_at).to be_nil - expect(queue2_status.healthy).to eq(false) - end - - it 'is unhealthy when not all queues are healthy' do - expect(check.healthy?).to eq(false) - end - - context 'when all queues are healthy' do - before { WorkerHealthChecker.mark_healthy!(queue2, now: now) } - - it 'is all healthy' do - expect(check.healthy?).to eq(true) - end - end - end -end diff --git a/spec/models/anonymous_user_spec.rb b/spec/models/anonymous_user_spec.rb new file mode 100644 index 00000000000..3dd4bff1624 --- /dev/null +++ b/spec/models/anonymous_user_spec.rb @@ -0,0 +1,17 @@ +require 'rails_helper' + +describe AnonymousUser do + describe 'Methods' do + it { is_expected.to respond_to(:phone_configurations) } + it { is_expected.to respond_to(:uuid) } + it { is_expected.to respond_to(:phone) } + it { is_expected.to respond_to(:email) } + it { is_expected.to respond_to(:second_factor_locked_at) } + end + + describe '#phone_configurations' do + subject { described_class.new.phone_configurations } + + it { is_expected.to eq [] } + end +end diff --git a/spec/models/doc_auth_spec.rb b/spec/models/doc_auth_spec.rb new file mode 100644 index 00000000000..f8a018f2969 --- /dev/null +++ b/spec/models/doc_auth_spec.rb @@ -0,0 +1,8 @@ +require 'rails_helper' + +describe DocAuth do + describe 'Associations' do + it { is_expected.to belong_to(:user) } + it { is_expected.to validate_presence_of(:user_id) } + end +end diff --git a/spec/models/email_address_spec.rb b/spec/models/email_address_spec.rb new file mode 100644 index 00000000000..4b1fcc7b5f4 --- /dev/null +++ b/spec/models/email_address_spec.rb @@ -0,0 +1,35 @@ +require 'rails_helper' + +describe EmailAddress do + describe 'Associations' do + it { is_expected.to belong_to(:user) } + it { is_expected.to validate_presence_of(:user_id) } + it { is_expected.to validate_presence_of(:encrypted_email) } + it { is_expected.to validate_presence_of(:email_fingerprint) } + end + + let(:email) { 'jd@example.com' } + + let(:email_address) { create(:email_address, email: email) } + + describe 'creation' do + it 'stores an encrypted form of the email address' do + expect(email_address.encrypted_email).to_not be_blank + end + end + + describe 'encrypted attributes' do + it 'decrypts email' do + expect(email_address.email).to eq email + end + + context 'with unnormalized email' do + let(:email) { ' jD@Example.Com ' } + let(:normalized_email) { 'jd@example.com' } + + it 'normalizes email' do + expect(email_address.email).to eq normalized_email + end + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 0a31acfe469..8ea4fee2ec3 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -13,6 +13,7 @@ it { is_expected.to have_one(:account_reset_request) } it { is_expected.to have_many(:phone_configurations) } it { is_expected.to have_many(:webauthn_configurations) } + it { is_expected.to have_one(:doc_auth) } end it 'does not send an email when #create is called' do @@ -21,6 +22,32 @@ end.to change(ActionMailer::Base.deliveries, :count).by(0) end + describe 'email_address' do + it 'creates an entry for the user when created' do + expect do + User.create(email: 'nobody@nobody.com') + end.to change(EmailAddress, :count).by(1) + end + + it 'mirrors the info from the user object on creation' do + user = create(:user) + email_address = user.email_address + expect(email_address).to be_present + expect(email_address.encrypted_email).to eq user.encrypted_email + expect(email_address.email).to eq user.email + expect(email_address.confirmed_at).to eq user.confirmed_at + end + + it 'mirrors the info from an unconfirmed user object' do + user = create(:user, :unconfirmed) + email_address = user.email_address + expect(email_address).to be_present + expect(email_address.encrypted_email).to eq user.encrypted_email + expect(email_address.email).to eq user.email + expect(email_address.confirmed_at).to be_nil + end + end + describe 'password validations' do it 'allows long phrases that contain common words' do user = create(:user) @@ -87,114 +114,22 @@ end end - describe '#piv_cac_enabled?' do - it 'is true when the user has a confirmed piv/cac associated' do - user = create(:user, :with_piv_or_cac) - - expect(user.piv_cac_enabled?).to eq true - end - - it 'is false when the user has no piv/cac associated' do - user = create(:user) - - expect(user.piv_cac_enabled?).to eq false - end - end - - describe '#piv_cac_available?' do - before(:each) do - allow(Figaro.env).to receive(:piv_cac_enabled).and_return('true') - end - - context 'when a user has no identities' do - let(:user) { create(:user) } - - it 'does not allow piv/cac' do - expect(user.piv_cac_available?).to be_falsey - end - end - - context 'when a user has an identity' do - let(:user) { create(:user) } - - let(:service_provider) do - create(:service_provider) - end - - let(:identity_with_sp) do - Identity.create( - user_id: user.id, - service_provider: service_provider.issuer - ) - end - - before(:each) do - user.identities << [identity_with_sp] - end - - context 'not allowing it' do - it 'does not allow piv/cac' do - expect(user.piv_cac_available?).to be_falsey - end - end - - context 'allowing it' do - before(:each) do - allow(Figaro.env).to receive(:piv_cac_agencies).and_return( - [service_provider.agency].to_json - ) - PivCacService.send(:reset_piv_cac_avaialable_agencies) - end - - it 'does allows piv/cac' do - expect(user.piv_cac_available?).to be_truthy - end - - context 'but piv/cac feature is not enabled' do - before(:each) do - allow(Figaro.env).to receive(:piv_cac_enabled).and_return('false') - end - - it 'does not allow piv/cac' do - expect(user.piv_cac_available?).to be_falsey - end - end - end - end - - context 'when a user has a piv/cac associated' do - let(:user) { create(:user, :with_piv_or_cac) } - - it 'allows piv/cac' do - expect(user.piv_cac_available?).to be_truthy - end - - context 'but the piv/cac feature is disabled' do - before(:each) do - allow(Figaro.env).to receive(:piv_cac_enabled).and_return('false') - end - - it 'does not allow piv/cac' do - expect(user.piv_cac_available?).to be_falsey - end - end - end - end - describe '#confirm_piv_cac?' do context 'when the user has a piv/cac associated' do let(:user) { create(:user, :with_piv_or_cac) } it 'is false when a blank is provided' do - expect(user.confirm_piv_cac?('')).to be_falsey + expect(MfaContext.new(user).piv_cac_configuration.mfa_confirmed?('')).to be_falsey end it 'is false when a nil is provided' do - expect(user.confirm_piv_cac?(nil)).to be_falsey + expect(MfaContext.new(user).piv_cac_configuration.mfa_confirmed?(nil)).to be_falsey end it 'is true when the correct valud is provided' do - expect(user.confirm_piv_cac?(user.x509_dn_uuid)).to be_truthy + expect( + MfaContext.new(user).piv_cac_configuration.mfa_confirmed?(user.x509_dn_uuid) + ).to be_truthy end end @@ -202,15 +137,17 @@ let(:user) { create(:user) } it 'is false when a blank is provided' do - expect(user.confirm_piv_cac?('')).to be_falsey + expect(MfaContext.new(user).piv_cac_configuration.mfa_confirmed?('')).to be_falsey end it 'is false when a nil is provided' do - expect(user.confirm_piv_cac?(nil)).to be_falsey + expect(MfaContext.new(user).piv_cac_configuration.mfa_confirmed?(nil)).to be_falsey end it 'is false when the user x509_dn_uuid value is provided' do - expect(user.confirm_piv_cac?(user.x509_dn_uuid)).to be_falsey + expect( + MfaContext.new(user).piv_cac_configuration.mfa_confirmed?(user.x509_dn_uuid) + ).to be_falsey end end end @@ -219,13 +156,13 @@ it 'is true when user has a confirmed phone' do user = create(:user, :with_phone) - expect(user.two_factor_enabled?).to eq true + expect(MfaPolicy.new(user).two_factor_enabled?).to eq true end it 'is false when user does not have a phone' do user = create(:user) - expect(user.two_factor_enabled?).to eq false + expect(MfaPolicy.new(user).two_factor_enabled?).to eq false end end @@ -235,7 +172,9 @@ it 'is true when two_factor_enabled' do user = build_stubbed(:user) - allow(user).to receive(:two_factor_enabled?).and_return true + mock_mfa = MfaPolicy.new(user) + allow(mock_mfa).to receive(:two_factor_enabled?).and_return true + allow(MfaPolicy).to receive(:new).with(user).and_return mock_mfa expect(user.need_two_factor_authentication?(nil)).to be_truthy end @@ -243,7 +182,9 @@ it 'is false when not two_factor_enabled' do user = build_stubbed(:user) - allow(user).to receive(:two_factor_enabled?).and_return false + mock_mfa = MfaPolicy.new(user) + allow(mock_mfa).to receive(:two_factor_enabled?).and_return false + allow(MfaPolicy).to receive(:new).with(user).and_return(mock_mfa) expect(user.need_two_factor_authentication?(nil)).to be_falsey end diff --git a/spec/models/webauthn_configuration_spec.rb b/spec/models/webauthn_configuration_spec.rb new file mode 100644 index 00000000000..76f3f39411b --- /dev/null +++ b/spec/models/webauthn_configuration_spec.rb @@ -0,0 +1,42 @@ +require 'rails_helper' + +describe WebauthnConfiguration do + describe 'Associations' do + it { is_expected.to belong_to(:user) } + it { is_expected.to validate_presence_of(:user_id) } + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_presence_of(:credential_id) } + it { is_expected.to validate_presence_of(:credential_public_key) } + end + + let(:subject) { create(:webauthn_configuration) } + + describe '#selection_presenters' do + it 'returns a WebauthnSelectionPresenter in an array' do + presenters = subject.selection_presenters + expect(presenters.count).to eq 1 + expect(presenters.first).to be_instance_of( + TwoFactorAuthentication::WebauthnSelectionPresenter + ) + end + end + + describe '#mfa_enabled?' do + let(:mfa_enabled) { subject.mfa_enabled? } + context 'when webauthn enabled' do + before(:each) do + allow(FeatureManagement).to receive(:webauthn_enabled?).and_return(true) + end + + it { expect(mfa_enabled).to be_truthy } + end + + context 'when webauthn not enabled' do + before(:each) do + allow(FeatureManagement).to receive(:webauthn_enabled?).and_return(false) + end + + it { expect(mfa_enabled).to be_falsey } + end + end +end diff --git a/spec/policies/piv_cac_login_option_policy_spec.rb b/spec/policies/piv_cac_login_option_policy_spec.rb deleted file mode 100644 index b17cefbb3c9..00000000000 --- a/spec/policies/piv_cac_login_option_policy_spec.rb +++ /dev/null @@ -1,66 +0,0 @@ -require 'rails_helper' - -describe PivCacLoginOptionPolicy do - let(:subject) { described_class.new(user) } - - describe '#configured?' do - context 'without a piv configured' do - let(:user) { build(:user) } - - it { expect(subject.configured?).to be_falsey } - end - - context 'with a piv configured' do - let(:user) { build(:user, :with_piv_or_cac) } - - it { expect(subject.configured?).to be_truthy } - end - end - - describe '#enabled?' do - context 'without a piv configured' do - let(:user) { build(:user) } - - it { expect(subject.configured?).to be_falsey } - end - - context 'with a piv configured' do - let(:user) { build(:user, :with_piv_or_cac) } - - it { expect(subject.configured?).to be_truthy } - end - end - - describe '#available?' do - let(:user) { build(:user) } - - context 'when enabled' do - before(:each) do - allow(subject).to receive(:enabled?).and_return(true) - end - - it { expect(subject.available?).to be_truthy } - end - - context 'when associated with a supported identity' do - before(:each) do - identity = double - allow(identity).to receive(:piv_cac_available?).and_return(true) - allow(user).to receive(:identities).and_return([identity]) - end - - it { expect(subject.available?).to be_truthy } - end - - context 'when not enabled and not a supported identity' do - before(:each) do - identity = double - allow(identity).to receive(:piv_cac_available?).and_return(false) - allow(user).to receive(:identities).and_return([identity]) - allow(subject).to receive(:enabled?).and_return(false) - end - - it { expect(subject.available?).to be_falsey } - end - end -end diff --git a/spec/policies/two_factor_authentication/piv_cac_policy_spec.rb b/spec/policies/two_factor_authentication/piv_cac_policy_spec.rb new file mode 100644 index 00000000000..115c3760ac8 --- /dev/null +++ b/spec/policies/two_factor_authentication/piv_cac_policy_spec.rb @@ -0,0 +1,126 @@ +require 'rails_helper' + +describe TwoFactorAuthentication::PivCacPolicy do + let(:subject) { described_class.new(user) } + + describe '#available?' do + context 'when a user has no identities' do + let(:user) { create(:user) } + + it 'does not allow piv/cac' do + expect(subject.available?).to be_falsey + end + end + + context 'when a user has an identity' do + let(:user) { create(:user) } + + let(:service_provider) do + create(:service_provider) + end + + let(:identity_with_sp) do + Identity.create( + user_id: user.id, + service_provider: service_provider.issuer + ) + end + + before(:each) do + user.identities << [identity_with_sp] + end + + context 'not allowing it' do + it 'does not allow piv/cac' do + expect(subject.available?).to be_falsey + end + end + + context 'allowing it' do + before(:each) do + allow(Figaro.env).to receive(:piv_cac_agencies).and_return( + [service_provider.agency].to_json + ) + PivCacService.send(:reset_piv_cac_avaialable_agencies) + end + + it 'does allows piv/cac' do + expect(subject.available?).to be_truthy + end + end + end + + context 'when a user has a piv/cac associated' do + let(:user) { create(:user, :with_piv_or_cac) } + + it 'disallows piv/cac setup' do + expect(subject.available?).to be_falsey + end + + it 'allow piv/cac visibility' do + expect(subject.visible?).to be_truthy + end + end + end + + describe '#configured?' do + context 'without a piv configured' do + let(:user) { build(:user) } + + it { expect(subject.configured?).to be_falsey } + end + + context 'with a piv configured' do + let(:user) { build(:user, :with_piv_or_cac) } + + it { expect(subject.configured?).to be_truthy } + end + end + + describe '#enabled?' do + context 'without a piv configured' do + let(:user) { build(:user) } + + it { expect(subject.configured?).to be_falsey } + end + + context 'with a piv configured' do + let(:user) { build(:user, :with_piv_or_cac) } + + it { expect(subject.configured?).to be_truthy } + end + end + + describe '#visible?' do + let(:user) { build(:user) } + + context 'when enabled' do + before(:each) do + allow(subject).to receive(:enabled?).and_return(true) + end + + it { expect(subject.visible?).to be_truthy } + end + + context 'when associated with a supported identity' do + before(:each) do + identity = double + allow(identity).to receive(:piv_cac_available?).and_return(true) + allow(user).to receive(:identities).and_return([identity]) + end + + it { expect(subject.visible?).to be_truthy } + end + + context 'when not enabled and not a supported identity' do + before(:each) do + identity = double + allow(identity).to receive(:piv_cac_available?).and_return(false) + allow(user).to receive(:identities).and_return([identity]) + allow(subject).to receive(:enabled?).and_return(false) + end + + it { expect(subject.visible?).to be_falsey } + end + end +end diff --git a/spec/policies/user_mfa_policy_spec.rb b/spec/policies/user_mfa_policy_spec.rb new file mode 100644 index 00000000000..a3af0d5968e --- /dev/null +++ b/spec/policies/user_mfa_policy_spec.rb @@ -0,0 +1,26 @@ +require 'rails_helper' + +describe MfaPolicy do + let(:subject) { described_class.new(user) } + + context 'no mfa configurations' do + let(:user) { create(:user) } + + it { expect(subject.two_factor_enabled?).to eq false } + it { expect(subject.multiple_factors_enabled?).to eq false } + end + + context 'one mfa configuration' do + let(:user) { create(:user, :with_phone) } + + it { expect(subject.two_factor_enabled?).to eq true } + it { expect(subject.multiple_factors_enabled?).to eq false } + end + + context 'two mfa configuration' do + let(:user) { create(:user, :with_phone, :with_piv_or_cac) } + + it { expect(subject.two_factor_enabled?).to eq true } + it { expect(subject.multiple_factors_enabled?).to eq true } + end +end diff --git a/spec/policies/webauthn_login_option_policy_spec.rb b/spec/policies/webauthn_login_option_policy_spec.rb new file mode 100644 index 00000000000..41e6f8f73a5 --- /dev/null +++ b/spec/policies/webauthn_login_option_policy_spec.rb @@ -0,0 +1,49 @@ +require 'rails_helper' + +describe TwoFactorAuthentication::WebauthnPolicy do + include WebauthnVerificationHelper + + describe '#configured?' do + context 'with no sp' do + let(:subject) { described_class.new(user, nil) } + + context 'without a webauthn configured' do + let(:user) { build(:user) } + + it { expect(subject.configured?).to be_falsey } + end + + context 'with a webauthn configured' do + let(:user) { create(:user) } + before do + create_webauthn_configuration(user) + end + + it 'returns a truthy value' do + expect(subject.configured?).to be_truthy + end + end + end + + context 'with an sp' do + let(:subject) { described_class.new(user, 'foo') } + + context 'without a webauthn configured' do + let(:user) { build(:user) } + + it { expect(subject.configured?).to be_falsey } + end + + context 'with a webauthn configured' do + let(:user) { create(:user) } + before do + create_webauthn_configuration(user) + end + + it 'returns a truthy value' do + expect(subject.configured?).to be_falsey + end + end + end + end +end diff --git a/spec/presenters/two_factor_auth_code/authenticator_delivery_presenter_spec.rb b/spec/presenters/two_factor_auth_code/authenticator_delivery_presenter_spec.rb index 7f4d9f4e860..7eeb436c35b 100644 --- a/spec/presenters/two_factor_auth_code/authenticator_delivery_presenter_spec.rb +++ b/spec/presenters/two_factor_auth_code/authenticator_delivery_presenter_spec.rb @@ -9,7 +9,7 @@ describe '#header' do it 'supplies a header' do - expect(presenter.header).to eq(t('devise.two_factor_authentication.totp_header_text')) + expect(presenter.header).to eq(t('two_factor_authentication.totp_header_text')) end end diff --git a/spec/presenters/two_factor_auth_code/generic_delivery_presenter_spec.rb b/spec/presenters/two_factor_auth_code/generic_delivery_presenter_spec.rb index 394a38e9dd2..acdc077c739 100644 --- a/spec/presenters/two_factor_auth_code/generic_delivery_presenter_spec.rb +++ b/spec/presenters/two_factor_auth_code/generic_delivery_presenter_spec.rb @@ -11,48 +11,6 @@ end end - describe '#piv_cac_option' do - context 'for a user without a piv/cac enabled' do - let(:presenter) { presenter_with(has_piv_cac_configured: false) } - - it 'returns nothing' do - expect(presenter.send(:piv_cac_option)).to be_nil - end - end - - context 'for a user with a piv/cac enabled' do - let(:presenter) { presenter_with(has_piv_cac_configured: true) } - - it 'returns a link to the piv/cac option' do - expect(presenter.send(:piv_cac_option)).to eq t( - 'devise.two_factor_authentication.piv_cac_fallback.text_html', - link: presenter.send(:piv_cac_link) - ) - end - end - end - - describe '#piv_cac_link' do - context 'for a user without a piv/cac enabled' do - let(:presenter) { presenter_with(has_piv_cac_configured: false) } - - it 'returns nothing' do - expect(presenter.send(:piv_cac_link)).to be_nil - end - end - - context 'for a user with a piv/cac enabled' do - let(:presenter) { presenter_with(has_piv_cac_configured: true) } - - it 'returns a link to the piv/cac option' do - expect(presenter.send(:piv_cac_link)).to eq ActionController::Base.new.view_context.link_to( - t('devise.two_factor_authentication.piv_cac_fallback.link'), - login_two_factor_piv_cac_path(locale: LinkLocaleResolver.locale) - ) - end - end - end - def presenter_with(arguments = {}, view = ActionController::Base.new.view_context) TwoFactorAuthCode::GenericDeliveryPresenter.new(data: arguments, view: view) end diff --git a/spec/presenters/two_factor_auth_code/phone_delivery_presenter_spec.rb b/spec/presenters/two_factor_auth_code/phone_delivery_presenter_spec.rb index 4650062ec07..4bb56e1e613 100644 --- a/spec/presenters/two_factor_auth_code/phone_delivery_presenter_spec.rb +++ b/spec/presenters/two_factor_auth_code/phone_delivery_presenter_spec.rb @@ -58,14 +58,14 @@ end def account_reset_cancel_link(account_reset_token) - I18n.t('devise.two_factor_authentication.account_reset.pending_html', cancel_link: - view.link_to(t('devise.two_factor_authentication.account_reset.cancel_link'), + I18n.t('two_factor_authentication.account_reset.pending_html', cancel_link: + view.link_to(t('two_factor_authentication.account_reset.cancel_link'), account_reset_cancel_url(token: account_reset_token))) end def account_reset_delete_account_link - I18n.t('devise.two_factor_authentication.account_reset.text_html', link: - view.link_to(t('devise.two_factor_authentication.account_reset.link'), + I18n.t('two_factor_authentication.account_reset.text_html', link: + view.link_to(t('two_factor_authentication.account_reset.link'), account_reset_request_path(locale: LinkLocaleResolver.locale))) end end diff --git a/spec/presenters/two_factor_auth_code/piv_cac_authentication_presenter_spec.rb b/spec/presenters/two_factor_auth_code/piv_cac_authentication_presenter_spec.rb index 9449d4137e0..b9282a9e425 100644 --- a/spec/presenters/two_factor_auth_code/piv_cac_authentication_presenter_spec.rb +++ b/spec/presenters/two_factor_auth_code/piv_cac_authentication_presenter_spec.rb @@ -13,7 +13,7 @@ def presenter_with(arguments = {}, view = ActionController::Base.new.view_contex let(:presenter) { presenter_with(reauthn: reauthn, user_email: user_email) } describe '#header' do - let(:expected_header) { t('devise.two_factor_authentication.piv_cac_header_text') } + let(:expected_header) { t('two_factor_authentication.piv_cac_header_text') } it { expect(presenter.header).to eq expected_header } end diff --git a/spec/presenters/two_factor_auth_code/webauthn_authentication_presenter_spec.rb b/spec/presenters/two_factor_auth_code/webauthn_authentication_presenter_spec.rb new file mode 100644 index 00000000000..ec0d6f474a8 --- /dev/null +++ b/spec/presenters/two_factor_auth_code/webauthn_authentication_presenter_spec.rb @@ -0,0 +1,56 @@ +require 'rails_helper' + +describe TwoFactorAuthCode::WebauthnAuthenticationPresenter do + include Rails.application.routes.url_helpers + + let(:view) { ActionController::Base.new.view_context } + let(:reauthn) {} + let(:presenter) do + TwoFactorAuthCode::WebauthnAuthenticationPresenter. + new(data: { reauthn: reauthn }, view: view) + end + + describe '#help_text' do + it 'supplies no help text' do + expect(presenter.help_text).to eq('') + end + end + + describe '#fallback_question' do + it 'supplies a fallback_question' do + expect(presenter.fallback_question).to \ + eq(t('two_factor_authentication.webauthn_fallback.question')) + end + end + + describe '#cancel_link' do + let(:locale) { LinkLocaleResolver.locale } + + context 'reauthn' do + let(:reauthn) { true } + + it 'returns the account path' do + expect(presenter.cancel_link).to eq account_path(locale: locale) + end + end + + context 'not reauthn' do + let(:reauthn) { false } + + it 'returns the sign out path' do + expect(presenter.cancel_link).to eq sign_out_path(locale: locale) + end + end + end + + it 'handles multiple locales' do + I18n.available_locales.each do |locale| + I18n.locale = locale + if locale == :en + expect(presenter.cancel_link).not_to match(%r{/en/}) + else + expect(presenter.cancel_link).to match(%r{/#{locale}/}) + end + end + end +end diff --git a/spec/presenters/two_factor_authentication/auth_app_selection_presenter_spec.rb b/spec/presenters/two_factor_authentication/auth_app_selection_presenter_spec.rb new file mode 100644 index 00000000000..43c92e4786f --- /dev/null +++ b/spec/presenters/two_factor_authentication/auth_app_selection_presenter_spec.rb @@ -0,0 +1,12 @@ +require 'rails_helper' + +describe TwoFactorAuthentication::AuthAppSelectionPresenter do + let(:subject) { described_class.new(configuration) } + let(:configuration) {} + + describe '#type' do + it 'returns auth_app' do + expect(subject.type).to eq 'auth_app' + end + end +end diff --git a/spec/presenters/two_factor_authentication/personal_key_selection_presenter_spec.rb b/spec/presenters/two_factor_authentication/personal_key_selection_presenter_spec.rb new file mode 100644 index 00000000000..f5204d63262 --- /dev/null +++ b/spec/presenters/two_factor_authentication/personal_key_selection_presenter_spec.rb @@ -0,0 +1,12 @@ +require 'rails_helper' + +describe TwoFactorAuthentication::PersonalKeySelectionPresenter do + let(:subject) { described_class.new(configuration) } + let(:configuration) {} + + describe '#type' do + it 'returns personal_key' do + expect(subject.type).to eq 'personal_key' + end + end +end diff --git a/spec/presenters/two_factor_authentication/piv_cac_selection_presenter_spec.rb b/spec/presenters/two_factor_authentication/piv_cac_selection_presenter_spec.rb new file mode 100644 index 00000000000..5cd077d058b --- /dev/null +++ b/spec/presenters/two_factor_authentication/piv_cac_selection_presenter_spec.rb @@ -0,0 +1,12 @@ +require 'rails_helper' + +describe TwoFactorAuthentication::PivCacSelectionPresenter do + let(:subject) { described_class.new(configuration) } + let(:configuration) {} + + describe '#type' do + it 'returns piv_cac' do + expect(subject.type).to eq 'piv_cac' + end + end +end diff --git a/spec/presenters/two_factor_authentication/sms_selection_presenter_spec.rb b/spec/presenters/two_factor_authentication/sms_selection_presenter_spec.rb new file mode 100644 index 00000000000..ce1794d1579 --- /dev/null +++ b/spec/presenters/two_factor_authentication/sms_selection_presenter_spec.rb @@ -0,0 +1,29 @@ +require 'rails_helper' + +describe TwoFactorAuthentication::SmsSelectionPresenter do + let(:subject) { described_class.new(phone) } + + describe '#type' do + context 'when a user has only one phone configuration' do + let(:user) { create(:user, :with_phone) } + let(:phone) { MfaContext.new(user).phone_configurations.first } + + it 'returns sms' do + expect(subject.type).to eq 'sms' + end + end + + context 'when a user has more than one phone configuration' do + let(:user) { create(:user, :with_phone) } + let(:phone) do + record = create(:phone_configuration, user: user) + user.reload + record + end + + it 'returns sms:id' do + expect(subject.type).to eq "sms:#{phone.id}" + end + end + end +end diff --git a/spec/presenters/two_factor_authentication/voice_selection_presenter_spec.rb b/spec/presenters/two_factor_authentication/voice_selection_presenter_spec.rb new file mode 100644 index 00000000000..9e2089fa988 --- /dev/null +++ b/spec/presenters/two_factor_authentication/voice_selection_presenter_spec.rb @@ -0,0 +1,29 @@ +require 'rails_helper' + +describe TwoFactorAuthentication::VoiceSelectionPresenter do + let(:subject) { described_class.new(phone) } + + describe '#type' do + context 'when a user has only one phone configuration' do + let(:user) { create(:user, :with_phone) } + let(:phone) { MfaContext.new(user).phone_configurations.first } + + it 'returns voice' do + expect(subject.type).to eq 'voice' + end + end + + context 'when a user has more than one phone configuration' do + let(:user) { create(:user, :with_phone) } + let(:phone) do + record = create(:phone_configuration, user: user) + user.reload + record + end + + it 'returns voice:id' do + expect(subject.type).to eq "voice:#{phone.id}" + end + end + end +end diff --git a/spec/presenters/two_factor_authentication/webauthn_selection_presenter_spec.rb b/spec/presenters/two_factor_authentication/webauthn_selection_presenter_spec.rb new file mode 100644 index 00000000000..74457bdb301 --- /dev/null +++ b/spec/presenters/two_factor_authentication/webauthn_selection_presenter_spec.rb @@ -0,0 +1,12 @@ +require 'rails_helper' + +describe TwoFactorAuthentication::WebauthnSelectionPresenter do + let(:subject) { described_class.new(configuration) } + let(:configuration) {} + + describe '#type' do + it 'returns webauthn' do + expect(subject.type).to eq 'webauthn' + end + end +end diff --git a/spec/presenters/two_factor_login_options_presenter_spec.rb b/spec/presenters/two_factor_login_options_presenter_spec.rb index c39f5d4bcd5..660413baace 100644 --- a/spec/presenters/two_factor_login_options_presenter_spec.rb +++ b/spec/presenters/two_factor_login_options_presenter_spec.rb @@ -27,9 +27,9 @@ receive(:account_reset_token).and_return('foo') expect(presenter.account_reset_or_cancel_link).to eq \ - t('devise.two_factor_authentication.account_reset.pending_html', + t('two_factor_authentication.account_reset.pending_html', cancel_link: view.link_to( - t('devise.two_factor_authentication.account_reset.cancel_link'), + t('two_factor_authentication.account_reset.cancel_link'), account_reset_cancel_url(token: 'foo') )) end @@ -39,9 +39,9 @@ receive(:account_reset_token_valid?).and_return(false) expect(presenter.account_reset_or_cancel_link).to eq \ - t('devise.two_factor_authentication.account_reset.text_html', + t('two_factor_authentication.account_reset.text_html', link: view.link_to( - t('devise.two_factor_authentication.account_reset.link'), + t('two_factor_authentication.account_reset.link'), account_reset_request_path(locale: LinkLocaleResolver.locale) )) end diff --git a/spec/presenters/two_factor_options_presenter_spec.rb b/spec/presenters/two_factor_options_presenter_spec.rb new file mode 100644 index 00000000000..472ec89aa79 --- /dev/null +++ b/spec/presenters/two_factor_options_presenter_spec.rb @@ -0,0 +1,69 @@ +require 'rails_helper' + +describe TwoFactorOptionsPresenter do + include Rails.application.routes.url_helpers + + let(:user) { build(:user) } + let(:presenter) do + described_class.new(user, nil) + end + + it 'supplies a title' do + expect(presenter.title).to eq \ + t('titles.two_factor_setup') + end + + it 'supplies a heading' do + expect(presenter.heading).to eq \ + t('two_factor_authentication.two_factor_choice') + end + + describe '#options' do + it 'supplies all the options for a user with no mfa configured' do + expect(presenter.options.map(&:class)).to eq [ + TwoFactorAuthentication::SmsSelectionPresenter, + TwoFactorAuthentication::VoiceSelectionPresenter, + TwoFactorAuthentication::AuthAppSelectionPresenter, + TwoFactorAuthentication::WebauthnSelectionPresenter, + ] + end + + context 'with a user with a phone configured' do + let(:user) { build(:user, :with_phone) } + + it 'supplies all the options' do + expect(presenter.options.map(&:class)).to eq [ + TwoFactorAuthentication::SmsSelectionPresenter, + TwoFactorAuthentication::VoiceSelectionPresenter, + TwoFactorAuthentication::AuthAppSelectionPresenter, + TwoFactorAuthentication::WebauthnSelectionPresenter, + ] + end + end + + context 'with a user with totp configured' do + let(:user) { build(:user, :with_authentication_app) } + + it 'supplies all the options but the auth app' do + expect(presenter.options.map(&:class)).to eq [ + TwoFactorAuthentication::SmsSelectionPresenter, + TwoFactorAuthentication::VoiceSelectionPresenter, + TwoFactorAuthentication::WebauthnSelectionPresenter, + ] + end + end + + context 'with a user with webauthn configured' do + let(:user) { build(:user, :with_webauthn) } + + it 'supplies all the options' do + expect(presenter.options.map(&:class)).to eq [ + TwoFactorAuthentication::SmsSelectionPresenter, + TwoFactorAuthentication::VoiceSelectionPresenter, + TwoFactorAuthentication::AuthAppSelectionPresenter, + TwoFactorAuthentication::WebauthnSelectionPresenter, + ] + end + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 2654e358a0b..fecc5585a22 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -17,7 +17,6 @@ require 'spec_helper' require 'email_spec' require 'factory_bot' -require 'sidekiq/testing' # Checks for pending migrations before tests are run. # If you are not using ActiveRecord, you can remove this line. @@ -72,21 +71,9 @@ FakeVoiceCall.calls = [] end - config.before(:each, idv_job: true) do - allow(Idv::ProoferJob).to receive(:perform_later) do |*args| - Idv::ProoferJob.perform_now(*args) - end - end - config.around(:each, user_flow: true) do |example| Capybara.current_driver = :rack_test example.run Capybara.use_default_driver end end - -Sidekiq::Testing.inline! - -Sidekiq::Testing.server_middleware do |chain| - chain.add WorkerHealthChecker::Middleware -end diff --git a/spec/requests/constrained_route_spec.rb b/spec/requests/constrained_route_spec.rb deleted file mode 100644 index b5a1bdc7c57..00000000000 --- a/spec/requests/constrained_route_spec.rb +++ /dev/null @@ -1,59 +0,0 @@ -require 'rails_helper' - -describe 'routes that require admin + 2FA' do - def sign_in_user(user) - post( - new_user_session_path, - params: { user: { email: user.email, password: user.password } } - ) - get otp_send_path, params: { otp_delivery_selection_form: { otp_delivery_preference: 'sms' } } - follow_redirect! - post( - login_two_factor_path, - params: { otp_delivery_preference: 'sms', code: user.reload.direct_otp } - ) - end - - shared_examples 'constrained route' do |endpoint| - context 'user is signed in via 2FA but is not an admin' do - it 'does not allow access' do - user = create(:user, :signed_up) - sign_in_user(user) - - get endpoint - - expect(response.body). - to match('The page you were looking for doesn't exist') - end - end - - context 'user is an admin but is not signed in via 2FA' do - it 'prompts admin to 2FA' do - user = create(:user, :signed_up, :admin) - - post( - new_user_session_path, - params: { user: { email: user.email, password: user.password } } - ) - - get endpoint - - expect(response.status).to eq 404 - end - end - - context 'user is an admin and is signed in via 2FA' do - it 'allows access' do - user = create(:user, :signed_up, :admin) - sign_in_user(user) - - get endpoint - - expect(response.body). - to_not match('The page you were looking for doesn't exist') - end - end - end - - it_behaves_like 'constrained route', '/sidekiq' -end diff --git a/spec/requests/invalid_sign_in_params_spec.rb b/spec/requests/invalid_sign_in_params_spec.rb index c0dd3dc1b33..c4721221143 100644 --- a/spec/requests/invalid_sign_in_params_spec.rb +++ b/spec/requests/invalid_sign_in_params_spec.rb @@ -5,3 +5,9 @@ get new_user_session_path, params: { user: 'test@test.com' } end end + +context 'when the request_id param is present but with a nil value' do + it 'does not raise an error' do + get new_user_session_path, params: { request_id: nil } + end +end diff --git a/spec/services/account_reset/cancel_spec.rb b/spec/services/account_reset/cancel_spec.rb index 85bd56db94e..c34f82109b2 100644 --- a/spec/services/account_reset/cancel_spec.rb +++ b/spec/services/account_reset/cancel_spec.rb @@ -22,7 +22,7 @@ context 'when the token is valid' do context 'when the user has a phone enabled for SMS' do before(:each) do - user.phone_configurations.first.update!(delivery_preference: :sms) + MfaContext.new(user).phone_configurations.first.update!(delivery_preference: :sms) end it 'notifies the user via SMS of the account reset cancellation' do @@ -31,8 +31,11 @@ AccountReset::Cancel.new(token).call - expect(SmsAccountResetCancellationNotifierJob). - to have_received(:perform_now).with(phone: user.phone_configurations.first.phone) + expect( + SmsAccountResetCancellationNotifierJob + ).to have_received(:perform_now).with( + phone: MfaContext.new(user).phone_configurations.first.phone + ) end end @@ -40,7 +43,7 @@ it 'does not notify the user via SMS' do token = create_account_reset_request_for(user) allow(SmsAccountResetCancellationNotifierJob).to receive(:perform_now) - user.phone_configurations.clear + MfaContext.new(user).phone_configurations.clear AccountReset::Cancel.new(token).call @@ -86,7 +89,7 @@ context 'when the user does not have a phone enabled for SMS' do it 'does not notify the user via SMS' do allow(SmsAccountResetCancellationNotifierJob).to receive(:perform_now) - user.phone_configurations.first.update!(mfa_enabled: false) + MfaContext.new(user).phone_configurations.first.update!(mfa_enabled: false) AccountReset::Cancel.new('foo').call diff --git a/spec/services/account_reset/grant_request_spec.rb b/spec/services/account_reset/grant_request_spec.rb new file mode 100644 index 00000000000..fe85fc9dd56 --- /dev/null +++ b/spec/services/account_reset/grant_request_spec.rb @@ -0,0 +1,18 @@ +require 'rails_helper' + +describe AccountReset::GrantRequest do + include AccountResetHelper + + let(:user) { create(:user) } + let(:user2) { create(:user) } + + describe '#call' do + it 'adds a notified at timestamp and granted token to the user' do + create_account_reset_request_for(user) + AccountReset::GrantRequest.new(user).call + arr = AccountResetRequest.find_by(user_id: user.id) + expect(arr.granted_at).to be_present + expect(arr.granted_token).to be_present + end + end +end diff --git a/spec/services/account_reset/grant_requests_and_send_emails_spec.rb b/spec/services/account_reset/grant_requests_and_send_emails_spec.rb new file mode 100644 index 00000000000..e46352b8789 --- /dev/null +++ b/spec/services/account_reset/grant_requests_and_send_emails_spec.rb @@ -0,0 +1,78 @@ +require 'rails_helper' + +describe AccountReset::GrantRequestsAndSendEmails do + include AccountResetHelper + + let(:user) { create(:user) } + let(:user2) { create(:user) } + + describe '#call' do + context 'after waiting the full wait period' do + it 'does not send notifications when the notifications were already sent' do + create_account_reset_request_for(user) + + after_waiting_the_full_wait_period do + AccountReset::GrantRequestsAndSendEmails.new.call + notifications_sent = AccountReset::GrantRequestsAndSendEmails.new.call + expect(notifications_sent).to eq(0) + end + end + + it 'does not send notifications when the request was cancelled' do + create_account_reset_request_for(user) + cancel_request_for(user) + + after_waiting_the_full_wait_period do + notifications_sent = AccountReset::GrantRequestsAndSendEmails.new.call + expect(notifications_sent).to eq(0) + end + end + + it 'sends notifications after a request is granted' do + create_account_reset_request_for(user) + + after_waiting_the_full_wait_period do + notifications_sent = AccountReset::GrantRequestsAndSendEmails.new.call + + expect(notifications_sent).to eq(1) + end + end + + it 'sends 2 notifications after 2 requests are granted' do + create_account_reset_request_for(user) + create_account_reset_request_for(user2) + + after_waiting_the_full_wait_period do + notifications_sent = AccountReset::GrantRequestsAndSendEmails.new.call + + expect(notifications_sent).to eq(2) + end + end + end + + context 'after not waiting the full wait period' do + it 'does not send notifications after a request' do + create_account_reset_request_for(user) + + notifications_sent = AccountReset::GrantRequestsAndSendEmails.new.call + expect(notifications_sent).to eq(0) + end + + it 'does not send notifications when the request was cancelled' do + create_account_reset_request_for(user) + cancel_request_for(user) + + notifications_sent = AccountReset::GrantRequestsAndSendEmails.new.call + expect(notifications_sent).to eq(0) + end + end + end + + def after_waiting_the_full_wait_period + TwilioService::Utils.telephony_service = FakeSms + days = Figaro.env.account_reset_wait_period_days.to_i.days + Timecop.travel(Time.zone.now + days) do + yield + end + end +end diff --git a/spec/services/account_reset_service_spec.rb b/spec/services/account_reset_service_spec.rb deleted file mode 100644 index a7b50bb112d..00000000000 --- a/spec/services/account_reset_service_spec.rb +++ /dev/null @@ -1,116 +0,0 @@ -require 'rails_helper' - -describe AccountResetService do - include AccountResetHelper - - let(:user) { create(:user) } - let(:user2) { create(:user) } - - describe '#report_fraud' do - it 'removes tokens from the request' do - create_account_reset_request_for(user) - AccountResetService.report_fraud(user.account_reset_request.request_token) - arr = AccountResetRequest.find_by(user_id: user.id) - expect(arr.request_token).to_not be_present - expect(arr.granted_token).to_not be_present - expect(arr.requested_at).to be_present - expect(arr.cancelled_at).to be_present - expect(arr.reported_fraud_at).to be_present - end - - it 'does not raise an error for a fraud request with a blank token' do - token_found = AccountResetService.report_fraud('') - expect(token_found).to be(false) - end - - it 'does not raise an error for a cancel request with a nil token' do - token_found = AccountResetService.report_fraud('') - expect(token_found).to be(false) - end - - it 'does not raise an error for a cancel request with a bad token' do - token_found = AccountResetService.report_fraud('ABC') - expect(token_found).to be(false) - end - end - - describe '#grant_request' do - it 'adds a notified at timestamp and granted token to the user' do - create_account_reset_request_for(user) - AccountResetService.new(user).grant_request - arr = AccountResetRequest.find_by(user_id: user.id) - expect(arr.granted_at).to be_present - expect(arr.granted_token).to be_present - end - end - - describe '.grant_tokens_and_send_notifications' do - context 'after waiting the full wait period' do - it 'does not send notifications when the notifications were already sent' do - create_account_reset_request_for(user) - - after_waiting_the_full_wait_period do - AccountResetService.grant_tokens_and_send_notifications - notifications_sent = AccountResetService.grant_tokens_and_send_notifications - expect(notifications_sent).to eq(0) - end - end - - it 'does not send notifications when the request was cancelled' do - create_account_reset_request_for(user) - cancel_request_for(user) - - after_waiting_the_full_wait_period do - notifications_sent = AccountResetService.grant_tokens_and_send_notifications - expect(notifications_sent).to eq(0) - end - end - - it 'sends notifications after a request is granted' do - create_account_reset_request_for(user) - - after_waiting_the_full_wait_period do - notifications_sent = AccountResetService.grant_tokens_and_send_notifications - - expect(notifications_sent).to eq(1) - end - end - - it 'sends 2 notifications after 2 requests are granted' do - create_account_reset_request_for(user) - create_account_reset_request_for(user2) - - after_waiting_the_full_wait_period do - notifications_sent = AccountResetService.grant_tokens_and_send_notifications - - expect(notifications_sent).to eq(2) - end - end - end - - context 'after not waiting the full wait period' do - it 'does not send notifications after a request' do - create_account_reset_request_for(user) - - notifications_sent = AccountResetService.grant_tokens_and_send_notifications - expect(notifications_sent).to eq(0) - end - - it 'does not send notifications when the request was cancelled' do - create_account_reset_request_for(user) - cancel_request_for(user) - - notifications_sent = AccountResetService.grant_tokens_and_send_notifications - expect(notifications_sent).to eq(0) - end - end - end - - def after_waiting_the_full_wait_period - TwilioService::Utils.telephony_service = FakeSms - days = Figaro.env.account_reset_wait_period_days.to_i.days - Timecop.travel(Time.zone.now + days) do - yield - end - end -end diff --git a/spec/services/encryption/password_verifier_spec.rb b/spec/services/encryption/password_verifier_spec.rb index fa93fbcb490..e62bcd53aa9 100644 --- a/spec/services/encryption/password_verifier_spec.rb +++ b/spec/services/encryption/password_verifier_spec.rb @@ -4,6 +4,11 @@ describe '.digest' do it 'creates a digest from the password' do salt = '1' * 64 # 32 hex encoded bytes is 64 characters + # The newrelic_rpm gem added a call to `SecureRandom.hex(8)` in + # abstract_segment.rb on 6/13/18. Our New Relic tracers in + # config/initializers/new_relic_tracers.rb trigger this call, which + # is why we stub with a default value first. + allow(SecureRandom).to receive(:hex) { salt } allow(SecureRandom).to receive(:hex).once.with(32).and_return(salt) digest = described_class.digest('saltypickles') diff --git a/spec/services/encryption/user_access_key_spec.rb b/spec/services/encryption/user_access_key_spec.rb index 8a3f0a05c11..5a5f264ba26 100644 --- a/spec/services/encryption/user_access_key_spec.rb +++ b/spec/services/encryption/user_access_key_spec.rb @@ -21,6 +21,11 @@ before do allow(FeatureManagement).to receive(:use_kms?).and_return(true) + # The newrelic_rpm gem added a call to `SecureRandom.hex(8)` in + # abstract_segment.rb on 6/13/18. Our New Relic tracers in + # config/initializers/new_relic_tracers.rb trigger this call, which + # is why we stub with a default value first. + allow(SecureRandom).to receive(:random_bytes) { random_r } allow(SecureRandom).to receive(:random_bytes).with(32).and_return(random_r) stub_aws_kms_client(random_r, encrypted_random_r) end @@ -78,7 +83,7 @@ it 'assigns random_r and calculates the cek, encryption_key, and encrypted_password' do subject.build - expect(SecureRandom).to have_received(:random_bytes).once + expect(SecureRandom).to have_received(:random_bytes).with(32).once expect(subject.random_r).to eq(random_r) expect(subject.encryption_key).to eq(encryption_key) expect(subject.cek).to eq(cek) @@ -90,7 +95,7 @@ it 'derives random_r from the encryption key and sets the cek and encrypted password' do subject.unlock(encryption_key) - expect(SecureRandom).to_not have_received(:random_bytes) + expect(SecureRandom).to_not have_received(:random_bytes).with(32) expect(subject.random_r).to eq(random_r) expect(subject.encryption_key).to eq(encryption_key) expect(subject.cek).to eq(cek) diff --git a/spec/services/idv/acuant/assure_id_spec.rb b/spec/services/idv/acuant/assure_id_spec.rb new file mode 100644 index 00000000000..f012491fb58 --- /dev/null +++ b/spec/services/idv/acuant/assure_id_spec.rb @@ -0,0 +1,141 @@ +require 'rails_helper' + +describe Idv::Acuant::AssureId do + let(:subject) { Idv::Acuant::AssureId.new } + let(:instance_id) { '123' } + let(:accuant_result_2) { '{"Result":2,"Alerts":[{"Actions":"Check the document"}]}' } + let(:good_acuant_status) { [true, '{"Result":1}'] } + let(:bad_acuant_status) { [false, ''] } + let(:good_http_status) { { status: 200, body: '{"Result":1}' } } + let(:failure_alerts_status) { { status: 200, body: accuant_result_2 } } + let(:bad_http_status) { { status: 441, body: '' } } + let(:acuant_base_url) { 'https://example.com' } + let(:image_data) { 'abc' } + + describe '#create_document' do + let(:path) { '/AssureIDService/Document/Instance' } + + it 'returns a good status with an instance id' do + stub_request(:post, acuant_base_url + path).to_return(status: 200, body: instance_id) + + result = subject.create_document + + expect(result).to eq([true, instance_id]) + expect(subject.instance_id).to eq(instance_id) + end + + it 'returns a bad status' do + stub_request(:post, acuant_base_url + path).to_return(bad_http_status) + + result = subject.create_document + + expect(result).to eq(bad_acuant_status) + end + end + + describe '#post_front_image' do + let(:side) { Idv::Acuant::AssureId::FRONT } + let(:path) { "/AssureIDService/Document/#{subject.instance_id}/Image?side=#{side}&light=0" } + + before do + subject.instance_id = instance_id + end + + it 'returns a good status' do + stub_request(:post, acuant_base_url + path).to_return(good_http_status) + + result = subject.post_front_image(image_data) + + expect(result).to eq(good_acuant_status) + end + + it 'returns a bad status' do + stub_request(:post, acuant_base_url + path).to_return(bad_http_status) + + result = subject.post_front_image(image_data) + + expect(result).to eq(bad_acuant_status) + end + end + + describe '#post_back_image' do + let(:side) { Idv::Acuant::AssureId::BACK } + let(:path) { "/AssureIDService/Document/#{subject.instance_id}/Image?side=#{side}&light=0" } + + before do + subject.instance_id = instance_id + end + + it 'returns a good status' do + stub_request(:post, acuant_base_url + path).to_return(good_http_status) + + result = subject.post_back_image(image_data) + + expect(result).to eq(good_acuant_status) + end + + it 'returns a bad status' do + stub_request(:post, acuant_base_url + path).to_return(bad_http_status) + + result = subject.post_back_image(image_data) + + expect(result).to eq(bad_acuant_status) + end + end + + describe '#results' do + let(:path) { "/AssureIDService/Document/#{subject.instance_id}" } + + before do + subject.instance_id = instance_id + end + + it 'returns a good status' do + stub_request(:get, acuant_base_url + path).to_return(status: 200, body: '{}') + + result = subject.results + + expect(result).to eq([true, {}]) + end + + it 'returns a bad status' do + stub_request(:get, acuant_base_url + path).to_return(bad_http_status) + + result = subject.results + + expect(result).to eq(bad_acuant_status) + end + + it 'returns failure alerts for accuant result=2' do + stub_request(:get, acuant_base_url + path).to_return(failure_alerts_status) + + result = subject.results + + expect(result).to eq([true, JSON.parse(accuant_result_2)]) + end + end + + describe '#face_image' do + let(:path) { "/AssureIDService/Document/#{subject.instance_id}/Field/Image?key=Photo" } + + before do + subject.instance_id = instance_id + end + + it 'returns a good status' do + stub_request(:get, acuant_base_url + path).to_return(good_http_status) + + result = subject.face_image + + expect(result).to eq(good_acuant_status) + end + + it 'returns a bad status' do + stub_request(:get, acuant_base_url + path).to_return(bad_http_status) + + result = subject.face_image + + expect(result).to eq(bad_acuant_status) + end + end +end diff --git a/spec/services/idv/acuant/facial_match_spec.rb b/spec/services/idv/acuant/facial_match_spec.rb new file mode 100644 index 00000000000..137edbfaef8 --- /dev/null +++ b/spec/services/idv/acuant/facial_match_spec.rb @@ -0,0 +1,28 @@ +require 'rails_helper' + +describe Idv::Acuant::FacialMatch do + let(:subject) { Idv::Acuant::FacialMatch.new } + let(:acuant_facial_match_url) { 'https://example.com' } + + describe '#call' do + let(:path) { '/FacialMatch' } + let(:id_image) { '123' } + let(:image) { 'abc' } + + it 'returns a good status' do + stub_request(:post, acuant_facial_match_url + path).to_return(status: 200, body: '{}') + + result = subject.call(id_image, image) + + expect(result).to eq([true, {}]) + end + + it 'returns a bad status' do + stub_request(:post, acuant_facial_match_url + path).to_return(status: 441, body: '') + + result = subject.call(id_image, image) + + expect(result).to eq([false, '']) + end + end +end diff --git a/spec/services/idv/agent_spec.rb b/spec/services/idv/agent_spec.rb index 601caeeccc2..e4e15321ffe 100644 --- a/spec/services/idv/agent_spec.rb +++ b/spec/services/idv/agent_spec.rb @@ -70,6 +70,8 @@ proc { |_, r| r.add_message('reason 2') } when :failed proc { |_, r| r.add_message('bah humbug').add_error(:bad, 'stuff') } + when :timed_out + proc { |_, r| r.instance_variable_set(:@exception, Proofer::TimeoutError.new) } end Class.new(Proofer::Base) do required_attributes(:foo) @@ -86,7 +88,8 @@ errors: {}, messages: [resolution_message, state_id_message], success: true, - exception: nil + exception: nil, + timed_out: false ) end end @@ -99,7 +102,19 @@ errors: { bad: ['stuff'] }, messages: [failed_message], success: false, - exception: nil + exception: nil, + timed_out: false + ) + end + end + + context 'when the first stage times out' do + let(:stages) { %i[timed_out state_id] } + + it 'returns a result where timed out is true' do + expect(subject.to_h).to include( + success: false, + timed_out: true ) end end diff --git a/spec/services/idv/flows/doc_auth_flow_spec.rb b/spec/services/idv/flows/doc_auth_flow_spec.rb new file mode 100644 index 00000000000..527e1269807 --- /dev/null +++ b/spec/services/idv/flows/doc_auth_flow_spec.rb @@ -0,0 +1,54 @@ +require 'rails_helper' + +describe Idv::Flows::DocAuthFlow do + include DocAuthHelper + + let(:user) { create(:user) } + let(:new_session) { { doc_auth: {} } } + let(:name) { :doc_auth } + + describe '#next_step' do + it 'returns ssn as the first step' do + subject = Idv::Flows::DocAuthFlow.new(new_session, user, name) + result = subject.next_step + + expect(result).to eq('ssn') + end + + it 'returns front image after the ssn step' do + expect_next_step(:ssn, :front_image) + end + + it 'returns back image after the front image step' do + expect_next_step(:front_image, :back_image) + end + + it 'returns self_image after the doc success step' do + expect_next_step(:doc_success, :self_image) + end + + it 'returns self_image after the doc success step' do + expect_next_step(:self_image, nil) + end + end + + describe '#handle' do + it 'handles the next step and returns a form response object' do + subject = Idv::Flows::DocAuthFlow.new(new_session, user, name) + params = ActionController::Parameters.new(doc_auth: { ssn: '111111111' }) + expect_any_instance_of(Idv::Steps::SsnStep).to receive(:call).exactly(:once) + + result = subject.handle(:ssn, params) + expect(result.class).to eq(FormResponse) + expect(result.success?).to eq(true) + end + end + + def expect_next_step(step, next_step) + session = session_from_completed_flow_steps(step) + subject = Idv::Flows::DocAuthFlow.new(session, user, name) + result = subject.next_step + + expect(result.to_s).to eq(next_step.to_s) + end +end diff --git a/spec/services/idv/job_spec.rb b/spec/services/idv/job_spec.rb deleted file mode 100644 index bfb37ac5690..00000000000 --- a/spec/services/idv/job_spec.rb +++ /dev/null @@ -1,34 +0,0 @@ -require 'rails_helper' - -RSpec.describe Idv::Job do - let(:idv_session) do - Idv::Session. - new(current_user: build(:user), issuer: nil, user_session: {}). - tap { |session| session.params = applicant } - end - - let(:applicant) { { first_name: 'Greatest', dob: '01/01/1985' } } - let(:result_id) { 'abcdef' } - let(:stages) { %i[resolution] } - - describe '#submit' do - it 'generates a UUID and enqueues a Idv::ProoferJob and saves the UUID in the session' do - expect(Idv::ProoferJob).to receive(:perform_later). - with( - result_id: result_id, - applicant_json: idv_session.vendor_params.to_json, - stages: stages.to_json - ) - - expect(idv_session.async_result_id).to eq(nil) - expect(idv_session.async_result_started_at).to eq(nil) - - expect(SecureRandom).to receive(:uuid).and_return(result_id).once - - Idv::Job.submit(idv_session, stages) - - expect(idv_session.async_result_id).to eq(result_id) - expect(idv_session.async_result_started_at).to be_within(1).of(Time.zone.now.to_i) - end - end -end diff --git a/spec/services/idv/phone_step_spec.rb b/spec/services/idv/phone_step_spec.rb index 093ccc89418..4d3e49e6e93 100644 --- a/spec/services/idv/phone_step_spec.rb +++ b/spec/services/idv/phone_step_spec.rb @@ -9,87 +9,102 @@ idvs.applicant = { first_name: 'Some' } idvs end - let(:idv_form_params) { { phone: '703-555-0000', phone_confirmed_at: nil } } - let(:idv_phone_form) { Idv::PhoneForm.new(idv_session.params, user) } - - def build_step(vendor_validator_result) - described_class.new( - idv_session: idv_session, - idv_form_params: idv_form_params, - vendor_validator_result: vendor_validator_result - ) - end + let(:good_phone) { '2255555000' } + let(:bad_phone) { '7035555555' } + let(:fail_phone) { '7035555999' } + let(:timeout_phone) { '7035555888' } - describe '#submit' do - let(:context) { 'some context' } + subject { described_class.new(idv_session: idv_session) } - it 'returns true for mock-happy phone' do - step = build_step( - Idv::VendorResult.new( - success: true, - errors: {}, - context: context - ) - ) + describe '#submit' do + it 'succeeds with good params' do + context = { stages: [{ address: 'AddressMock' }] } + extra = { vendor: { messages: [], context: context, exception: nil, timed_out: false } } - result = step.submit + result = subject.submit(phone: good_phone) expect(result).to be_kind_of(FormResponse) expect(result.success?).to eq(true) expect(result.errors).to be_empty + expect(result.extra).to eq(extra) expect(idv_session.vendor_phone_confirmation).to eq true - expect(idv_session.params).to eq idv_phone_form.idv_params - expect(result.extra).to include( - vendor: { - messages: [], - context: context, - exception: nil, - } - ) + expect(idv_session.applicant).to eq(first_name: 'Some', phone: good_phone) end - it 'returns false for mock-sad phone' do - idv_form_params[:phone] = '703-555-5555' - errors = { phone: ['The phone number could not be verified.'] } + it 'fails with bad params' do + context = { stages: [{ address: 'AddressMock' }] } + extra = { vendor: { messages: [], context: context, exception: nil, timed_out: false } } - step = build_step( - Idv::VendorResult.new( - success: false, - errors: errors - ) - ) - - result = step.submit + result = subject.submit(phone: bad_phone) expect(result).to be_kind_of(FormResponse) expect(result.success?).to eq(false) - expect(result.errors).to eq(errors) - expect(idv_session.vendor_phone_confirmation).to eq false + expect(result.errors).to eq(phone: ['The phone number could not be verified.']) + expect(result.extra).to eq(extra) + expect(idv_session.vendor_phone_confirmation).to be_falsy + expect(idv_session.user_phone_confirmation).to be_falsy + expect(idv_session.applicant).to eq(first_name: 'Some') + end + + it 'increments step attempts' do + original_step_attempts = idv_session.step_attempts[:phone] + + subject.submit(phone: bad_phone) + + expect(idv_session.step_attempts[:phone]).to eq(original_step_attempts + 1) end - it 'marks the phone number as confirmed by user if it matches 2FA phone' do - idv_form_params[:phone_confirmed_at] = Time.zone.now - step = build_step( - Idv::VendorResult.new( - success: true, - errors: {} - ) - ) - step.submit + it 'marks the phone as confirmed if it matches 2FA phone' do + user.phone_configurations = [build(:phone_configuration, user: user, phone: good_phone)] + result = subject.submit(phone: good_phone) + expect(result.success?).to eq(true) + expect(idv_session.vendor_phone_confirmation).to eq(true) expect(idv_session.user_phone_confirmation).to eq(true) end - it 'does not mark the phone number as confirmed by user if it does not match 2FA phone' do - step = build_step( - Idv::VendorResult.new( - success: true, - errors: {} - ) - ) - step.submit + it 'does not mark the phone as confirmed if it does not match 2FA phone' do + result = subject.submit(phone: good_phone) + + expect(result.success?).to eq(true) + expect(idv_session.vendor_phone_confirmation).to eq(true) + expect(idv_session.user_phone_confirmation).to be_falsy + end + end + + describe '#failure_reason' do + context 'when there are idv attempts remaining' do + it 'returns :warning' do + subject.submit(phone: bad_phone) + + expect(subject.failure_reason).to eq(:warning) + end + end + + context 'when there are not idv attempts remaining' do + it 'returns :fail' do + idv_session.step_attempts[:phone] = Idv::Attempter.idv_max_attempts - 1 + + subject.submit(phone: bad_phone) + + expect(subject.failure_reason).to eq(:fail) + end + end + + context 'when the vendor raises a timeout exception' do + it 'returns :timeout' do + subject.submit(phone: timeout_phone) + + expect(subject.failure_reason).to eq(:timeout) + end + end + + context 'when the vendor raises an exception' do + it 'returns :jobfail' do + subject.submit(phone: fail_phone) - expect(idv_session.user_phone_confirmation).to eq(false) + expect(subject.failure_reason).to eq(:jobfail) + end end end end diff --git a/spec/services/idv/profile_step_spec.rb b/spec/services/idv/profile_step_spec.rb index 714875ba80b..ef0af5b7491 100644 --- a/spec/services/idv/profile_step_spec.rb +++ b/spec/services/idv/profile_step_spec.rb @@ -3,7 +3,6 @@ describe Idv::ProfileStep do let(:user) { create(:user) } let(:idv_session) { Idv::Session.new(user_session: {}, current_user: user, issuer: nil) } - let(:idv_profile_form) { Idv::ProfileForm.new(idv_session.params, user) } let(:user_attrs) do { first_name: 'Some', @@ -13,137 +12,93 @@ address1: '123 Main St', address2: '', city: 'Somewhere', - state: 'KS', + state: 'VA', zipcode: '66044', + state_id_number: '123abc', + state_id_type: 'drivers_license', } end - def build_step(params, vendor_validator_result) - idv_session.params.merge!(params) - idv_session.applicant = idv_session.vendor_params - - described_class.new( - idv_form_params: params, - vendor_validator_result: vendor_validator_result, - idv_session: idv_session - ) - end + subject { described_class.new(idv_session: idv_session) } describe '#submit' do it 'succeeds with good params' do - messages = ['Everything looks good'] + context = { stages: [{ resolution: 'ResolutionMock' }, { state_id: 'StateIdMock' }] } extra = { idv_attempts_exceeded: false, - vendor: { messages: messages, context: {}, exception: nil }, + vendor: { messages: [], context: context, exception: nil, timed_out: false }, } - step = build_step( - user_attrs, - Idv::VendorResult.new( - success: true, - errors: {}, - messages: messages, - applicant: { first_name: 'Some' } - ) - ) - - result = step.submit + result = subject.submit(user_attrs) expect(result).to be_kind_of(FormResponse) expect(result.success?).to eq(true) expect(result.errors).to be_empty expect(result.extra).to eq(extra) expect(idv_session.profile_confirmation).to eq true + expect(idv_session.resolution_successful).to eq true + expect(idv_session.applicant).to eq(user_attrs.merge(uuid: user.uuid)) end - it 'fails with invalid SSN' do - messages = ['The SSN was suspicious'] + it 'fails with bad params' do + user_attrs[:ssn] = '666-66-6666' + + context = { stages: [{ resolution: 'ResolutionMock' }] } errors = { ssn: ['Unverified SSN.'] } extra = { idv_attempts_exceeded: false, - vendor: { messages: messages, context: {}, exception: nil }, + vendor: { messages: [], context: context, exception: nil, timed_out: false }, } - step = build_step( - user_attrs.merge(ssn: '666-66-6666'), - Idv::VendorResult.new(success: false, errors: errors, messages: messages) - ) - - result = step.submit + result = subject.submit(user_attrs) expect(result).to be_kind_of(FormResponse) expect(result.success?).to eq(false) expect(result.errors).to eq(errors) expect(result.extra).to eq(extra) expect(idv_session.profile_confirmation).to be_nil + expect(idv_session.resolution_successful).to be_nil + expect(idv_session.applicant).to be_nil end - it 'fails with invalid first name' do - errors = { first_name: ['Unverified first name.'] } - messages = ['The name was suspicious'] - extra = { - idv_attempts_exceeded: false, - vendor: { messages: messages, context: {}, exception: nil }, - } - - step = build_step( - user_attrs.merge(first_name: 'Bad'), - Idv::VendorResult.new(success: false, errors: errors, messages: messages) - ) - - result = step.submit - - expect(result).to be_kind_of(FormResponse) - expect(result.success?).to eq(false) - expect(result.errors).to eq(errors) - expect(result.extra).to eq(extra) - expect(idv_session.profile_confirmation).to be_nil + it 'increments attempts count' do + expect { subject.submit(user_attrs) }.to change(user, :idv_attempts).by(1) end + end - it 'fails with invalid ZIP code on current address' do - messages = ['The ZIP code was suspicious'] - errors = { zipcode: ['Unverified ZIP code.'] } - extra = { - idv_attempts_exceeded: false, - vendor: { messages: messages, context: {}, exception: nil }, - } + describe '#failure_reason' do + context 'when there are idv attempts remaining' do + it 'returns :warning' do + subject.submit(user_attrs.merge(first_name: 'Bad')) - step = build_step( - user_attrs.merge(zipcode: '00000'), - Idv::VendorResult.new(success: false, errors: errors, messages: messages) - ) + expect(subject.failure_reason).to eq(:warning) + end + end - result = step.submit + context 'when there are not idv attempts remaining' do + it 'returns :fail' do + user.update(idv_attempts: Idv::Attempter.idv_max_attempts - 1) - expect(result).to be_kind_of(FormResponse) - expect(result.success?).to eq(false) - expect(result.errors).to eq(errors) - expect(result.extra).to eq(extra) - expect(idv_session.profile_confirmation).to be_nil - end + subject.submit(user_attrs.merge(first_name: 'Bad')) - it 'increments attempts count' do - step = build_step(user_attrs, Idv::VendorResult.new(errors: {})) - expect { step.submit }.to change(user, :idv_attempts).by(1) + expect(subject.failure_reason).to eq(:fail) + end end - it 'initializes the idv_session' do - step = build_step(user_attrs, Idv::VendorResult.new(errors: {})) - step.submit + context 'when the vendor raises a timeout exception' do + it 'returns :timeout' do + subject.submit(user_attrs.merge(first_name: 'Time')) - expect(idv_session.params).to eq user_attrs - expect(idv_session.applicant).to eq user_attrs.merge('uuid' => user.uuid) + expect(subject.failure_reason).to eq(:timeout) + end end - end - describe '#attempts_exceeded?' do - it 'calls Idv::Attempter#exceeded?' do - attempter = instance_double(Idv::Attempter) - allow(Idv::Attempter).to receive(:new).with(user).and_return(attempter) - allow(attempter).to receive(:exceeded?) + context 'when the vendor raises an exception' do + it 'returns :jobfail' do + subject.submit(user_attrs.merge(first_name: 'Fail')) - step = build_step(user_attrs, Idv::VendorResult.new(errors: {})) - expect(step.attempts_exceeded?).to eq attempter.exceeded? + expect(subject.failure_reason).to eq(:jobfail) + end end end end diff --git a/spec/services/idv/send_phone_confirmation_otp_spec.rb b/spec/services/idv/send_phone_confirmation_otp_spec.rb index 1acb84c319e..8e782e7db13 100644 --- a/spec/services/idv/send_phone_confirmation_otp_spec.rb +++ b/spec/services/idv/send_phone_confirmation_otp_spec.rb @@ -14,7 +14,7 @@ before do # Setup Idv::Session - idv_session.params[:phone] = phone + idv_session.applicant = { phone: phone } idv_session.phone_confirmation_otp_delivery_method = otp_delivery_preference # Mock Idv::GeneratePhoneConfirmationOtp diff --git a/spec/services/idv/utils/images_to_tmp_files_spec.rb b/spec/services/idv/utils/images_to_tmp_files_spec.rb new file mode 100644 index 00000000000..5442cbdfe52 --- /dev/null +++ b/spec/services/idv/utils/images_to_tmp_files_spec.rb @@ -0,0 +1,21 @@ +require 'rails_helper' + +describe Idv::Utils::ImagesToTmpFiles do + let(:images) { %w[abc def] } + let(:subject) { Idv::Utils::ImagesToTmpFiles.new(*images) } + + describe '#call' do + it 'creates temporary files for the images' do + save_paths = [] + subject.call do |tmp_fns| + save_paths << tmp_fns[0].path + save_paths << tmp_fns[1].path + expect(File.read(tmp_fns[0])).to eq('abc') + expect(File.read(tmp_fns[1])).to eq('def') + end + + expect(File.exist?(save_paths[0])).to eq(false) + expect(File.exist?(save_paths[1])).to eq(false) + end + end +end diff --git a/spec/services/idv/utils/pii_from_doc_spec.rb b/spec/services/idv/utils/pii_from_doc_spec.rb new file mode 100644 index 00000000000..48c07d0e901 --- /dev/null +++ b/spec/services/idv/utils/pii_from_doc_spec.rb @@ -0,0 +1,17 @@ +require 'rails_helper' + +describe Idv::Utils::PiiFromDoc do + include DocAuthHelper + + let(:subject) { Idv::Utils::PiiFromDoc } + let(:ssn) { '123' } + let(:phone) { '456' } + + describe '#call' do + it 'correctly parses the pii data from acuant and returns a hash' do + results = subject.new(DocAuthHelper::ACUANT_RESULTS).call(ssn, phone) + + expect(results).to eq(DocAuthHelper::ACUANT_RESULTS_TO_PII) + end + end +end diff --git a/spec/services/idv/vendor_result_spec.rb b/spec/services/idv/vendor_result_spec.rb deleted file mode 100644 index d3cc893faf2..00000000000 --- a/spec/services/idv/vendor_result_spec.rb +++ /dev/null @@ -1,62 +0,0 @@ -require 'rails_helper' - -RSpec.describe Idv::VendorResult do - let(:success) { true } - let(:errors) { { foo: ['is not valid'] } } - let(:messages) { %w[foo bar baz] } - let(:applicant) { { last_name: 'Ever', first_name: 'Greatest' } } - let(:timed_out) { false } - - subject(:vendor_result) do - Idv::VendorResult.new( - success: success, - errors: errors, - messages: messages, - applicant: applicant, - timed_out: timed_out - ) - end - - describe '#success?' do - it 'is the success value' do - expect(vendor_result.success?).to eq(success) - end - end - - describe '#timed_out?' do - it 'is the timed_out value' do - expect(vendor_result.timed_out?).to eq(timed_out) - end - end - - describe '#to_json' do - it 'serializes applicant correctly' do - json = vendor_result.to_json - - parsed = JSON.parse(json, symbolize_names: true) - expect(parsed[:applicant][:last_name]).to eq(applicant[:last_name]) - end - end - - describe '.new_from_json' do - subject(:new_from_json) { Idv::VendorResult.new_from_json(vendor_result.to_json) } - - it 'has simple attributes' do - expect(new_from_json.success?).to eq(vendor_result.success?) - expect(new_from_json.errors).to eq(vendor_result.errors) - expect(new_from_json.messages).to eq(vendor_result.messages) - end - - it 'turns applicant into a full object' do - expect(new_from_json.applicant[:last_name]).to eq(applicant[:last_name]) - end - - context 'without an applicant' do - let(:applicant) { nil } - - it 'does not have an applicant' do - expect(new_from_json.applicant).to eq(nil) - end - end - end -end diff --git a/spec/services/otp_rate_limiter_spec.rb b/spec/services/otp_rate_limiter_spec.rb index 51ce271c314..df7aa0980b3 100644 --- a/spec/services/otp_rate_limiter_spec.rb +++ b/spec/services/otp_rate_limiter_spec.rb @@ -2,12 +2,14 @@ RSpec.describe OtpRateLimiter do let(:current_user) { build(:user, :with_phone) } + let(:phone) { MfaContext.new(current_user).phone_configurations.first.phone } + subject(:otp_rate_limiter) do - OtpRateLimiter.new(phone: current_user.phone_configurations.first.phone, user: current_user) + OtpRateLimiter.new(phone: phone, user: current_user) end let(:phone_fingerprint) do - Pii::Fingerprinter.fingerprint(current_user.phone_configurations.first.phone) + Pii::Fingerprinter.fingerprint(phone) end let(:rate_limited_phone) { OtpRequestsTracker.find_by(phone_fingerprint: phone_fingerprint) } @@ -29,9 +31,7 @@ describe '#increment' do it 'updates otp_last_sent_at' do - tracker = OtpRequestsTracker.find_or_create_with_phone( - current_user.phone_configurations.first.phone - ) + tracker = OtpRequestsTracker.find_or_create_with_phone(phone) old_otp_last_sent_at = tracker.reload.otp_last_sent_at otp_rate_limiter.increment new_otp_last_sent_at = tracker.reload.otp_last_sent_at diff --git a/spec/services/phone_verification_spec.rb b/spec/services/phone_verification_spec.rb index 86db9f3fe67..b771ba20d05 100644 --- a/spec/services/phone_verification_spec.rb +++ b/spec/services/phone_verification_spec.rb @@ -2,44 +2,24 @@ describe PhoneVerification do describe '#send_sms' do - it 'makes a POST request to Twilio Verify endpoint' do - PhoneVerification.adapter = FakeAdapter - - phone = '17873270143' - headers = { 'X-Authy-API-Key' => 'secret' } - locale = 'es' - code = '123456' - body = { - code_length: 6, - country_code: '1', - custom_code: code, - locale: locale, - phone_number: '7873270143', - via: 'sms', - } - connecttimeout = PhoneVerification::OPEN_TIMEOUT - timeout = PhoneVerification::READ_TIMEOUT + let(:phone) { '17035551212' } + let(:code) { '123456' } + let(:verification) do + PhoneVerification.new(phone: phone, code: code).send_sms + end - expect(FakeAdapter).to receive(:post). - with( - PhoneVerification::AUTHY_START_ENDPOINT, - headers: headers, - body: body, - connecttimeout: connecttimeout, - timeout: timeout - ).and_return(FakeAdapter::SuccessResponse.new) + it 'does not raise an error when the response is successful' do + PhoneVerification.adapter = FakeAdapter + allow(FakeAdapter).to receive(:post).and_return(FakeAdapter::SuccessResponse.new) - PhoneVerification.new(phone: phone, locale: locale, code: code).send_sms + expect { verification }.to_not raise_error end it 'raises VerifyError when response is not successful' do PhoneVerification.adapter = FakeAdapter - phone = '17035551212' - code = '123456' - allow(FakeAdapter).to receive(:post).and_return(FakeAdapter::ErrorResponse.new) - expect { PhoneVerification.new(phone: phone, code: code).send_sms }.to raise_error do |error| + expect { verification }.to raise_error do |error| expect(error.code).to eq 60_033 expect(error.message).to eq 'Invalid number' expect(error).to be_a(PhoneVerification::VerifyError) @@ -48,12 +28,9 @@ it 'raises VerifyError when response body is not valid JSON' do PhoneVerification.adapter = FakeAdapter - phone = '17035551212' - code = '123456' - allow(FakeAdapter).to receive(:post).and_return(FakeAdapter::EmptyResponse.new) - expect { PhoneVerification.new(phone: phone, code: code).send_sms }.to raise_error do |error| + expect { verification }.to raise_error do |error| expect(error.code).to eq 0 expect(error.message).to eq '' expect(error.status).to eq 400 @@ -61,5 +38,54 @@ expect(error).to be_a(PhoneVerification::VerifyError) end end + + it 'calls the Twilio/Authy Verify API with the right parameters' do + PhoneVerification.adapter = Faraday.new(url: PhoneVerification::AUTHY_HOST) + + locale = 'fr' + body = "code_length=6&country_code=1&custom_code=#{code}&locale=#{locale}&" \ + "phone_number=7035551212&via=sms" + + stub_request(:post, 'https://api.authy.com/protected/json/phones/verification/start'). + with( + body: body, + headers: { + 'Accept' => '*/*', + 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'Content-Type' => 'application/x-www-form-urlencoded', + 'User-Agent' => 'Faraday v0.15.2', + 'X-Authy-Api-Key' => Figaro.env.twilio_verify_api_key, + } + ). + to_return(status: 200, body: '', headers: {}) + + PhoneVerification.new(phone: phone, code: code, locale: locale).send_sms + end + + it 'rescues timeout errors, retries, then raises a custom Twilio error' do + PhoneVerification.adapter = FakeAdapter + expect(FakeAdapter).to receive(:post).twice.and_raise(Faraday::TimeoutError) + + expect { verification }.to raise_error do |error| + expect(error.code).to eq 4_815_162_342 + expect(error.message).to eq 'Twilio Verify: Faraday::TimeoutError' + expect(error.status).to eq 0 + expect(error.response).to eq '' + expect(error).to be_a(PhoneVerification::VerifyError) + end + end + + it 'rescues failed connection errors, retries, then raises a custom Twilio error' do + PhoneVerification.adapter = FakeAdapter + expect(FakeAdapter).to receive(:post).twice.and_raise(Faraday::ConnectionFailed.new('error')) + + expect { verification }.to raise_error do |error| + expect(error.code).to eq 4_815_162_342 + expect(error.message).to eq 'Twilio Verify: Faraday::ConnectionFailed' + expect(error.status).to eq 0 + expect(error.response).to eq '' + expect(error).to be_a(PhoneVerification::VerifyError) + end + end end end diff --git a/spec/services/piv_cac_service_spec.rb b/spec/services/piv_cac_service_spec.rb index 5222cb9afc4..f94795b43ad 100644 --- a/spec/services/piv_cac_service_spec.rb +++ b/spec/services/piv_cac_service_spec.rb @@ -27,7 +27,7 @@ describe '#decode_token' do context 'when configured for local development' do before(:each) do - allow(FeatureManagement).to receive(:development_and_piv_cac_entry_enabled?) { true } + allow(FeatureManagement).to receive(:development_and_identity_pki_disabled?) { true } end it 'raises an error if no token provided' do @@ -58,7 +58,7 @@ context 'when communicating with piv/cac service' do context 'when in non-development mode' do before(:each) do - allow(FeatureManagement).to receive(:development_and_piv_cac_entry_enabled?) { false } + allow(FeatureManagement).to receive(:development_and_identity_pki_disabled?) { false } end it 'raises an error if no token provided' do @@ -69,7 +69,6 @@ describe 'when configured with a user-facing endpoint' do before(:each) do - allow(Figaro.env).to receive(:piv_cac_enabled) { 'true' } allow(Figaro.env).to receive(:identity_pki_disabled) { 'false' } allow(Figaro.env).to receive(:piv_cac_service_url) { base_url } end @@ -85,7 +84,7 @@ context 'when in development mode' do before(:each) do - allow(FeatureManagement).to receive(:development_and_piv_cac_entry_enabled?) { true } + allow(FeatureManagement).to receive(:development_and_identity_pki_disabled?) { true } end let(:nonce) { 'once' } @@ -97,7 +96,6 @@ describe 'when configured to contact remote service' do before(:each) do - allow(Figaro.env).to receive(:piv_cac_enabled) { 'true' } allow(Figaro.env).to receive(:identity_pki_disabled) { 'false' } allow(Figaro.env).to receive(:piv_cac_verify_token_url) { 'http://localhost:8443/' } end @@ -138,7 +136,6 @@ describe 'with bad json' do before(:each) do - allow(Figaro.env).to receive(:piv_cac_enabled) { 'true' } allow(Figaro.env).to receive(:identity_pki_disabled) { 'false' } allow(Figaro.env).to receive(:piv_cac_verify_token_url) { 'http://localhost:8443/' } end @@ -198,7 +195,6 @@ context 'with the agency not configured to be available' do before(:each) do - allow(FeatureManagement).to receive(:piv_cac_enabled?).and_return(true) allow(Figaro.env).to receive(:piv_cac_agencies).and_return('["bar"]') end @@ -207,7 +203,6 @@ context 'with the agency configured to be available' do before(:each) do - allow(FeatureManagement).to receive(:piv_cac_enabled?).and_return(true) allow(Figaro.env).to receive(:piv_cac_agencies).and_return('["bar","foo"]') end @@ -220,7 +215,6 @@ context 'with the agency not configured to be available' do before(:each) do - allow(FeatureManagement).to receive(:piv_cac_enabled?).and_return(true) allow(Figaro.env).to receive(:piv_cac_agencies_scoped_by_email).and_return('["bar"]') end @@ -229,7 +223,6 @@ context 'with the agency configured to be available' do before(:each) do - allow(FeatureManagement).to receive(:piv_cac_enabled?).and_return(true) allow(Figaro.env).to receive(:piv_cac_agencies_scoped_by_email).and_return('["bar","foo"]') end diff --git a/spec/services/populate_email_addresses_table_spec.rb b/spec/services/populate_email_addresses_table_spec.rb new file mode 100644 index 00000000000..10fe5e0abe5 --- /dev/null +++ b/spec/services/populate_email_addresses_table_spec.rb @@ -0,0 +1,49 @@ +require 'rails_helper' + +describe PopulateEmailAddressesTable do + let(:subject) { described_class.new } + + describe '#call' do + context 'a user with no email' do + let!(:user) { create(:user, email: '', confirmed_at: nil) } + + it 'migrates nothing' do + expect(user.email_address).to be_nil + + expect { subject.call }.to change { EmailAddress.count }.by(0) + end + end + + context 'a user with an email' do + let!(:user) { create(:user) } + + context 'and no email_address entry' do + before(:each) do + user.email_address.delete + user.reload + end + + it 'migrates without decrypting and re-encrypting' do + expect(EncryptedAttribute).to_not receive(:new) + subject.call + end + + it 'migrates the email' do + expect { subject.call }.to change { EmailAddress.count }.by(1) + + address = user.reload.email_address + expect(user.email).to eq address.email + expect(user.confirmed_at).to eq user.confirmed_at + expect(user.confirmation_sent_at).to eq user.confirmation_sent_at + expect(user.confirmation_token).to eq user.confirmation_token + end + end + + context 'and an existing email_address entry' do + it 'adds no new rows' do + expect { subject.call }.to change { EmailAddress.count }.by(0) + end + end + end + end +end diff --git a/spec/services/twilio_service_spec.rb b/spec/services/twilio_service_spec.rb index d8cc7409821..8a7a0c5976a 100644 --- a/spec/services/twilio_service_spec.rb +++ b/spec/services/twilio_service_spec.rb @@ -101,7 +101,7 @@ to raise_error(Twilio::REST::RestError, sanitized_message) end - it 'rescues timeout errors and raises a custom Twilio error' do + it 'rescues timeout errors, retries, then raises a custom Twilio error' do TwilioService::Utils.telephony_service = FakeVoiceCall error_code = 4_815_162_342 status_code = 4_815_162_342 @@ -109,12 +109,27 @@ message = "[HTTP #{status_code}] #{error_code} : timeout\n\n" service = TwilioService::Utils.new - expect(service.send(:client).calls).to receive(:create). + expect(service.send(:client).calls).to receive(:create).twice. and_raise(Faraday::TimeoutError) expect { service.place_call(to: '+123456789012', url: 'https://twimlet.com') }. to raise_error(Twilio::REST::RestError, message) end + + it 'rescues failed connection errors, retries, then raises a custom Twilio error' do + TwilioService::Utils.telephony_service = FakeVoiceCall + error_code = 4_815_162_342 + status_code = 4_815_162_342 + + message = "[HTTP #{status_code}] #{error_code} : timeout\n\n" + service = TwilioService::Utils.new + + expect(service.send(:client).calls).to receive(:create).twice. + and_raise(Faraday::ConnectionFailed.new('error')) + + expect { service.place_call(to: '+123456789012', url: 'https://twimlet.com') }. + to raise_error(Twilio::REST::RestError, message) + end end describe '#send_sms' do @@ -158,7 +173,7 @@ to raise_error(Twilio::REST::RestError, sanitized_message) end - it 'rescues timeout errors and raises a custom Twilio error' do + it 'rescues timeout errors, retries, then raises a custom Twilio error' do TwilioService::Utils.telephony_service = FakeSms error_code = 4_815_162_342 status_code = 4_815_162_342 @@ -166,11 +181,36 @@ message = "[HTTP #{status_code}] #{error_code} : timeout\n\n" service = TwilioService::Utils.new - expect(service.send(:client).messages).to receive(:create). + request_data = { + event: 'Twilio Request Timeout', + url: 'foo', + method: 'get', + params: {}, + headers: {}, + }.to_json + + expect(Rails.logger).to receive(:info).with(request_data) + + expect(service.send(:client).messages).to receive(:create).twice. and_raise(Faraday::TimeoutError) expect { service.send_sms(to: '+123456789012', body: 'test') }. to raise_error(Twilio::REST::RestError, message) end + + it 'rescues failed connection errors, retries, then raises a custom Twilio error' do + TwilioService::Utils.telephony_service = FakeSms + error_code = 4_815_162_342 + status_code = 4_815_162_342 + + message = "[HTTP #{status_code}] #{error_code} : timeout\n\n" + service = TwilioService::Utils.new + + expect(service.send(:client).messages).to receive(:create).twice. + and_raise(Faraday::ConnectionFailed.new('error')) + + expect { service.send_sms(to: '+123456789012', body: 'test') }. + to raise_error(Twilio::REST::RestError, message) + end end end diff --git a/spec/services/vendor_validator_result_storage_spec.rb b/spec/services/vendor_validator_result_storage_spec.rb deleted file mode 100644 index 009075ac889..00000000000 --- a/spec/services/vendor_validator_result_storage_spec.rb +++ /dev/null @@ -1,49 +0,0 @@ -require 'rails_helper' - -RSpec.describe VendorValidatorResultStorage do - subject(:service) { VendorValidatorResultStorage.new } - - let(:result_id) { SecureRandom.uuid } - let(:original_result) do - Idv::VendorResult.new( - success: false, - applicant: { first_name: 'First' } - ) - end - - describe '#store_result' do - it 'stores the result in redis with a TTL' do - key = service.redis_key(result_id) - - before_redis = Sidekiq.redis { |redis| redis.get(key) } - expect(before_redis).to be_nil - - service.store(result_id: result_id, result: original_result) - - Sidekiq.redis do |redis| - expect(redis.get(key)).to be_present - expect(redis.ttl(key)).to be_within(1).of(VendorValidatorResultStorage::TTL) - end - end - end - - describe '#vendor_validator_result' do - before { service.store(result_id: result_id, result: original_result) } - - it 'retrieves a stored result' do - result = service.load(result_id) - - expect(result.success?).to eq(original_result.success?) - expect(result.errors).to eq(original_result.errors) - expect(result.messages).to eq(original_result.messages) - expect(result.applicant.as_json). - to eq(original_result.applicant.as_json) - end - - it 'is nil with a bad result id' do - result = service.load(SecureRandom.uuid) - - expect(result).to be_nil - end - end -end diff --git a/spec/support/account_reset_helper.rb b/spec/support/account_reset_helper.rb index 9176d3078c1..1e4e1f86319 100644 --- a/spec/support/account_reset_helper.rb +++ b/spec/support/account_reset_helper.rb @@ -9,4 +9,8 @@ def cancel_request_for(user) account_reset_request = AccountResetRequest.find_by(user_id: user.id) account_reset_request.update(cancelled_at: Time.zone.now) end + + def grant_request(user) + AccountReset::GrantRequest.new(user).call + end end diff --git a/spec/support/controller_helper.rb b/spec/support/controller_helper.rb index 88544ad25b7..79ad53a4873 100644 --- a/spec/support/controller_helper.rb +++ b/spec/support/controller_helper.rb @@ -36,7 +36,7 @@ def stub_verify_steps_one_and_two(user) user_session = {} stub_sign_in(user) idv_session = Idv::Session.new(user_session: user_session, current_user: user, issuer: nil) - idv_session.applicant = { first_name: 'Some', last_name: 'One' } + idv_session.applicant = { first_name: 'Some', last_name: 'One' }.with_indifferent_access allow(subject).to receive(:confirm_idv_session_started).and_return(true) allow(subject).to receive(:confirm_idv_attempts_allowed).and_return(true) allow(subject).to receive(:idv_session).and_return(idv_session) diff --git a/spec/support/fake_adapter.rb b/spec/support/fake_adapter.rb index 628551296c9..1b831cff66f 100644 --- a/spec/support/fake_adapter.rb +++ b/spec/support/fake_adapter.rb @@ -14,14 +14,14 @@ def success? false end - def response_body + def body { error_code: '60033', message: 'Invalid number', }.to_json end - def response_code + def status 400 end end @@ -31,11 +31,11 @@ def success? false end - def response_body + def body '' end - def response_code + def status 400 end end diff --git a/spec/support/fake_sms.rb b/spec/support/fake_sms.rb index f884cf2e64d..93c6a6568eb 100644 --- a/spec/support/fake_sms.rb +++ b/spec/support/fake_sms.rb @@ -1,6 +1,7 @@ class FakeSms Message = Struct.new(:to, :body, :messaging_service_sid) - HttpClient = Struct.new(:adapter) + HttpClient = Struct.new(:adapter, :last_request) + LastRequest = Struct.new(:url, :params, :headers, :method) cattr_accessor :messages self.messages = [] @@ -20,6 +21,6 @@ def create(opts = {}) end def http_client - HttpClient.new(adapter: 'foo') + HttpClient.new('foo', LastRequest.new('foo', {}, {}, 'get')) end end diff --git a/spec/support/fake_voice_call.rb b/spec/support/fake_voice_call.rb index f5afeb89093..d069486b091 100644 --- a/spec/support/fake_voice_call.rb +++ b/spec/support/fake_voice_call.rb @@ -1,5 +1,6 @@ class FakeVoiceCall - HttpClient = Struct.new(:adapter) + HttpClient = Struct.new(:adapter, :last_request) + LastRequest = Struct.new(:url, :params, :headers, :method) cattr_accessor :calls self.calls = [] @@ -15,6 +16,6 @@ def create(opts = {}) end def http_client - HttpClient.new(adapter: 'foo') + HttpClient.new('foo', LastRequest.new('foo', {}, {}, 'get')) end end diff --git a/spec/support/features/doc_auth_helper.rb b/spec/support/features/doc_auth_helper.rb new file mode 100644 index 00000000000..b555c541e88 --- /dev/null +++ b/spec/support/features/doc_auth_helper.rb @@ -0,0 +1,142 @@ +module DocAuthHelper + ACUANT_RESULTS = { + 'Result' => 1, + 'Fields' => [ + { 'Name' => 'First Name', 'Value' => 'Jane' }, + { 'Name' => 'Middle Name', 'Value' => 'Ann' }, + { 'Name' => 'Surname', 'Value' => 'Doe' }, + { 'Name' => 'Address Line 1', 'Value' => '1 Street' }, + { 'Name' => 'Address City', 'Value' => 'New York' }, + { 'Name' => 'Address State', 'Value' => 'NY' }, + { 'Name' => 'Address Postal Code', 'Value' => '11364' }, + { 'Name' => 'Birth Date', 'Value' => '/Date(' + + (Date.strptime('10-05-1938', '%m-%d-%Y').strftime('%Q').to_i + 43_200_000).to_s + ')/' }, + ], + }.freeze + + ACUANT_RESULTS_TO_PII = + { + first_name: 'Jane', + middle_name: 'Ann', + last_name: 'Doe', + address1: '1 Street', + city: 'New York', + state: 'NY', + zipcode: '11364', + dob: '10/05/1938', + ssn: '123', + phone: '456', + }.freeze + + def session_from_completed_flow_steps(finished_step) + session = { doc_auth: {} } + Idv::Flows::DocAuthFlow::STEPS.each do |step, klass| + session[:doc_auth][klass.to_s] = true + return session if step == finished_step + end + session + end + + def fill_out_ssn_form_ok + fill_in 'doc_auth_ssn', with: '666-66-1234' + end + + def fill_out_ssn_form_fail + fill_in 'doc_auth_ssn', with: '' + end + + def idv_doc_auth_ssn_step + idv_doc_auth_step_path(step: :ssn) + end + + def idv_doc_auth_front_image_step + idv_doc_auth_step_path(step: :front_image) + end + + def idv_doc_auth_back_image_step + idv_doc_auth_step_path(step: :back_image) + end + + def idv_doc_auth_doc_success_step + idv_doc_auth_step_path(step: :doc_success) + end + + def idv_doc_auth_doc_failed_step + idv_doc_auth_step_path(step: :doc_failed) + end + + def idv_doc_auth_self_image_step + idv_doc_auth_step_path(step: :self_image) + end + + def complete_doc_auth_steps_before_ssn_step(user = user_with_2fa) + sign_in_and_2fa_user(user) + visit idv_doc_auth_ssn_step unless current_path == idv_doc_auth_ssn_step + end + + def complete_doc_auth_steps_before_front_image_step(user = user_with_2fa) + complete_doc_auth_steps_before_ssn_step(user) + fill_out_ssn_form_ok + click_idv_continue + end + + def complete_doc_auth_steps_before_back_image_step(user = user_with_2fa) + complete_doc_auth_steps_before_front_image_step(user) + mock_assure_id_ok + attach_image + click_idv_continue + end + + def complete_doc_auth_steps_before_doc_success_step(user = user_with_2fa) + complete_doc_auth_steps_before_back_image_step(user) + attach_image + click_idv_continue + end + + def complete_doc_auth_steps_before_doc_failed_step(user = user_with_2fa) + complete_doc_auth_steps_before_back_image_step(user) + attach_image + allow_any_instance_of(Idv::Agent).to receive(:proof). + and_return(success: false, errors: {}) + click_idv_continue + end + + def complete_doc_auth_steps_before_self_image_step(user = user_with_2fa) + complete_doc_auth_steps_before_doc_success_step(user) + click_idv_continue + end + + def mock_assure_id_ok + allow_any_instance_of(Idv::Acuant::AssureId).to receive(:create_document). + and_return([true, '123']) + allow_any_instance_of(Idv::Acuant::AssureId).to receive(:post_front_image). + and_return([true, '']) + allow_any_instance_of(Idv::Acuant::AssureId).to receive(:post_back_image). + and_return([true, '']) + allow_any_instance_of(Idv::Acuant::AssureId).to receive(:results). + and_return([true, ACUANT_RESULTS]) + allow_any_instance_of(Idv::Acuant::AssureId).to receive(:face_image).and_return([true, '']) + allow_any_instance_of(Idv::Acuant::FacialMatch).to receive(:call). + and_return([true, { 'FacialMatch' => 1 }]) + end + + def mock_assure_id_fail + allow_any_instance_of(Idv::Acuant::AssureId).to receive(:create_document). + and_return([false, '']) + end + + def enable_doc_auth + allow(FeatureManagement).to receive(:doc_auth_enabled?).and_return(true) + end + + def attach_image + attach_file 'doc_auth_image', 'app/assets/images/logo.png' + end + + def assure_id_results_with_result_2 + result = DocAuthHelper::ACUANT_RESULTS.dup + result['Result'] = 2 + result['Alerts'] = [{ 'Actions': 'Check the document' }] + result + end +end diff --git a/spec/support/features/idv_helper.rb b/spec/support/features/idv_helper.rb index 33980872edb..0b77bd47e95 100644 --- a/spec/support/features/idv_helper.rb +++ b/spec/support/features/idv_helper.rb @@ -65,15 +65,17 @@ def click_idv_continue end def choose_idv_otp_delivery_method_sms - using_wait_time(5) do - click_on t('idv.buttons.send_confirmation_code') - end + page.find( + 'label', + text: t('two_factor_authentication.otp_delivery_preference.sms') + ).click + click_on t('idv.buttons.send_confirmation_code') end def choose_idv_otp_delivery_method_voice page.find( 'label', - text: t('devise.two_factor_authentication.otp_delivery_preference.voice') + text: t('two_factor_authentication.otp_delivery_preference.voice') ).click click_on t('idv.buttons.send_confirmation_code') end diff --git a/spec/support/features/idv_step_helper.rb b/spec/support/features/idv_step_helper.rb index 879fdd3b932..8286027e2ad 100644 --- a/spec/support/features/idv_step_helper.rb +++ b/spec/support/features/idv_step_helper.rb @@ -63,7 +63,7 @@ def complete_idv_steps_before_phone_otp_verification_step(user = user_with_2fa) def complete_idv_steps_with_phone_before_review_step(user = user_with_2fa) complete_idv_steps_before_phone_step(user) - fill_out_phone_form_ok(user.phone_configurations.first.phone) + fill_out_phone_form_ok(MfaContext.new(user).phone_configurations.first.phone) click_idv_continue end diff --git a/spec/support/features/session_helper.rb b/spec/support/features/session_helper.rb index 5d8f458b1a2..0873b43dfba 100644 --- a/spec/support/features/session_helper.rb +++ b/spec/support/features/session_helper.rb @@ -81,7 +81,7 @@ def sign_in_before_2fa(user = create(:user)) allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) login_as(user, scope: :user, run_callbacks: false) - if user.phone_configurations.any? + if TwoFactorAuthentication::PhonePolicy.new(user).enabled? Warden.on_next_request do |proxy| session = proxy.env['rack.session'] session['warden.user.user.session'] = {} @@ -144,8 +144,7 @@ def sign_in_live_with_2fa(user = user_with_2fa) def sign_in_live_with_piv_cac(user = user_with_piv_cac) sign_in_user(user) - allow(FeatureManagement).to receive(:piv_cac_enabled?).and_return(true) - allow(FeatureManagement).to receive(:development_and_piv_cac_entry_enabled?).and_return(true) + allow(FeatureManagement).to receive(:development_and_identity_pki_disabled?).and_return(true) visit login_two_factor_piv_cac_path stub_piv_cac_service visit_piv_cac_service( @@ -417,12 +416,11 @@ def set_up_2fa_with_authenticator_app def register_user_with_piv_cac(email = 'test@test.com') allow(PivCacService).to receive(:piv_cac_available_for_agency?).and_return(true) - allow(FeatureManagement).to receive(:piv_cac_enabled?).and_return(true) confirm_email_and_password(email) expect(page).to have_current_path two_factor_options_path expect(page).to have_content( - t('devise.two_factor_authentication.two_factor_choice_options.piv_cac') + t('two_factor_authentication.login_options.piv_cac') ) set_up_2fa_with_piv_cac @@ -458,7 +456,6 @@ def stub_twilio_service def stub_piv_cac_service allow(Figaro.env).to receive(:identity_pki_disabled).and_return('false') - allow(Figaro.env).to receive(:piv_cac_enabled).and_return('true') allow(Figaro.env).to receive(:piv_cac_service_url).and_return('http://piv.example.com/') allow(Figaro.env).to receive(:piv_cac_verify_token_url).and_return('http://piv.example.com/') stub_request(:post, 'piv.example.com').to_return do |request| diff --git a/spec/support/features/webauth_verification_helper.rb b/spec/support/features/webauth_verification_helper.rb new file mode 100644 index 00000000000..83ff633985d --- /dev/null +++ b/spec/support/features/webauth_verification_helper.rb @@ -0,0 +1,44 @@ +module WebauthnVerificationHelper + def protocol + 'http://' + end + + def challenge + [152, 207, 129, 117, 183, 199, 18, 19, 51, 104, 207, 109, 12, 50, 143, 155] + end + + def credential_ids + '60Aa7rKEJJEkqDM0flq4NoNu3L/ZpZfamNbScSG+I9AZnV3efKCyRNXK78lRxuqmxmfa87fwrrS1+5PJvJdG0A==' + end + + def credential_id + '60Aa7rKEJJEkqDM0flq4NoNu3L/ZpZfamNbScSG+I9AZnV3efKCyRNXK78lRxuqmxmfa87fwrrS1+5PJvJdG0A==' + end + + def authenticator_data + 'SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2MBAAAAJg==' + end + + def signature + 'MEUCIQDlEB4VUN/X15N/Jmgx4ACbOlLLHRRcKsBkejpdQj81vQIgIo97sxdpP/hZgQpIXJMa3cBnAzcnfw+1CJ2LP3VvOg\ +4=' + end + + def client_data_json + 'eyJjaGFsbGVuZ2UiOiJtTS1CZGJmSEVoTXphTTl0RERLUG13Iiwib3JpZ2luIjoiaHR0cDovL2xvY2FsaG9zdDozMDAwI\ +iwidHlwZSI6IndlYmF1dGhuLmdldCJ9' + end + + def public_key + 'BBWEWPFKW60xPIcf/U098QEsiB3wUmJm9TN+bU1d5Y9noAnfr412Wu3KOrX0uhy/t14t4aFuUfNu054zkKVhQDM=' + end + + def create_webauthn_configuration(user) + WebauthnConfiguration.create( + user_id: user.id, + credential_id: credential_id, + credential_public_key: public_key, + name: 'foo' + ) + end +end diff --git a/spec/support/features/webauthn_helper.rb b/spec/support/features/webauthn_helper.rb index e079499f2b4..8de7714f29d 100644 --- a/spec/support/features/webauthn_helper.rb +++ b/spec/support/features/webauthn_helper.rb @@ -1,4 +1,23 @@ module WebauthnHelper + def mock_challenge + allow(WebAuthn).to receive(:credential_creation_options).and_return( + challenge: challenge.pack('c*') + ) + end + + def mock_press_button_on_hardware_key_and_fill_in_name_field + # this is required because the domain is embedded in the supplied attestation object + allow(WebauthnSetupForm).to receive(:domain_name).and_return('localhost:3000') + + set_hidden_field('attestation_object', attestation_object) + set_hidden_field('client_data_json', client_data_json) + fill_in 'name', with: 'mykey' + end + + def set_hidden_field(id, value) + first("input##{id}", visible: false).set(value) + end + def protocol 'http://' end diff --git a/spec/support/idv_examples/cancel_at_idv_step.rb b/spec/support/idv_examples/cancel_at_idv_step.rb index ad7c93a2856..8d032f4f901 100644 --- a/spec/support/idv_examples/cancel_at_idv_step.rb +++ b/spec/support/idv_examples/cancel_at_idv_step.rb @@ -19,20 +19,53 @@ expect(current_path).to eq(original_path) end - it 'shows the user a cancellation message with the option to cancel and reset idv' do - click_link t('links.cancel') + context 'with an sp', if: sp do + it 'shows the user a cancellation message with the option to cancel and reset idv' do + failure_to_proof_url = 'https://www.example.com/failure' + sp_name = 'Test SP' + allow_any_instance_of(ServiceProviderSessionDecorator).to receive(:failure_to_proof_url). + and_return(failure_to_proof_url) + allow_any_instance_of(ServiceProviderSessionDecorator).to receive(:sp_name). + and_return(sp_name) - expect(page).to have_content(t('idv.cancel.modal_header')) - expect(current_path).to eq(idv_cancel_path) + click_link t('links.cancel') - click_on t('forms.buttons.cancel') + expect(page).to have_content(t('idv.cancel.modal_header')) + expect(current_path).to eq(idv_cancel_path) - expect(page).to have_content(t('headings.cancellations.confirmation')) - expect(current_path).to eq(idv_cancel_path) + click_on t('forms.buttons.cancel') + + expect(page).to have_content(t('headings.cancellations.confirmation')) + expect(current_path).to eq(idv_cancel_path) + + expect(page).to have_link("‹ #{t('links.back_to_sp', sp: sp_name)}", + href: failure_to_proof_url) + + # After visiting /verify, expect to redirect to the jurisdiction step, + # the first step in the IdV flow + visit idv_path + expect(current_path).to eq(idv_jurisdiction_path) + end + end + + context 'without an sp' do + it 'shows a cancellation message with option to cancel and reset idv', if: sp.nil? do + click_link t('links.cancel') + + expect(page).to have_content(t('idv.cancel.modal_header')) + expect(current_path).to eq(idv_cancel_path) + + click_on t('forms.buttons.cancel') + + expect(page).to have_content(t('headings.cancellations.confirmation')) + expect(current_path).to eq(idv_cancel_path) + expect(page).to have_link("‹ #{t('links.back_to_sp', sp: t('links.my_account'))}", + href: account_url) - # After visiting /verify, expect to redirect to the jurisdiction step, - # the first step in the IdV flow - visit idv_path - expect(current_path).to eq(idv_jurisdiction_path) + # After visiting /verify, expect to redirect to the jurisdiction step, + # the first step in the IdV flow + visit idv_path + expect(current_path).to eq(idv_jurisdiction_path) + end end end diff --git a/spec/support/idv_examples/failed_idv_job.rb b/spec/support/idv_examples/failed_idv_job.rb index 0e1699cec07..6bf0074dbfd 100644 --- a/spec/support/idv_examples/failed_idv_job.rb +++ b/spec/support/idv_examples/failed_idv_job.rb @@ -1,6 +1,5 @@ shared_examples 'failed idv job' do |step| let(:locale) { LinkLocaleResolver.locale } - let(:idv_job_class) { Idv::ProoferJob } let(:step_locale_key) do return :sessions if step == :profile step @@ -12,12 +11,10 @@ complete_idv_steps_before_step(step) end - context 'the job raises an error' do + context 'the proofer raises an error' do before do - stub_idv_job_to_raise_error_in_background(idv_job_class) - - fill_out_idv_form_ok if step == :profile - fill_out_phone_form_ok if step == :phone + fill_out_idv_form_error if step == :profile + fill_out_phone_form_error if step == :phone click_idv_continue end @@ -29,25 +26,14 @@ end end - context 'the job times out' do + context 'the proofer times out' do before do - stub_idv_job_to_timeout_in_background(idv_job_class) - - fill_out_idv_form_ok if step == :profile - fill_out_phone_form_ok('5202691958') if step == :phone + fill_out_idv_form_timeout if step == :profile + fill_out_phone_form_timeout if step == :phone click_idv_continue - - seconds_to_travel = (Figaro.env.async_job_refresh_max_wait_seconds.to_i + 1).seconds - Timecop.travel seconds_to_travel - - visit current_path - end - - after do - Timecop.return end - it 'renders a timeout failure page' do + it 'renders a timeout failure screen' do expect(page).to have_current_path(session_failure_path(:timeout)) if step == :profile expect(page).to have_current_path(phone_failure_path(:timeout)) if step == :phone expect(page).to have_content t("idv.failure.#{step_locale_key}.heading") @@ -55,25 +41,22 @@ end end - # rubocop:disable Lint/HandleExceptions - # rubocop:disable Style/RedundantBegin - # Disabling Style/RedundantBegin because when i remove make the changes - # to remove it, fasterer can no longer parse the code... - def stub_idv_job_to_raise_error_in_background(idv_job_class) - allow(Idv::Agent).to receive(:new).and_raise('this is a test error') - allow(idv_job_class).to receive(:perform_now).and_wrap_original do |perform_now, *args| - begin - perform_now.call(*args) - rescue StandardError - # Swallow the error so it does not get re-raised by the job - end - end + def fill_out_idv_form_error + fill_out_idv_form_ok + fill_in 'profile_first_name', with: 'Fail' + end + + def fill_out_phone_form_error + fill_in :idv_phone_form_phone, with: '7035555999' + end + + def fill_out_idv_form_timeout + fill_out_idv_form_ok + fill_in 'profile_first_name', with: 'Time' end - # rubocop:enable Style/RedundantBegin - # rubocop:enable Lint/HandleExceptions - def stub_idv_job_to_timeout_in_background(idv_job_class) - allow(idv_job_class).to receive(:perform_now) + def fill_out_phone_form_timeout + fill_in :idv_phone_form_phone, with: '7035555888' end def session_failure_path(reason) diff --git a/spec/support/idv_examples/max_attempts.rb b/spec/support/idv_examples/max_attempts.rb index 25fb4bdd203..094569e18c0 100644 --- a/spec/support/idv_examples/max_attempts.rb +++ b/spec/support/idv_examples/max_attempts.rb @@ -119,8 +119,8 @@ def expect_user_to_fail_at_phone_step end def advance_to_phone_step - fill_out_idv_jurisdiction_ok - click_idv_continue + # Currently on the session success path + # Click continue to advance to the phone step click_idv_continue end end diff --git a/spec/support/shared_examples/account_creation.rb b/spec/support/shared_examples/account_creation.rb index dde4eaf5b47..ef357ffcccd 100644 --- a/spec/support/shared_examples/account_creation.rb +++ b/spec/support/shared_examples/account_creation.rb @@ -1,16 +1,3 @@ -shared_examples 'csrf error when asking for new personal key' do |sp| - it 'redirects to sign in page', email: true do - visit_idp_from_sp_with_loa1(sp) - register_user - allow_any_instance_of(Users::PersonalKeysController). - to receive(:create).and_raise(ActionController::InvalidAuthenticityToken) - click_on t('users.personal_key.get_another') - - expect(current_path).to eq new_user_session_path - expect(page).to have_content t('errors.invalid_authenticity_token') - end -end - shared_examples 'csrf error when acknowledging personal key' do |sp| it 'redirects to sign in page', email: true do visit_idp_from_sp_with_loa1(sp) @@ -134,3 +121,48 @@ end end end + +shared_examples 'creating an LOA3 account using webauthn for 2FA' do |sp| + it 'does not have webauthn as an option', email: true do + mock_challenge + visit_idp_from_sp_with_loa3(sp) + confirm_email_and_password('test@test.com') + expect(page).to_not have_css("label[for='two_factor_options_form_selection_webauthn']") + end + + # Remove the above test and enable this one when we enable webauthn for the SPs + xit 'does not prompt for recovery code before IdV flow', email: true do + mock_challenge + visit_idp_from_sp_with_loa3(sp) + confirm_email_and_password('test@test.com') + select_2fa_option('webauthn') + mock_press_button_on_hardware_key_and_fill_in_name_field + click_submit_default + fill_out_idv_jurisdiction_ok + click_idv_continue + fill_out_idv_form_ok + click_idv_continue + click_idv_continue + fill_out_phone_form_ok + click_idv_continue + choose_idv_otp_delivery_method_sms + click_submit_default + fill_in 'Password', with: Features::SessionHelper::VALID_PASSWORD + click_continue + click_acknowledge_personal_key + + if sp == :oidc + expect(page.response_headers['Content-Security-Policy']). + to(include('form-action \'self\' http://localhost:7654')) + end + + click_on t('forms.buttons.continue') + expect(current_url).to eq @saml_authn_request if sp == :saml + + if sp == :oidc + redirect_uri = URI(current_url) + + expect(redirect_uri.to_s).to start_with('http://localhost:7654/auth/result') + end + end +end diff --git a/spec/support/shared_examples/sign_in.rb b/spec/support/shared_examples/sign_in.rb index 564464611c5..9e165e77f8b 100644 --- a/spec/support/shared_examples/sign_in.rb +++ b/spec/support/shared_examples/sign_in.rb @@ -51,7 +51,7 @@ end shared_examples 'signing in as LOA3 with personal key' do |sp| - it 'redirects to the SP after acknowledging new personal key', :email, :idv_job do + it 'redirects to the SP after acknowledging new personal key', :email do user = create_loa3_account_go_back_to_sp_and_sign_out(sp) pii = { ssn: '666-66-1234', dob: '1920-01-01', first_name: 'alice' } @@ -105,7 +105,7 @@ end shared_examples 'signing in as LOA3 with personal key after resetting password' do |sp| - xit 'redirects to SP after reactivating account', :email, :idv_job do + xit 'redirects to SP after reactivating account', :email do user = create_loa3_account_go_back_to_sp_and_sign_out(sp) visit_idp_from_sp_with_loa3(sp) trigger_reset_password_and_click_email_link(user.email) @@ -175,7 +175,7 @@ stub_piv_cac_service user = create(:user, :signed_up, :with_piv_or_cac) - user.phone_configurations.clear + MfaContext.new(user).phone_configurations.clear visit_idp_from_sp_with_loa1(sp) click_link t('links.sign_in') fill_in_credentials_and_submit(user.email, user.password) diff --git a/spec/support/shared_examples_for_personal_keys.rb b/spec/support/shared_examples_for_personal_keys.rb index f9e76283884..bba05290f02 100644 --- a/spec/support/shared_examples_for_personal_keys.rb +++ b/spec/support/shared_examples_for_personal_keys.rb @@ -1,33 +1,7 @@ shared_examples_for 'personal key page' do include XPathHelper - context 'regenerating personal key with `Get another code` button' do - scenario 'displays a flash message and a new code' do - old_digest = @user.reload.encrypted_recovery_code_digest - - click_button t('users.personal_key.get_another') - - expect(@user.reload.encrypted_recovery_code_digest).to_not eq old_digest - expect(page).to have_content t('notices.send_code.personal_key') - end - end - context 'informational text' do - let(:accordion_control_selector) { generate_class_selector('accordion-header-controls') } - let(:content_selector) { generate_class_selector('accordion-content') } - - scenario 'it displays the personal key info header' do - expect(page).to have_content(t('users.personal_key.help_text_header')) - end - - context 'with javascript disabled' do - scenario 'content is visible by default' do - expect(page).to have_xpath("//#{accordion_control_selector}[@aria-expanded='true']") - expect(page).to have_xpath("//#{content_selector}") - expect(page).to have_content(t('users.personal_key.help_text')) - end - end - context 'modal content' do it 'displays the modal title' do expect(page).to have_content t('forms.personal_key.title') diff --git a/spec/support/shared_examples_for_phone_validation.rb b/spec/support/shared_examples_for_phone_validation.rb index 2d8e33503b8..580acae5492 100644 --- a/spec/support/shared_examples_for_phone_validation.rb +++ b/spec/support/shared_examples_for_phone_validation.rb @@ -16,10 +16,12 @@ second_user = build_stubbed(:user, :signed_up, with: { phone: '+1 (202) 555-1213' }) allow(User).to receive(:exists?).with(email: 'new@gmail.com').and_return(false) allow(User).to receive(:exists?).with( - phone_configuration: { phone: second_user.phone_configurations.first.phone } + phone_configuration: { + phone: MfaContext.new(second_user).phone_configurations.first.phone, + } ).and_return(true) - params[:phone] = second_user.phone_configurations.first.phone + params[:phone] = MfaContext.new(second_user).phone_configurations.first.phone result = subject.submit(params) expect(result).to be_kind_of(FormResponse) @@ -37,8 +39,8 @@ context 'when phone is same as current user' do it 'is valid' do - user.phone_configurations.first.phone = '+1 (703) 500-5000' - params[:phone] = user.phone_configurations.first.phone + MfaContext.new(user).phone_configurations.first.phone = '+1 (703) 500-5000' + params[:phone] = MfaContext.new(user).phone_configurations.first.phone result = subject.submit(params) expect(result).to be_kind_of(FormResponse) diff --git a/spec/support/sp_auth_helper.rb b/spec/support/sp_auth_helper.rb index 3036c9b524e..c9599c32230 100644 --- a/spec/support/sp_auth_helper.rb +++ b/spec/support/sp_auth_helper.rb @@ -27,7 +27,7 @@ def create_loa3_account_go_back_to_sp_and_sign_out(sp) fill_out_idv_form_ok click_idv_continue click_idv_continue - fill_out_phone_form_ok(user.phone_configurations.detect(&:mfa_enabled?).phone) + fill_out_phone_form_ok(MfaContext.new(user).phone_configurations.detect(&:mfa_enabled?).phone) click_idv_continue fill_in :user_password, with: user.password click_continue diff --git a/spec/view_models/account_show_spec.rb b/spec/view_models/account_show_spec.rb index cca373d355a..7ec51fd00d4 100644 --- a/spec/view_models/account_show_spec.rb +++ b/spec/view_models/account_show_spec.rb @@ -28,7 +28,7 @@ it 'returns the personal_key partial' do user = User.new profile_index = AccountShow.new( - decrypted_pii: {}, personal_key: 'foo', decorated_user: user + decrypted_pii: {}, personal_key: 'foo', decorated_user: user.decorate ) expect(profile_index.personal_key_partial).to eq 'accounts/personal_key' @@ -39,7 +39,7 @@ it 'returns the shared/null partial' do user = User.new profile_index = AccountShow.new( - decrypted_pii: {}, personal_key: '', decorated_user: user + decrypted_pii: {}, personal_key: '', decorated_user: user.decorate ) expect(profile_index.personal_key_partial).to eq 'shared/null' @@ -50,7 +50,7 @@ describe '#password_reset_partial' do context 'user has a password_reset_profile' do it 'returns the accounts/password_reset partial' do - user = User.new + user = User.new.decorate allow(user).to receive(:password_reset_profile).and_return('profile') profile_index = AccountShow.new( decrypted_pii: {}, personal_key: 'foo', decorated_user: user @@ -65,7 +65,7 @@ user = User.new allow(user).to receive(:password_reset_profile).and_return(nil) profile_index = AccountShow.new( - decrypted_pii: {}, personal_key: '', decorated_user: user + decrypted_pii: {}, personal_key: '', decorated_user: user.decorate ) expect(profile_index.password_reset_partial).to eq 'shared/null' @@ -76,7 +76,7 @@ describe '#pending_profile_partial' do context 'user needs profile usps verification' do it 'returns the accounts/pending_profile_usps partial' do - user = User.new + user = User.new.decorate allow(user).to receive(:pending_profile_requires_verification?).and_return(true) profile_index = AccountShow.new( decrypted_pii: {}, personal_key: 'foo', decorated_user: user @@ -88,7 +88,7 @@ context 'user does not need profile verification' do it 'returns the shared/null partial' do - user = User.new + user = User.new.decorate allow(user).to receive(:pending_profile_requires_verification?).and_return(false) profile_index = AccountShow.new(decrypted_pii: {}, personal_key: '', decorated_user: user) @@ -100,7 +100,7 @@ describe '#pii_partial' do context 'AccountShow instance has decrypted_pii' do it 'returns the accounts/password_reset partial' do - user = User.new + user = User.new.decorate profile_index = AccountShow.new( decrypted_pii: { foo: 'bar' }, personal_key: '', decorated_user: user ) @@ -111,7 +111,7 @@ context 'AccountShow instance does not have decrypted_pii' do it 'returns the shared/null partial' do - user = User.new + user = User.new.decorate profile_index = AccountShow.new(decrypted_pii: {}, personal_key: '', decorated_user: user) expect(profile_index.pii_partial).to eq 'shared/null' @@ -123,8 +123,13 @@ context 'user has enabled an authenticator app' do it 'returns the disable_totp partial' do user = User.new - allow(user).to receive(:totp_enabled?).and_return(true) - profile_index = AccountShow.new(decrypted_pii: {}, personal_key: '', decorated_user: user) + allow_any_instance_of( + TwoFactorAuthentication::AuthAppPolicy + ).to receive(:enabled?).and_return(true) + + profile_index = AccountShow.new( + decrypted_pii: {}, personal_key: '', decorated_user: user.decorate + ) expect(profile_index.totp_partial).to eq 'accounts/actions/disable_totp' end @@ -133,8 +138,13 @@ context 'user does not have an authenticator app enabled' do it 'returns the enable_totp partial' do user = User.new - allow(user).to receive(:totp_enabled?).and_return(false) - profile_index = AccountShow.new(decrypted_pii: {}, personal_key: '', decorated_user: user) + allow_any_instance_of( + TwoFactorAuthentication::AuthAppPolicy + ).to receive(:enabled?).and_return(false) + + profile_index = AccountShow.new( + decrypted_pii: {}, personal_key: '', decorated_user: user.decorate + ) expect(profile_index.totp_partial).to eq 'accounts/actions/enable_totp' end @@ -148,7 +158,7 @@ first_name = 'John' decrypted_pii = Pii::Attributes.new_from_json({ first_name: first_name }.to_json) profile_index = AccountShow.new( - decrypted_pii: decrypted_pii, personal_key: '', decorated_user: user + decrypted_pii: decrypted_pii, personal_key: '', decorated_user: user.decorate ) expect(profile_index.header_personalization).to eq first_name @@ -158,7 +168,7 @@ context 'AccountShow instance does not have decrypted_pii' do it "returns the user's email" do email = 'john@smith.com' - user = User.new(email: email) + user = User.new(email: email).decorate profile_index = AccountShow.new(decrypted_pii: {}, personal_key: '', decorated_user: user) expect(profile_index.header_personalization).to eq email @@ -170,8 +180,13 @@ context 'user has enabled an authenticator app' do it 'returns localization for auth_app_enabled' do user = User.new - allow(user).to receive(:totp_enabled?).and_return(true) - profile_index = AccountShow.new(decrypted_pii: {}, personal_key: '', decorated_user: user) + allow_any_instance_of( + TwoFactorAuthentication::AuthAppPolicy + ).to receive(:enabled?).and_return(true) + + profile_index = AccountShow.new( + decrypted_pii: {}, personal_key: '', decorated_user: user.decorate + ) expect(profile_index.totp_content).to eq t('account.index.auth_app_enabled') end @@ -179,8 +194,10 @@ context 'user does not have an authenticator app enabled' do it 'returns localization for auth_app_disabled' do - user = User.new - allow(user).to receive(:totp_enabled?).and_return(false) + user = User.new.decorate + allow_any_instance_of( + TwoFactorAuthentication::AuthAppPolicy + ).to receive(:enabled?).and_return(false) profile_index = AccountShow.new(decrypted_pii: {}, personal_key: '', decorated_user: user) expect(profile_index.totp_content).to eq t('account.index.auth_app_disabled') diff --git a/spec/views/account_reset/cancel/show.html.slim_spec.rb b/spec/views/account_reset/cancel/show.html.slim_spec.rb new file mode 100644 index 00000000000..d970e889e6d --- /dev/null +++ b/spec/views/account_reset/cancel/show.html.slim_spec.rb @@ -0,0 +1,14 @@ +require 'rails_helper' + +describe 'account_reset/cancel/show.html.slim' do + it 'has a localized title' do + expect(view).to receive(:title).with(t('account_reset.cancel_request.title')) + + render + end + + it 'has button to cancel request' do + render + expect(rendered).to have_button t('account_reset.cancel_request.cancel_button') + end +end diff --git a/spec/views/accounts/show.html.slim_spec.rb b/spec/views/accounts/show.html.slim_spec.rb index e3f94da5dfe..a1d3bc3629a 100644 --- a/spec/views/accounts/show.html.slim_spec.rb +++ b/spec/views/accounts/show.html.slim_spec.rb @@ -147,4 +147,47 @@ expect(view).to render_template(partial: '_delete_account_item_heading') end + + context 'phone listing and adding' do + it 'renders the phone section' do + render + + expect(view).to render_template(partial: '_phone') + end + + context 'user has no phone' do + let(:user) do + record = build_stubbed(:user, :signed_up, :with_piv_or_cac) + record.phone_configurations = [] + record + end + + it 'shows the add phone link' do + render + + expect(rendered).to have_link( + t('account.index.phone_add'), href: manage_phone_path + ) + end + end + + context 'user has a phone' do + it 'shows no add phone link' do + render + + expect(rendered).to_not have_content t('account.index.phone_add') + expect(rendered).to_not have_link( + t('account.index.phone_add'), href: manage_phone_path + ) + end + + it 'shows an edit link' do + render + + expect(rendered).to have_link( + t('account.index.phone'), href: manage_phone_url + ) + end + end + end end diff --git a/spec/views/idv/review/new.html.slim_spec.rb b/spec/views/idv/review/new.html.slim_spec.rb index 52fa0b5e440..25b84e5b230 100644 --- a/spec/views/idv/review/new.html.slim_spec.rb +++ b/spec/views/idv/review/new.html.slim_spec.rb @@ -7,7 +7,7 @@ before do user = build_stubbed(:user, :signed_up) allow(view).to receive(:current_user).and_return(user) - @idv_params = { + @applicant = { first_name: 'Some', last_name: 'One', ssn: '666-66-1234', diff --git a/spec/views/phone_setup/index.html.slim_spec.rb b/spec/views/phone_setup/index.html.slim_spec.rb index 5f50343aac4..428203ec39f 100644 --- a/spec/views/phone_setup/index.html.slim_spec.rb +++ b/spec/views/phone_setup/index.html.slim_spec.rb @@ -6,7 +6,7 @@ allow(view).to receive(:current_user).and_return(user) - @user_phone_form = UserPhoneForm.new(user) + @user_phone_form = UserPhoneForm.new(user, nil) @presenter = PhoneSetupPresenter.new('voice') render end diff --git a/spec/views/sign_up/personal_keys/show.html.slim_spec.rb b/spec/views/sign_up/personal_keys/show.html.slim_spec.rb index 0e000395d6e..5f0e88c9e1d 100644 --- a/spec/views/sign_up/personal_keys/show.html.slim_spec.rb +++ b/spec/views/sign_up/personal_keys/show.html.slim_spec.rb @@ -40,8 +40,8 @@ it 'informs the user of importance of keeping the personal key in a safe place' do render expect(rendered).to have_content( - t('instructions.personal_key_html', - accent: t('instructions.personal_key_accent')) + t('instructions.personal_key.info_html', + accent: t('instructions.personal_key.accent')) ) end @@ -61,7 +61,6 @@ it 'displays a button to get a new personal key' do render - expect(rendered).to have_xpath("//input[@value='#{t('users.personal_key.get_another')}']") expect(rendered).to have_xpath("//form[@action='#{sign_up_personal_key_path}']") end end diff --git a/spec/views/two_factor_authentication/otp_verification/show.html.slim_spec.rb b/spec/views/two_factor_authentication/otp_verification/show.html.slim_spec.rb index 2f39b20e64e..3decf89da77 100644 --- a/spec/views/two_factor_authentication/otp_verification/show.html.slim_spec.rb +++ b/spec/views/two_factor_authentication/otp_verification/show.html.slim_spec.rb @@ -35,7 +35,7 @@ it 'has a localized heading' do render - expect(rendered).to have_content t('devise.two_factor_authentication.header_text') + expect(rendered).to have_content t('two_factor_authentication.header_text') end end @@ -138,7 +138,7 @@ render expect(rendered).not_to have_link( - t('devise.two_factor_authentication.personal_key_fallback.link'), + t('two_factor_authentication.personal_key_fallback.link'), href: login_two_factor_personal_key_path ) end diff --git a/spec/views/two_factor_authentication/personal_key_verification/show.html.slim_spec.rb b/spec/views/two_factor_authentication/personal_key_verification/show.html.slim_spec.rb index c616b68c0a8..57de794f908 100644 --- a/spec/views/two_factor_authentication/personal_key_verification/show.html.slim_spec.rb +++ b/spec/views/two_factor_authentication/personal_key_verification/show.html.slim_spec.rb @@ -21,14 +21,14 @@ render expect(rendered). - to have_content t('devise.two_factor_authentication.personal_key_header_text') + to have_content t('two_factor_authentication.personal_key_header_text') end it 'prompts the user to enter their personal key' do render expect(rendered). - to have_content t('devise.two_factor_authentication.personal_key_prompt') + to have_content t('two_factor_authentication.personal_key_prompt') end it 'contains a form to submit the personal key' do diff --git a/spec/views/two_factor_authentication/totp_verification/show.html.slim_spec.rb b/spec/views/two_factor_authentication/totp_verification/show.html.slim_spec.rb index 18312877504..735e3ee73c7 100644 --- a/spec/views/two_factor_authentication/totp_verification/show.html.slim_spec.rb +++ b/spec/views/two_factor_authentication/totp_verification/show.html.slim_spec.rb @@ -6,7 +6,7 @@ attributes_for(:generic_otp_presenter).merge( two_factor_authentication_method: 'authenticator', user_email: view.current_user.email, - phone_enabled: user.phone_configurations.any?(&:mfa_enabled?) + phone_enabled: TwoFactorAuthentication::PhonePolicy.new(user).enabled? ) end @@ -23,7 +23,7 @@ it_behaves_like 'an otp form' it 'shows the correct header' do - expect(rendered).to have_content t('devise.two_factor_authentication.totp_header_text') + expect(rendered).to have_content t('two_factor_authentication.totp_header_text') end it 'shows the correct help text' do diff --git a/spec/views/users/phones/edit.html.slim_spec.rb b/spec/views/users/phones/edit.html.slim_spec.rb index a18ee38b7ab..c8cb2ff5867 100644 --- a/spec/views/users/phones/edit.html.slim_spec.rb +++ b/spec/views/users/phones/edit.html.slim_spec.rb @@ -5,7 +5,7 @@ before do user = build_stubbed(:user, :signed_up) allow(view).to receive(:current_user).and_return(user) - @user_phone_form = UserPhoneForm.new(user) + @user_phone_form = UserPhoneForm.new(user, MfaContext.new(user).phone_configurations.first) @presenter = PhoneSetupPresenter.new('voice') end