diff --git a/.reek b/.reek index 165c5e1c031..1c72440ceb5 100644 --- a/.reek +++ b/.reek @@ -3,7 +3,6 @@ Attribute: ControlParameter: exclude: - CustomDeviseFailureApp#i18n_message - - OpenidConnectRedirector#initialize - NoRetryJobs#call - PhoneFormatter#self.format - Users::TwoFactorAuthenticationController#invalid_phone_number @@ -33,7 +32,6 @@ FeatureEnvy: - reauthn? - mark_profile_inactive - EncryptedSidekiqRedis#zrem - - UserDecorator#should_acknowledge_personal_key? - Pii::Attributes#[]= - OpenidConnectLogoutForm#load_identity - Idv::ProfileMaker#pii_from_applicant @@ -46,7 +44,6 @@ FeatureEnvy: - Utf8Sanitizer#event_attributes - Utf8Sanitizer#remote_ip - TwoFactorAuthenticationController#capture_analytics_for_exception - - Users::SessionsController#configure_permitted_parameters - UspsConfirmationExporter#make_entry_row InstanceVariableAssumption: exclude: @@ -58,7 +55,6 @@ ManualDispatch: exclude: - EncryptedSidekiqRedis#respond_to_missing? - CloudhsmKeyGenerator#initialize_settings - - Users::SessionsController#configure_permitted_parameters NestedIterators: exclude: - UserFlowExporter#self.massage_html @@ -87,10 +83,10 @@ TooManyConstants: TooManyInstanceVariables: exclude: - OpenidConnectAuthorizeForm - - OpenidConnectRedirector - Idv::VendorResult - CloudhsmKeyGenerator - CloudhsmKeySharer + - WebauthnSetupForm TooManyStatements: max_statements: 6 exclude: diff --git a/.reek.yml b/.reek.yml new file mode 100644 index 00000000000..0af0017f933 --- /dev/null +++ b/.reek.yml @@ -0,0 +1,220 @@ +detectors: + Attribute: + enabled: false + ControlParameter: + exclude: + - CustomDeviseFailureApp#i18n_message + - OpenidConnectRedirector#initialize + - 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 + - UserDecorator#should_acknowledge_personal_key? + - 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 + - OpenidConnectRedirector + - 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 +directories: + '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/Gemfile b/Gemfile index acf84c96d1b..d70e7d25a9c 100644 --- a/Gemfile +++ b/Gemfile @@ -60,6 +60,7 @@ gem 'two_factor_authentication' gem 'typhoeus' gem 'uglifier', '~> 3.2' gem 'valid_email' +gem 'webauthn' gem 'webpacker', '~> 3.4' gem 'xml-simple' gem 'xmlenc', '~> 0.6' diff --git a/Gemfile.lock b/Gemfile.lock index 3a7ee7e011b..a809613d807 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -125,21 +125,21 @@ GEM arel (8.0.0) ast (2.4.0) aws-eventstream (1.0.1) - aws-partitions (1.97.0) - aws-sdk-core (3.24.0) + aws-partitions (1.103.0) + aws-sdk-core (3.27.0) aws-eventstream (~> 1.0) aws-partitions (~> 1.0) aws-sigv4 (~> 1.0) jmespath (~> 1.0) - aws-sdk-kms (1.7.0) - aws-sdk-core (~> 3) + aws-sdk-kms (1.9.0) + aws-sdk-core (~> 3, >= 3.26.0) aws-sigv4 (~> 1.0) aws-sdk-s3 (1.17.0) aws-sdk-core (~> 3, >= 3.21.2) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.0) - aws-sdk-ses (1.8.0) - aws-sdk-core (~> 3) + aws-sdk-ses (1.10.0) + aws-sdk-core (~> 3, >= 3.26.0) aws-sigv4 (~> 1.0) aws-sigv4 (1.0.3) axe-matchers (1.3.4) @@ -152,7 +152,7 @@ GEM base32-crockford (0.1.0) bcrypt (3.1.12) benchmark-ips (2.7.2) - better_errors (2.4.0) + better_errors (2.5.0) coderay (>= 1.0.0) erubi (>= 1.0.0) rack (>= 0.9.0) @@ -162,7 +162,7 @@ GEM brakeman (4.3.1) browser (2.5.3) builder (3.2.3) - bullet (5.7.5) + bullet (5.7.6) activesupport (>= 3.0.0) uniform_notifier (~> 1.11.0) bummr (0.3.2) @@ -179,6 +179,7 @@ GEM capybara-selenium (0.0.6) capybara selenium-webdriver + cbor (0.5.9.3) childprocess (0.9.0) ffi (~> 1.0, >= 1.0.11) choice (0.2.0) @@ -204,7 +205,6 @@ GEM daemons (1.2.4) database_cleaner (1.7.0) debug_inspector (0.0.3) - deepl-rb (2.1.0) derailed (0.1.0) derailed_benchmarks derailed_benchmarks (1.3.4) @@ -218,7 +218,7 @@ GEM descendants_tracker (0.0.4) thread_safe (~> 0.3, >= 0.3.1) device_detector (1.0.1) - devise (4.4.3) + devise (4.5.0) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 4.1.0, < 6.0) @@ -231,9 +231,6 @@ GEM actionpack (>= 4) i18n dumb_delegator (0.8.0) - easy_translate (0.5.1) - thread - thread_safe email_spec (2.2.0) htmlentities (~> 4.3.3) launchy (~> 2.1) @@ -249,10 +246,10 @@ GEM actionmailer (>= 4.0, < 6) activesupport (>= 4.0, < 6) execjs (2.7.0) - factory_bot (4.10.0) + factory_bot (4.11.0) activesupport (>= 3.0.0) - factory_bot_rails (4.10.0) - factory_bot (~> 4.10.0) + factory_bot_rails (4.11.0) + factory_bot (~> 4.11.0) railties (>= 3.0.0) fakefs (0.18.0) faker (1.9.1) @@ -294,7 +291,7 @@ GEM gyoku (1.3.1) builder (>= 2.1.2) hashdiff (0.3.7) - hashie (3.5.7) + hashie (3.6.0) heapy (0.1.3) highline (2.0.0) hiredis (0.6.1) @@ -307,13 +304,11 @@ GEM socksify i18n (1.1.0) concurrent-ruby (~> 1.0) - i18n-tasks (0.9.23) + i18n-tasks (0.9.24) activesupport (>= 4.0.2) ast (>= 2.1.0) - deepl-rb (>= 2.1.0) - easy_translate (>= 0.5.1) erubi - highline (>= 1.7.3) + highline (>= 2.0.0) i18n parser (>= 2.2.3.0) rainbow (>= 2.2.2, < 4.0) @@ -331,6 +326,7 @@ GEM jwt (2.1.0) knapsack (1.16.0) rake + kwalify (0.7.2) launchy (2.4.3) addressable (~> 2.3) listen (3.1.5) @@ -371,13 +367,13 @@ GEM nenv (~> 0.1) shellany (~> 0.0) orm_adapter (0.5.0) - overcommit (0.45.0) + overcommit (0.46.0) childprocess (~> 0.6, >= 0.6.3) iniparse (~> 1.4) parallel (1.12.1) parser (2.5.1.2) ast (~> 2.4.0) - pg (1.0.0) + pg (1.1.3) phonelib (0.6.24) pkcs11 (0.2.7) powerpack (0.1.2) @@ -454,12 +450,13 @@ GEM readthis (2.2.0) connection_pool (~> 2.1) redis (>= 3.0, < 5.0) - recaptcha (4.11.1) + recaptcha (4.12.0) json redis (3.3.5) - reek (4.8.1) + reek (5.0.2) codeclimate-engine-rb (~> 0.4.0) - parser (>= 2.5.0.0, < 2.6) + kwalify (~> 0.7.0) + parser (>= 2.5.0.0, < 2.6, != 2.5.1.1) rainbow (>= 2.0, < 4.0) referer-parser (0.3.0) request_store (1.4.1) @@ -470,27 +467,27 @@ GEM rotp (3.3.1) rqrcode (0.10.1) chunky_png (~> 1.0) - rspec (3.7.0) - rspec-core (~> 3.7.0) - rspec-expectations (~> 3.7.0) - rspec-mocks (~> 3.7.0) - rspec-core (3.7.1) - rspec-support (~> 3.7.0) - rspec-expectations (3.7.0) + rspec (3.8.0) + rspec-core (~> 3.8.0) + rspec-expectations (~> 3.8.0) + rspec-mocks (~> 3.8.0) + rspec-core (3.8.0) + rspec-support (~> 3.8.0) + rspec-expectations (3.8.1) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.7.0) - rspec-mocks (3.7.0) + rspec-support (~> 3.8.0) + rspec-mocks (3.8.0) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.7.0) - rspec-rails (3.7.2) + rspec-support (~> 3.8.0) + rspec-rails (3.8.0) actionpack (>= 3.0) activesupport (>= 3.0) railties (>= 3.0) - rspec-core (~> 3.7.0) - rspec-expectations (~> 3.7.0) - rspec-mocks (~> 3.7.0) - rspec-support (~> 3.7.0) - rspec-support (3.7.1) + rspec-core (~> 3.8.0) + rspec-expectations (~> 3.8.0) + rspec-mocks (~> 3.8.0) + rspec-support (~> 3.8.0) + rspec-support (3.8.0) rubocop (0.58.2) jaro_winkler (~> 1.5.1) parallel (~> 1.10) @@ -501,12 +498,12 @@ GEM unicode-display_width (~> 1.0, >= 1.0.1) ruby-graphviz (1.2.3) ruby-progressbar (1.10.0) - ruby-saml (1.8.0) + ruby-saml (1.9.0) nokogiri (>= 1.5.10) ruby_dep (1.5.0) ruby_parser (3.11.0) sexp_processor (~> 4.9) - rubyzip (1.2.1) + rubyzip (1.2.2) safe_yaml (1.0.4) safely_block (0.2.1) errbase @@ -532,7 +529,7 @@ GEM scrypt (3.0.5) ffi-compiler (>= 1.0, < 2.0) secure_headers (6.0.0) - selenium-webdriver (3.11.0) + selenium-webdriver (3.14.0) childprocess (~> 0.5) rubyzip (~> 1.2) sexp_processor (4.11.0) @@ -563,10 +560,10 @@ GEM actionpack (>= 3.1) railties (>= 3.1) slim (~> 3.0) - slim_lint (0.15.1) + slim_lint (0.16.0) rake (>= 10, < 13) rubocop (>= 0.50.0) - slim (~> 3.0) + slim (>= 3.0, < 5.0) sysexits (~> 1.1) socksify (1.7.1) sprockets (3.7.2) @@ -589,11 +586,10 @@ GEM eventmachine (~> 1.0, >= 1.0.4) rack (>= 1, < 3) thor (0.20.0) - thread (0.2.2) thread_safe (0.3.6) tilt (2.0.8) timecop (0.9.1) - twilio-ruby (5.12.1) + twilio-ruby (5.12.4) faraday (~> 0.9) jwt (>= 1.5, <= 2.5) nokogiri (>= 1.6, < 2.0) @@ -627,6 +623,8 @@ GEM wasabi (3.5.0) httpi (~> 2.0) nokogiri (>= 1.4.2) + webauthn (0.2.0) + cbor (~> 0.5.9.2) webmock (3.4.2) addressable (>= 2.3.6) crack (>= 0.3.2) @@ -751,6 +749,7 @@ DEPENDENCIES typhoeus uglifier (~> 3.2) valid_email + webauthn webmock webpacker (~> 3.4) xml-simple @@ -762,4 +761,4 @@ RUBY VERSION ruby 2.5.1p57 BUNDLED WITH - 1.16.3 + 1.16.4 diff --git a/app/assets/images/sp-logos/hud.png b/app/assets/images/sp-logos/hud.png new file mode 100644 index 00000000000..c0d45fd3d27 Binary files /dev/null and b/app/assets/images/sp-logos/hud.png differ diff --git a/app/assets/stylesheets/components/_btn.scss b/app/assets/stylesheets/components/_btn.scss index c0599bdfdc3..e84a3215c71 100644 --- a/app/assets/stylesheets/components/_btn.scss +++ b/app/assets/stylesheets/components/_btn.scss @@ -65,11 +65,9 @@ display: inline-block; padding: $space-1 $space-2; - // &.is-focused { - // border-color: $field-focus-color; - // box-shadow: 0 0 0 2px rgba($field-focus-color, .5); - // outline: none; - // } + &.is-focused { + border-color: $field-focus-color; + } } .btn-disabled { @@ -77,3 +75,19 @@ border-color: $gray; color: $gray; } + +.btn-account-action { + border: 0; + color: $blue; + font-size: .8125rem; + font-weight: normal; + margin-bottom: -3px; + margin-top: -3px; + padding: .5rem; + padding-bottom: .125rem; + padding-top: .125rem; + + a { + text-decoration: none; + } +} diff --git a/app/controllers/account_reset/delete_account_controller.rb b/app/controllers/account_reset/delete_account_controller.rb index 88614da8654..1719fd22d72 100644 --- a/app/controllers/account_reset/delete_account_controller.rb +++ b/app/controllers/account_reset/delete_account_controller.rb @@ -1,18 +1,30 @@ module AccountReset class DeleteAccountController < ApplicationController before_action :check_feature_enabled - before_action :prevent_parameter_leak, only: :show - before_action :check_granted_token - def show; end + def show + render :show and return unless token + + result = AccountReset::ValidateGrantedToken.new(token).call + analytics.track_event(Analytics::ACCOUNT_RESET, result.to_h) + + if result.success? + handle_valid_token + else + handle_invalid_token(result) + end + end def delete - user = @account_reset_request.user - analytics.track_event(Analytics::ACCOUNT_RESET, - event: :delete, token_valid: true, user_id: user.uuid) - email = reset_session_and_set_email(user) - UserMailer.account_reset_complete(email).deliver_later - redirect_to account_reset_confirm_delete_account_url + granted_token = session.delete(:granted_token) + result = AccountReset::DeleteAccount.new(granted_token).call + analytics.track_event(Analytics::ACCOUNT_RESET, result.to_h.except(:email)) + + if result.success? + handle_successful_deletion(result) + else + handle_invalid_token(result) + end end private @@ -21,48 +33,24 @@ def check_feature_enabled redirect_to root_url unless FeatureManagement.account_reset_enabled? end - def reset_session_and_set_email(user) - email = user.email - user.destroy! - sign_out - flash[:email] = email + def token + params[:token] end - def check_granted_token - @account_reset_request = AccountResetRequest.from_valid_granted_token(session[:granted_token]) - return if @account_reset_request - analytics.track_event(Analytics::ACCOUNT_RESET, event: :delete, token_valid: false) - redirect_to root_url + def handle_valid_token + session[:granted_token] = token + redirect_to url_for end - def prevent_parameter_leak - token = params[:token] - return if token.blank? - remove_token_from_url(token) - end - - def remove_token_from_url(token) - ar = AccountResetRequest.find_by(granted_token: token) - if ar&.granted_token_valid? - session[:granted_token] = token - redirect_to url_for - return - end - handle_expired_token(ar) if ar&.granted_token_expired? + def handle_invalid_token(result) + flash[:error] = result.errors[:token].first redirect_to root_url end - def handle_expired_token(ar) - analytics.track_event(Analytics::ACCOUNT_RESET, - event: :delete, - token_valid: true, - expired: true, - user_id: ar&.user&.uuid) - flash[:error] = link_expired - end - - def link_expired - t('devise.two_factor_authentication.account_reset.link_expired') + def handle_successful_deletion(result) + sign_out + flash[:email] = result.extra[:email] + redirect_to account_reset_confirm_delete_account_url end end end diff --git a/app/controllers/concerns/account_recoverable.rb b/app/controllers/concerns/account_recoverable.rb index 492401c6c75..aabeb95017e 100644 --- a/app/controllers/concerns/account_recoverable.rb +++ b/app/controllers/concerns/account_recoverable.rb @@ -1,5 +1,5 @@ module AccountRecoverable def piv_cac_enabled_but_not_phone_enabled? - current_user.piv_cac_enabled? && !current_user.phone_configuration&.mfa_enabled? + current_user.piv_cac_enabled? && current_user.phone_configurations.none?(&:mfa_enabled?) end end diff --git a/app/controllers/concerns/authorizable.rb b/app/controllers/concerns/authorizable.rb index ced31305fc1..8a1cffe732b 100644 --- a/app/controllers/concerns/authorizable.rb +++ b/app/controllers/concerns/authorizable.rb @@ -1,6 +1,6 @@ module Authorizable def authorize_user - return unless current_user.phone_configuration&.mfa_enabled? + return unless current_user.phone_configurations.any?(&:mfa_enabled?) if user_fully_authenticated? redirect_to account_url diff --git a/app/controllers/concerns/idv/phone_otp_rate_limitable.rb b/app/controllers/concerns/idv/phone_otp_rate_limitable.rb new file mode 100644 index 00000000000..f20e7b05151 --- /dev/null +++ b/app/controllers/concerns/idv/phone_otp_rate_limitable.rb @@ -0,0 +1,53 @@ +module Idv + module PhoneOtpRateLimitable + extend ActiveSupport::Concern + + included do + before_action :confirm_two_factor_authenticated + before_action :handle_locked_out_user + end + + def handle_locked_out_user + reset_attempt_count_if_user_no_longer_locked_out + return unless decorated_user.locked_out? + analytics.track_event(Analytics::IDV_PHONE_CONFIRMATION_OTP_RATE_LIMIT_LOCKED_OUT) + handle_too_many_otp_attempts + false + end + + def reset_attempt_count_if_user_no_longer_locked_out + return unless decorated_user.no_longer_locked_out? + + UpdateUser.new( + user: current_user, + attributes: { + second_factor_attempts_count: 0, + second_factor_locked_at: nil, + } + ).call + end + + def handle_too_many_otp_sends + analytics.track_event(Analytics::IDV_PHONE_CONFIRMATION_OTP_RATE_LIMIT_SENDS) + handle_max_attempts('otp_requests') + end + + def handle_too_many_otp_attempts + analytics.track_event(Analytics::IDV_PHONE_CONFIRMATION_OTP_RATE_LIMIT_ATTEMPTS) + handle_max_attempts('otp_login_attempts') + end + + def handle_max_attempts(type) + presenter = TwoFactorAuthCode::MaxAttemptsReachedPresenter.new( + type, + decorated_user + ) + sign_out + render_full_width('shared/_failure', locals: { presenter: presenter }) + end + + def decorated_user + current_user.decorate + end + end +end diff --git a/app/controllers/concerns/idv/phone_otp_sendable.rb b/app/controllers/concerns/idv/phone_otp_sendable.rb new file mode 100644 index 00000000000..f19473ff63d --- /dev/null +++ b/app/controllers/concerns/idv/phone_otp_sendable.rb @@ -0,0 +1,57 @@ +module Idv + module PhoneOtpSendable + extend ActiveSupport::Concern + + included do + before_action :confirm_two_factor_authenticated + before_action :handle_locked_out_user + end + + def send_phone_confirmation_otp + send_phone_confirmation_otp_service.call + end + + def send_phone_confirmation_otp_rate_limited? + send_phone_confirmation_otp_service.user_locked_out? + end + + def invalid_phone_number(exception) + capture_analytics_for_twilio_exception(exception) + twilio_errors = TwilioErrors::REST_ERRORS.merge(TwilioErrors::VERIFY_ERRORS) + flash[:error] = twilio_errors.fetch(exception.code, t('errors.messages.otp_failed')) + redirect_to idv_phone_url + end + + private + + def send_phone_confirmation_otp_service + @send_phone_confirmation_otp_service ||= Idv::SendPhoneConfirmationOtp.new( + user: current_user, + idv_session: idv_session, + locale: user_locale + ) + end + + def user_locale + available_locales = PhoneVerification::AVAILABLE_LOCALES + http_accept_language.language_region_compatible_from(available_locales) + end + + # rubocop:disable Metrics/MethodLength + # :reek:FeatureEnvy + def capture_analytics_for_twilio_exception(exception) + attributes = { + error: exception.message, + code: exception.code, + context: 'idv', + country: Phonelib.parse(send_phone_confirmation_otp_service.phone).country, + } + if exception.is_a?(PhoneVerification::VerifyError) + attributes[:status] = exception.status + attributes[:response] = exception.response + end + analytics.track_event(Analytics::TWILIO_PHONE_VALIDATION_FAILED, attributes) + end + # rubocop:enable Metrics/MethodLength + end +end diff --git a/app/controllers/concerns/phone_confirmation.rb b/app/controllers/concerns/phone_confirmation.rb index 3327749e5bb..907dd65e29e 100644 --- a/app/controllers/concerns/phone_confirmation.rb +++ b/app/controllers/concerns/phone_confirmation.rb @@ -1,7 +1,7 @@ module PhoneConfirmation - def prompt_to_confirm_phone(phone:, context: 'confirmation', selected_delivery_method: nil) + def prompt_to_confirm_phone(phone:, selected_delivery_method: nil) user_session[:unconfirmed_phone] = phone - user_session[:context] = context + user_session[:context] = 'confirmation' redirect_to otp_send_url( otp_delivery_selection_form: { @@ -15,6 +15,7 @@ def prompt_to_confirm_phone(phone:, context: 'confirmation', selected_delivery_m 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_configuration&.delivery_preference || current_user.otp_delivery_preference + current_user.phone_configurations.first&.delivery_preference || + current_user.otp_delivery_preference end end diff --git a/app/controllers/concerns/remember_device_concern.rb b/app/controllers/concerns/remember_device_concern.rb index 3bc01e76930..59542d5d7c1 100644 --- a/app/controllers/concerns/remember_device_concern.rb +++ b/app/controllers/concerns/remember_device_concern.rb @@ -2,7 +2,6 @@ module RememberDeviceConcern extend ActiveSupport::Concern def save_remember_device_preference - return if idv_context? return unless params[:remember_device] == 'true' cookies.encrypted[:remember_device] = { value: RememberDeviceCookie.new(user_id: current_user.id, created_at: Time.zone.now).to_json, diff --git a/app/controllers/concerns/secure_headers_concern.rb b/app/controllers/concerns/secure_headers_concern.rb index 74a11e2078b..23da64ea5c3 100644 --- a/app/controllers/concerns/secure_headers_concern.rb +++ b/app/controllers/concerns/secure_headers_concern.rb @@ -5,17 +5,20 @@ def apply_secure_headers_override return if stored_url_for_user.blank? authorize_params = URIService.params(stored_url_for_user) - authorize_form = OpenidConnectAuthorizeForm.new(authorize_params) return unless authorize_form.valid? + redirect_uri = authorize_params[:redirect_uri] + override_content_security_policy_directives( - form_action: ["'self'", authorize_form.sp_redirect_uri].compact, + form_action: ["'self'", redirect_uri].compact, preserve_schemes: true ) end + private + def stored_url_for_user sp_session[:request_url] end diff --git a/app/controllers/concerns/two_factor_authenticatable.rb b/app/controllers/concerns/two_factor_authenticatable.rb index 0ec2683f91a..82a1cd0994f 100644 --- a/app/controllers/concerns/two_factor_authenticatable.rb +++ b/app/controllers/concerns/two_factor_authenticatable.rb @@ -72,7 +72,7 @@ def reset_attempt_count_if_user_no_longer_locked_out def handle_valid_otp if authentication_context? handle_valid_otp_for_authentication_context - elsif idv_or_confirmation_context? || profile_context? + elsif confirmation_context? handle_valid_otp_for_confirmation_context end save_remember_device_preference @@ -128,7 +128,7 @@ def handle_valid_otp_for_authentication_context end def assign_phone - @updating_existing_number = old_phone.present? && !profile_context? + @updating_existing_number = old_phone.present? if @updating_existing_number && confirmation_context? phone_changed @@ -140,7 +140,7 @@ def assign_phone end def old_phone - current_user.phone_configuration&.phone + current_user.phone_configurations.first&.phone end def phone_changed @@ -153,32 +153,10 @@ def phone_confirmed end def update_phone_attributes - if idv_or_profile_context? - update_idv_state - else - UpdateUser.new( - user: current_user, - attributes: { phone: user_session[:unconfirmed_phone], phone_confirmed_at: Time.zone.now } - ).call - end - end - - def update_idv_state - if idv_context? - confirm_idv_session_phone - elsif profile_context? - Idv::ProfileActivator.new(user: current_user).call - end - end - - def confirm_idv_session_phone - idv_session = Idv::Session.new( - user_session: user_session, - current_user: current_user, - issuer: sp_session[:issuer] - ) - idv_session.user_phone_confirmation = true - idv_session.params['phone_confirmed_at'] = Time.zone.now + UpdateUser.new( + user: current_user, + attributes: { phone: user_session[:unconfirmed_phone], phone_confirmed_at: Time.zone.now } + ).call end def reset_otp_session_data @@ -187,9 +165,7 @@ def reset_otp_session_data end def after_otp_verification_confirmation_url - if idv_context? - idv_review_url - elsif after_otp_action_required? + if after_otp_action_required? after_otp_action_url else after_sign_in_path_for(current_user) @@ -197,13 +173,17 @@ def after_otp_verification_confirmation_url end def after_otp_action_required? + policy = PersonalKeyForNewUserPolicy.new(user: current_user, session: session) + decorated_user.password_reset_profile.present? || @updating_existing_number || - decorated_user.should_acknowledge_personal_key?(session) + policy.show_personal_key_after_initial_2fa_setup? end def after_otp_action_url - if decorated_user.should_acknowledge_personal_key?(user_session) + policy = PersonalKeyForNewUserPolicy.new(user: current_user, session: session) + + if policy.show_personal_key_after_initial_2fa_setup? sign_up_personal_key_url elsif @updating_existing_number account_url @@ -224,20 +204,17 @@ def direct_otp_code end def personal_key_unavailable? - idv_or_confirmation_context? || - profile_context? || - current_user.encrypted_recovery_code_digest.blank? + current_user.encrypted_recovery_code_digest.blank? end def unconfirmed_phone? - user_session[:unconfirmed_phone] && idv_or_confirmation_context? + user_session[:unconfirmed_phone] && confirmation_context? end # rubocop:disable MethodLength def phone_view_data { confirmation_for_phone_change: confirmation_for_phone_change?, - confirmation_for_idv: idv_context?, phone_number: display_phone_to_deliver_to, code_value: direct_otp_code, otp_delivery_preference: two_factor_authentication_method, @@ -245,7 +222,7 @@ def phone_view_data reenter_phone_number_path: reenter_phone_number_path, unconfirmed_phone: unconfirmed_phone?, totp_enabled: current_user.totp_enabled?, - remember_device_available: !idv_context?, + remember_device_available: true, account_reset_token: account_reset_token, }.merge(generic_data) end @@ -260,7 +237,7 @@ 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_configuration&.mfa_enabled?, + phone_enabled: current_user.phone_configurations.any?(&:mfa_enabled?), }.merge(generic_data) end @@ -282,7 +259,7 @@ def display_phone_to_deliver_to def voice_otp_delivery_unsupported? phone_number = if authentication_context? - current_user.phone_configuration&.phone + current_user.phone_configurations.first&.phone else user_session[:unconfirmed_phone] end @@ -295,9 +272,7 @@ def decorated_user def reenter_phone_number_path locale = LinkLocaleResolver.locale - if idv_context? - idv_phone_path(locale: locale) - elsif current_user.phone_configuration.present? + if current_user.phone_configurations.any? manage_phone_path(locale: locale) else phone_setup_path(locale: locale) @@ -305,7 +280,7 @@ def reenter_phone_number_path end def confirmation_for_phone_change? - confirmation_context? && current_user.phone_configuration.present? + confirmation_context? && current_user.phone_configurations.any? end def presenter_for_two_factor_authentication_method diff --git a/app/controllers/concerns/user_session_context.rb b/app/controllers/concerns/user_session_context.rb index ed40c55796b..a7021ddb1e6 100644 --- a/app/controllers/concerns/user_session_context.rb +++ b/app/controllers/concerns/user_session_context.rb @@ -16,20 +16,4 @@ def authentication_context? def confirmation_context? context == 'confirmation' end - - def idv_context? - context == 'idv' - end - - def idv_or_confirmation_context? - confirmation_context? || idv_context? - end - - def idv_or_profile_context? - idv_context? || profile_context? - end - - def profile_context? - context == 'profile' - end end diff --git a/app/controllers/concerns/verify_profile_concern.rb b/app/controllers/concerns/verify_profile_concern.rb index 709cf7603a7..598533a4fe9 100644 --- a/app/controllers/concerns/verify_profile_concern.rb +++ b/app/controllers/concerns/verify_profile_concern.rb @@ -10,7 +10,6 @@ def account_or_verify_profile_url end def account_or_verify_profile_route - return 'account' if idv_context? || profile_context? return 'account' unless profile_needs_verification? 'verify_account' end diff --git a/app/controllers/idv/confirmations_controller.rb b/app/controllers/idv/confirmations_controller.rb index 4bb1ce61c71..e5a210c8579 100644 --- a/app/controllers/idv/confirmations_controller.rb +++ b/app/controllers/idv/confirmations_controller.rb @@ -35,7 +35,9 @@ def confirm_profile_has_been_created def track_final_idv_event result = { success: true, - new_phone_added: idv_session.params['phone'] != current_user.phone_configuration&.phone, + new_phone_added: !current_user.phone_configurations.map(&:phone).include?( + idv_session.params['phone'] + ), } analytics.track_event(Analytics::IDV_FINAL, result) end diff --git a/app/controllers/idv/otp_delivery_method_controller.rb b/app/controllers/idv/otp_delivery_method_controller.rb index e8e0a147542..89d2dd5da56 100644 --- a/app/controllers/idv/otp_delivery_method_controller.rb +++ b/app/controllers/idv/otp_delivery_method_controller.rb @@ -1,11 +1,13 @@ module Idv class OtpDeliveryMethodController < ApplicationController include IdvSession - include PhoneConfirmation + include PhoneOtpRateLimitable + include PhoneOtpSendable + # confirm_two_factor_authenticated before action is in PhoneOtpRateLimitable before_action :confirm_phone_step_complete before_action :confirm_step_needed - before_action :idv_phone # Memoize to use ivar in the view + before_action :set_idv_phone def new analytics.track_event(Analytics::IDV_PHONE_OTP_DELIVERY_SELECTION_VISIT) @@ -14,11 +16,10 @@ 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) - if result.success? - prompt_to_confirm_idv_phone - else - render :new - end + return render(:new) unless result.success? + send_phone_confirmation_otp_and_handle_result + rescue Twilio::REST::RestError, PhoneVerification::VerifyError => exception + invalid_phone_number(exception) end private @@ -32,16 +33,8 @@ def confirm_step_needed idv_session.user_phone_confirmation == true end - def idv_phone - @idv_phone ||= PhoneFormatter.format(idv_session.params[:phone]) - end - - def prompt_to_confirm_idv_phone - prompt_to_confirm_phone( - phone: idv_phone, - context: 'idv', - selected_delivery_method: otp_delivery_selection_form.otp_delivery_preference - ) + def set_idv_phone + @idv_phone = PhoneFormatter.format(idv_session.params[:phone]) end def otp_delivery_selection_params @@ -50,6 +43,22 @@ def otp_delivery_selection_params ) end + def send_phone_confirmation_otp_and_handle_result + save_delivery_preference_in_session + result = send_phone_confirmation_otp + analytics.track_event(Analytics::IDV_PHONE_CONFIRMATION_OTP_SENT, result.to_h) + if send_phone_confirmation_otp_rate_limited? + handle_too_many_otp_sends + else + redirect_to idv_otp_verification_url + end + end + + def save_delivery_preference_in_session + idv_session.phone_confirmation_otp_delivery_method = + @otp_delivery_selection_form.otp_delivery_preference + end + def otp_delivery_selection_form @otp_delivery_selection_form ||= Idv::OtpDeliveryMethodForm.new end diff --git a/app/controllers/idv/otp_verification_controller.rb b/app/controllers/idv/otp_verification_controller.rb new file mode 100644 index 00000000000..3ee9294bdb1 --- /dev/null +++ b/app/controllers/idv/otp_verification_controller.rb @@ -0,0 +1,67 @@ +module Idv + class OtpVerificationController < ApplicationController + include IdvSession + include PhoneOtpRateLimitable + + # confirm_two_factor_authenticated before action is in PhoneOtpRateLimitable + before_action :confirm_step_needed + before_action :confirm_otp_sent + before_action :set_code + before_action :set_otp_verification_presenter + + def show + # memoize the form so the ivar is available to the view + phone_confirmation_otp_verification_form + analytics.track_event(Analytics::IDV_PHONE_CONFIRMATION_OTP_VISIT) + end + + def update + result = phone_confirmation_otp_verification_form.submit(code: params[:code]) + analytics.track_event(Analytics::IDV_PHONE_CONFIRMATION_OTP_SUBMITTED, result.to_h) + if result.success? + redirect_to idv_review_url + else + handle_otp_confirmation_failure + end + end + + private + + def confirm_step_needed + return unless idv_session.user_phone_confirmation + redirect_to idv_review_url + end + + def confirm_otp_sent + return if idv_session.phone_confirmation_otp.present? && + idv_session.phone_confirmation_otp_sent_at.present? + + redirect_to idv_otp_delivery_method_url + end + + def set_code + return unless FeatureManagement.prefill_otp_codes? + @code = idv_session.phone_confirmation_otp + end + + def set_otp_verification_presenter + @presenter = OtpVerificationPresenter.new(idv_session: idv_session) + end + + 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') + render :show + end + end + + def phone_confirmation_otp_verification_form + @phone_confirmation_otp_verification_form ||= PhoneConfirmationOtpVerificationForm.new( + user: current_user, + idv_session: idv_session + ) + end + end +end diff --git a/app/controllers/idv/resend_otp_controller.rb b/app/controllers/idv/resend_otp_controller.rb new file mode 100644 index 00000000000..83482415e4b --- /dev/null +++ b/app/controllers/idv/resend_otp_controller.rb @@ -0,0 +1,37 @@ +module Idv + class ResendOtpController < ApplicationController + include IdvSession + include PhoneOtpRateLimitable + include PhoneOtpSendable + + # confirm_two_factor_authenticated before action is in PhoneOtpRateLimitable + before_action :confirm_user_phone_confirmation_needed + before_action :confirm_otp_delivery_preference_selected + + def create + result = send_phone_confirmation_otp + analytics.track_event(Analytics::IDV_PHONE_CONFIRMATION_OTP_RESENT, result.to_h) + if send_phone_confirmation_otp_rate_limited? + handle_too_many_otp_sends + else + redirect_to idv_otp_verification_url + end + rescue Twilio::REST::RestError, PhoneVerification::VerifyError => exception + invalid_phone_number(exception) + end + + private + + def confirm_user_phone_confirmation_needed + return unless idv_session.user_phone_confirmation + redirect_to idv_review_url + end + + def confirm_otp_delivery_preference_selected + return if idv_session.params[:phone].present? && + idv_session.phone_confirmation_otp_delivery_method.present? + + redirect_to idv_otp_delivery_method_url + end + end +end diff --git a/app/controllers/idv/review_controller.rb b/app/controllers/idv/review_controller.rb index 857f180cf55..88bd1dda2d3 100644 --- a/app/controllers/idv/review_controller.rb +++ b/app/controllers/idv/review_controller.rb @@ -15,11 +15,7 @@ def confirm_idv_steps_complete def confirm_idv_phone_confirmed return unless idv_session.address_verification_mechanism == 'phone' return if idv_session.phone_confirmed? - - prompt_to_confirm_phone( - phone: idv_session.params[:phone], - context: 'idv' - ) + redirect_to idv_otp_verification_path end def confirm_current_password diff --git a/app/controllers/idv/sessions_controller.rb b/app/controllers/idv/sessions_controller.rb index 4c9ae33a16e..d33df561000 100644 --- a/app/controllers/idv/sessions_controller.rb +++ b/app/controllers/idv/sessions_controller.rb @@ -17,7 +17,6 @@ class SessionsController < ApplicationController def new analytics.track_event(Analytics::IDV_BASIC_INFO_VISIT) - user_session[:context] = 'idv' set_idv_form @selected_state = user_session[:idv_jurisdiction] end diff --git a/app/controllers/openid_connect/authorization_controller.rb b/app/controllers/openid_connect/authorization_controller.rb index c0be8f8155c..941c1cea532 100644 --- a/app/controllers/openid_connect/authorization_controller.rb +++ b/app/controllers/openid_connect/authorization_controller.rb @@ -38,6 +38,7 @@ def redirect_to_account_or_verify_profile_url end def profile_or_identity_needs_verification? + return false unless @authorize_form.loa3_requested? profile_needs_verification? || identity_needs_verification? end @@ -52,7 +53,7 @@ def track_authorize_analytics(result) def apply_secure_headers_override override_content_security_policy_directives( - form_action: ["'self'", @authorize_form.sp_redirect_uri].compact, + form_action: ["'self'", authorization_params[:redirect_uri]].compact, preserve_schemes: true ) end diff --git a/app/controllers/saml_idp_controller.rb b/app/controllers/saml_idp_controller.rb index c6bc5ab98ab..3bce7a68583 100644 --- a/app/controllers/saml_idp_controller.rb +++ b/app/controllers/saml_idp_controller.rb @@ -81,6 +81,7 @@ def redirect_to_account_or_verify_profile_url end def profile_or_identity_needs_verification? + return false unless loa3_requested? profile_needs_verification? || identity_needs_verification? end diff --git a/app/controllers/two_factor_authentication/otp_verification_controller.rb b/app/controllers/two_factor_authentication/otp_verification_controller.rb index e86e697162e..f955715df68 100644 --- a/app/controllers/two_factor_authentication/otp_verification_controller.rb +++ b/app/controllers/two_factor_authentication/otp_verification_controller.rb @@ -26,7 +26,7 @@ def create private def confirm_two_factor_enabled - return if confirming_phone? || phone_enabled? + return if confirmation_context? || phone_enabled? if current_user.two_factor_enabled? && !phone_enabled? && user_signed_in? return redirect_to user_two_factor_authentication_url @@ -35,12 +35,8 @@ def confirm_two_factor_enabled redirect_to phone_setup_url end - def confirming_phone? - idv_context? || confirmation_context? - end - def phone_enabled? - current_user.phone_configuration&.mfa_enabled? + current_user.phone_configurations.any?(&:mfa_enabled?) end def confirm_voice_capability @@ -58,7 +54,7 @@ def confirm_voice_capability end def phone - current_user&.phone_configuration&.phone || user_session[:unconfirmed_phone] + 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 19380eee8e7..821c27be3e8 100644 --- a/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb +++ b/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb @@ -39,9 +39,11 @@ def handle_valid_piv_cac end def next_step - return account_recovery_setup_url unless current_user.phone_configuration&.mfa_enabled? - - after_otp_verification_confirmation_url + if current_user.phone_configurations.any?(&:mfa_enabled?) + after_otp_verification_confirmation_url + else + account_recovery_setup_url + end end def handle_invalid_piv_cac @@ -64,7 +66,7 @@ def piv_cac_view_data user_email: current_user.email, remember_device_available: false, totp_enabled: current_user.totp_enabled?, - phone_enabled: current_user.phone_configuration&.mfa_enabled?, + phone_enabled: current_user.phone_configurations.any?(&:mfa_enabled?), piv_cac_nonce: piv_cac_nonce, }.merge(generic_data) end diff --git a/app/controllers/two_factor_authentication/totp_verification_controller.rb b/app/controllers/two_factor_authentication/totp_verification_controller.rb index 60fe755c1c8..d13dfc4fec0 100644 --- a/app/controllers/two_factor_authentication/totp_verification_controller.rb +++ b/app/controllers/two_factor_authentication/totp_verification_controller.rb @@ -6,6 +6,8 @@ class TotpVerificationController < ApplicationController def show @presenter = presenter_for_two_factor_authentication_method + return unless FeatureManagement.prefill_otp_codes? + @code = ROTP::TOTP.new(current_user.otp_secret_key).now end def create diff --git a/app/controllers/users/phone_setup_controller.rb b/app/controllers/users/phone_setup_controller.rb index adbcf2af193..7f035e4d117 100644 --- a/app/controllers/users/phone_setup_controller.rb +++ b/app/controllers/users/phone_setup_controller.rb @@ -30,7 +30,8 @@ def create private def delivery_preference - current_user.phone_configuration&.delivery_preference || current_user.otp_delivery_preference + current_user.phone_configurations.first&.delivery_preference || + current_user.otp_delivery_preference end def two_factor_enabled? diff --git a/app/controllers/users/phones_controller.rb b/app/controllers/users/phones_controller.rb index 86f655b7aea..708af96a429 100644 --- a/app/controllers/users/phones_controller.rb +++ b/app/controllers/users/phones_controller.rb @@ -27,7 +27,8 @@ def user_params end def delivery_preference - current_user.phone_configuration&.delivery_preference || current_user.otp_delivery_preference + current_user.phone_configurations.first&.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 b7df4bbd715..3e10f52b4d3 100644 --- a/app/controllers/users/piv_cac_authentication_setup_controller.rb +++ b/app/controllers/users/piv_cac_authentication_setup_controller.rb @@ -79,7 +79,7 @@ def process_valid_submission end def next_step - return account_url if current_user.phone_configuration&.mfa_enabled? + return account_url if current_user.phone_configurations.any?(&:mfa_enabled?) account_recovery_setup_url end diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index b48093f87c0..5b57540e8d7 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -10,7 +10,6 @@ class SessionsController < Devise::SessionsController before_action :store_sp_metadata_in_session, only: [:new] before_action :check_user_needs_redirect, only: [:new] before_action :apply_secure_headers_override, only: [:new] - before_action :configure_permitted_parameters, only: [:new] def new analytics.track_event( @@ -51,12 +50,6 @@ def timeout private - def configure_permitted_parameters - devise_parameter_sanitizer.permit(:sign_in) do |user_params| - user_params.permit(:email) if user_params.respond_to?(:permit) - end - end - def redirect_to_signin controller_info = 'users/sessions#create' analytics.track_event(Analytics::INVALID_AUTHENTICITY_TOKEN, controller: controller_info) @@ -130,8 +123,10 @@ def cache_active_profile begin cacher.save(auth_params[:password], profile) rescue Encryption::EncryptionError => err - profile.deactivate(:encryption_error) - analytics.track_event(Analytics::PROFILE_ENCRYPTION_INVALID, error: err.message) + if profile + profile.deactivate(:encryption_error) + analytics.track_event(Analytics::PROFILE_ENCRYPTION_INVALID, error: err.message) + end end end diff --git a/app/controllers/users/totp_setup_controller.rb b/app/controllers/users/totp_setup_controller.rb index e8a4c829015..6b0dac80dfc 100644 --- a/app/controllers/users/totp_setup_controller.rb +++ b/app/controllers/users/totp_setup_controller.rb @@ -63,13 +63,21 @@ def mark_user_as_fully_authenticated end def url_after_entering_valid_code - if current_user.decorate.should_acknowledge_personal_key?(user_session) + 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 - account_url + idv_jurisdiction_url end end + def user_already_has_a_personal_key? + PersonalKeyLoginOptionPolicy.new(current_user).configured? + end + def process_invalid_code flash[:error] = t('errors.invalid_totp') redirect_to authenticator_setup_url diff --git a/app/controllers/users/two_factor_authentication_controller.rb b/app/controllers/users/two_factor_authentication_controller.rb index e4f285aa902..d87c2a8c74b 100644 --- a/app/controllers/users/two_factor_authentication_controller.rb +++ b/app/controllers/users/two_factor_authentication_controller.rb @@ -41,11 +41,10 @@ def phone_enabled? end def phone_configuration - current_user.phone_configuration + current_user.phone_configurations.first end def validate_otp_delivery_preference_and_send_code - delivery_preference = phone_configuration.delivery_preference result = otp_delivery_selection_form.submit(otp_delivery_preference: delivery_preference) analytics.track_event(Analytics::OTP_DELIVERY_SELECTION, result.to_h) @@ -57,6 +56,10 @@ def validate_otp_delivery_preference_and_send_code end end + def delivery_preference + phone_configuration&.delivery_preference || current_user.otp_delivery_preference + end + def update_otp_delivery_preference_if_needed OtpDeliveryPreferenceUpdater.new( user: current_user, @@ -67,8 +70,7 @@ def update_otp_delivery_preference_if_needed def handle_invalid_otp_delivery_preference(result) flash[:error] = result.errors[:phone].first - preference = current_user.phone_configuration.delivery_preference - redirect_to login_two_factor_url(otp_delivery_preference: preference) + redirect_to login_two_factor_url(otp_delivery_preference: delivery_preference) end def invalid_phone_number(exception, action:) @@ -85,7 +87,7 @@ def invalid_phone_number(exception, action:) def redirect_to_otp_verification_with_error flash[:error] = t('errors.messages.phone_unsupported') redirect_to login_two_factor_url( - otp_delivery_preference: current_user.phone_configuration.delivery_preference, + otp_delivery_preference: phone_configuration.delivery_preference, reauthn: reauthn? ) end @@ -179,7 +181,7 @@ def delivery_params end def phone_to_deliver_to - return current_user.phone_configuration.phone if authentication_context? + return phone_configuration&.phone if authentication_context? user_session[:unconfirmed_phone] end diff --git a/app/controllers/users/webauthn_setup_controller.rb b/app/controllers/users/webauthn_setup_controller.rb new file mode 100644 index 00000000000..f2a771d4068 --- /dev/null +++ b/app/controllers/users/webauthn_setup_controller.rb @@ -0,0 +1,87 @@ +module Users + class WebauthnSetupController < ApplicationController + before_action :authenticate_user! + before_action :confirm_two_factor_authenticated, if: :two_factor_enabled? + + def new + analytics.track_event(Analytics::WEBAUTHN_SETUP_VISIT) + save_challenge_in_session + end + + def confirm + form = WebauthnSetupForm.new(current_user, user_session) + result = form.submit(request.protocol, params) + analytics.track_event(Analytics::WEBAUTHN_SETUP_SUBMITTED, result.to_h) + if result.success? + process_valid_webauthn(form.attestation_response) + else + process_invalid_webauthn(form) + end + end + + def delete + if current_user.total_mfa_options_enabled > 1 + handle_successful_delete + else + handle_failed_delete + end + redirect_to account_url + end + + private + + def handle_successful_delete + WebauthnConfiguration.where(user_id: current_user.id, id: params[:id]).destroy_all + flash[:success] = t('notices.webauthn_deleted') + track_delete(true) + end + + def handle_failed_delete + flash[:error] = t('errors.webauthn_setup.delete_last') + track_delete(false) + end + + def track_delete(success) + analytics.track_event( + Analytics::WEBAUTHN_DELETED, + success: success, + mfa_options_enabled: current_user.total_mfa_options_enabled + ) + end + + def save_challenge_in_session + credential_creation_options = ::WebAuthn.credential_creation_options + user_session[:webauthn_challenge] = credential_creation_options[:challenge].bytes.to_a + end + + def two_factor_enabled? + current_user.two_factor_enabled? + end + + def process_valid_webauthn(attestation_response) + create_webauthn_configuration(attestation_response) + flash[:success] = t('notices.webauthn_added') + redirect_to account_url + end + + def process_invalid_webauthn(form) + if form.name_taken + flash.now[:error] = t('errors.webauthn_setup.unique_name') + render 'users/webauthn_setup/new' + else + flash[:error] = t('errors.webauthn_setup.general_error') + redirect_to account_url + end + end + + def create_webauthn_configuration(attestation_response) + credential = attestation_response.credential + public_key = Base64.encode64(credential.public_key) + id = Base64.encode64(credential.id) + WebauthnConfiguration.create(user_id: current_user.id, + credential_public_key: public_key, + credential_id: id, + name: params[:name]) + end + end +end diff --git a/app/decorators/service_provider_session_decorator.rb b/app/decorators/service_provider_session_decorator.rb index c025283e6e2..46e2a470c72 100644 --- a/app/decorators/service_provider_session_decorator.rb +++ b/app/decorators/service_provider_session_decorator.rb @@ -11,14 +11,6 @@ class ServiceProviderSessionDecorator i18n_name: 'usa_jobs', learn_more: 'https://login.gov/help/', }, - 'Railroad Retirement Board' => { - i18n_name: 'railroad_retirement_board', - learn_more: 'https://login.gov/help/', - }, - 'U.S. Railroad Retirement Board Benefit Connect' => { - i18n_name: 'railroad_retirement_board', - learn_more: 'https://login.gov/help/', - }, 'SAM' => { i18n_name: 'sam', learn_more: 'https://login.gov/help/', @@ -92,8 +84,12 @@ def sp_agency end def sp_return_url - if sp.redirect_uris.present? && request_url.is_a?(String) && openid_connect_redirector.valid? - openid_connect_redirector.decline_redirect_uri + if sp.redirect_uris.present? && valid_oidc_request? + URIService.add_params( + oidc_redirect_uri, + error: 'access_denied', + state: request_params[:state] + ) else sp.return_to_sp_url end @@ -131,7 +127,20 @@ def request_url sp_session[:request_url] || service_provider_request.url end - def openid_connect_redirector - @_openid_connect_redirector ||= OpenidConnectRedirector.from_request_url(request_url) + def valid_oidc_request? + return false if request_url.nil? + authorize_form.valid? + end + + def authorize_form + OpenidConnectAuthorizeForm.new(request_params) + end + + def oidc_redirect_uri + request_params[:redirect_uri] + end + + def request_params + @request_params ||= URIService.params(request_url) end end diff --git a/app/decorators/user_decorator.rb b/app/decorators/user_decorator.rb index 919595205e3..9e44ee157db 100644 --- a/app/decorators/user_decorator.rb +++ b/app/decorators/user_decorator.rb @@ -36,7 +36,7 @@ def confirmation_period end def masked_two_factor_phone_number - masked_number(user.phone_configuration&.phone) + masked_number(user.phone_configurations.first&.phone) end def active_identity_for(service_provider) @@ -99,14 +99,6 @@ def no_longer_locked_out? user.second_factor_locked_at.present? && lockout_period_expired? end - def should_acknowledge_personal_key?(session) - return true if session[:personal_key] - - sp_session = session[:sp] - - user.encrypted_recovery_code_digest.blank? && (sp_session.blank? || sp_session[:loa3] == false) - end - def recent_events events = user.events.order('created_at DESC').limit(MAX_RECENT_EVENTS).map(&:decorate) identities = user.identities.order('last_authenticated_at DESC').map(&:decorate) diff --git a/app/errors/twilio_errors.rb b/app/errors/twilio_errors.rb index e2f3d37f763..731552437ef 100644 --- a/app/errors/twilio_errors.rb +++ b/app/errors/twilio_errors.rb @@ -4,6 +4,7 @@ module TwilioErrors 21_211 => I18n.t('errors.messages.invalid_phone_number'), 21_215 => I18n.t('errors.messages.invalid_calling_area'), 21_614 => I18n.t('errors.messages.invalid_sms_number'), + 4_815_162_342 => I18n.t('errors.messages.twilio_timeout'), }.freeze VERIFY_ERRORS = { diff --git a/app/forms/idv/phone_confirmation_otp_verification_form.rb b/app/forms/idv/phone_confirmation_otp_verification_form.rb new file mode 100644 index 00000000000..a26586c67c7 --- /dev/null +++ b/app/forms/idv/phone_confirmation_otp_verification_form.rb @@ -0,0 +1,61 @@ +module Idv + class PhoneConfirmationOtpVerificationForm + attr_reader :user, :idv_session, :code + + def initialize(user:, idv_session:) + @user = user + @idv_session = idv_session + end + + def submit(code:) + @code = code + success = code_valid? + if success + idv_session.user_phone_confirmation = true + clear_second_factor_attempts + else + increment_second_factor_attempts + end + FormResponse.new(success: success, errors: {}, extra: extra_analytics_attributes) + end + + private + + def code_valid? + return false if code_expired? + code_matches? + end + + # Ignore duplicate method call on Time.zone :reek:DuplicateMethodCall + def code_expired? + sent_at_time = Time.zone.parse(idv_session.phone_confirmation_otp_sent_at) + expiration_time = sent_at_time + Figaro.env.otp_valid_for.to_i.minutes + Time.zone.now > expiration_time + end + + def code_matches? + Devise.secure_compare(code, idv_session.phone_confirmation_otp) + end + + def clear_second_factor_attempts + UpdateUser.new(user: user, attributes: { second_factor_attempts_count: 0 }).call + end + + def increment_second_factor_attempts + user.second_factor_attempts_count += 1 + attributes = {} + attributes[:second_factor_locked_at] = Time.zone.now if user.max_login_attempts? + + UpdateUser.new(user: user, attributes: attributes).call + end + + def extra_analytics_attributes + { + code_expired: code_expired?, + code_matches: code_matches?, + second_factor_attempts_count: user.second_factor_attempts_count, + second_factor_locked_at: user.second_factor_locked_at, + } + end + end +end diff --git a/app/forms/idv/phone_form.rb b/app/forms/idv/phone_form.rb index 30f4a1420ce..2bca7248048 100644 --- a/app/forms/idv/phone_form.rb +++ b/app/forms/idv/phone_form.rb @@ -10,7 +10,7 @@ class PhoneForm def initialize(idv_params, user) @idv_params = idv_params @user = user - self.phone = initial_phone_value(idv_params[:phone] || user.phone_configuration&.phone) + self.phone = initial_phone_value(idv_params[:phone] || user.phone_configurations.first&.phone) self.international_code = PhoneFormatter::DEFAULT_COUNTRY end @@ -45,11 +45,11 @@ def update_idv_params(phone) idv_params[:phone] = normalized_phone return idv_params[:phone_confirmed_at] = nil unless phone == formatted_user_phone - idv_params[:phone_confirmed_at] = user.phone_configuration&.confirmed_at + idv_params[:phone_confirmed_at] = user.phone_configurations.first&.confirmed_at end def formatted_user_phone - Phonelib.parse(user.phone_configuration&.phone).international + Phonelib.parse(user.phone_configurations.first&.phone).international end def parsed_phone diff --git a/app/forms/openid_connect_authorize_form.rb b/app/forms/openid_connect_authorize_form.rb index 4ee64191fd9..83596f7876b 100644 --- a/app/forms/openid_connect_authorize_form.rb +++ b/app/forms/openid_connect_authorize_form.rb @@ -2,6 +2,7 @@ class OpenidConnectAuthorizeForm include ActiveModel::Model include ActionView::Helpers::TranslationHelper + include RedirectUriValidator SIMPLE_ATTRS = %i[ client_id @@ -33,7 +34,6 @@ class OpenidConnectAuthorizeForm validate :validate_acr_values validate :validate_client_id - validate :validate_redirect_uri validate :validate_scope def initialize(params) @@ -43,10 +43,6 @@ def initialize(params) instance_variable_set(:"@#{key}", params[key]) end @prompt ||= 'select_account' - - @openid_connect_redirector = OpenidConnectRedirector.new( - redirect_uri: redirect_uri, service_provider: service_provider, state: state, errors: errors - ) end def submit @@ -59,10 +55,6 @@ def loa3_requested? loa == 3 end - def sp_redirect_uri - openid_connect_redirector.validated_input_redirect_uri - end - def service_provider @_service_provider ||= ServiceProvider.from_issuer(client_id) end @@ -79,13 +71,15 @@ def link_identity_to_service_provider(current_user, rails_session_id) end def success_redirect_uri + uri = redirect_uri unless errors.include?(:redirect_uri) code = identity&.session_uuid - openid_connect_redirector.success_redirect_uri(code: code) if code + + URIService.add_params(uri, code: code, state: state) if code end private - attr_reader :identity, :success, :openid_connect_redirector, :already_linked + attr_reader :identity, :success, :already_linked def requested_attributes @requested_attributes ||= @@ -107,10 +101,6 @@ def validate_client_id errors.add(:client_id, t('openid_connect.authorization.errors.bad_client_id')) end - def validate_redirect_uri - openid_connect_redirector.validate - end - def validate_scope return if scope.present? errors.add(:scope, t('openid_connect.authorization.errors.no_valid_scope')) @@ -141,7 +131,14 @@ def result_uri end def error_redirect_uri - openid_connect_redirector.error_redirect_uri + uri = redirect_uri unless errors.include?(:redirect_uri) + + URIService.add_params( + uri, + error: 'invalid_request', + error_description: errors.full_messages.join(' '), + state: state + ) end end # rubocop:enable Metrics/ClassLength diff --git a/app/forms/openid_connect_logout_form.rb b/app/forms/openid_connect_logout_form.rb index 1b21e5bd241..98bc7d2af4a 100644 --- a/app/forms/openid_connect_logout_form.rb +++ b/app/forms/openid_connect_logout_form.rb @@ -1,6 +1,7 @@ class OpenidConnectLogoutForm include ActiveModel::Model include ActionView::Helpers::TranslationHelper + include RedirectUriValidator ATTRS = %i[ id_token_hint @@ -16,7 +17,6 @@ class OpenidConnectLogoutForm validates :post_logout_redirect_uri, presence: true validates :state, presence: true, length: { minimum: RANDOM_VALUE_MINIMUM_LENGTH } - validate :validate_redirect_uri validate :validate_identity def initialize(params) @@ -25,7 +25,6 @@ def initialize(params) end @identity = load_identity - @openid_connect_redirector = build_openid_connect_redirector end def submit @@ -39,7 +38,6 @@ def submit private attr_reader :identity, - :openid_connect_redirector, :success def load_identity @@ -58,20 +56,6 @@ def identity_from_payload(payload) AgencyIdentityLinker.sp_identity_from_uuid_and_sp(uuid, sp) end - def build_openid_connect_redirector - OpenidConnectRedirector.new( - redirect_uri: post_logout_redirect_uri, - service_provider: service_provider, - state: state, - errors: errors, - error_attr: :post_logout_redirect_uri - ) - end - - def validate_redirect_uri - openid_connect_redirector.validate - end - def validate_identity errors.add(:id_token_hint, t('openid_connect.logout.errors.id_token_hint')) unless identity end @@ -90,10 +74,23 @@ def extra_analytics_attributes end def redirect_uri - if success - openid_connect_redirector.logout_redirect_uri - else - openid_connect_redirector.error_redirect_uri - end + success ? logout_redirect_uri : error_redirect_uri + end + + def logout_redirect_uri + uri = post_logout_redirect_uri unless errors.include?(:redirect_uri) + + URIService.add_params(uri, state: state) + end + + def error_redirect_uri + uri = post_logout_redirect_uri unless errors.include?(:redirect_uri) + + URIService.add_params( + uri, + error: 'invalid_request', + error_description: errors.full_messages.join(' '), + state: state + ) end end diff --git a/app/forms/two_factor_options_form.rb b/app/forms/two_factor_options_form.rb index adb16f78cc1..c62048eef4e 100644 --- a/app/forms/two_factor_options_form.rb +++ b/app/forms/two_factor_options_form.rb @@ -36,7 +36,7 @@ def extra_analytics_attributes def user_needs_updating? return false unless %w[voice sms].include?(selection) - return false if selection == user.phone_configuration&.delivery_preference + return false if selection == user.phone_configurations.first&.delivery_preference selection != user.otp_delivery_preference end diff --git a/app/forms/user_phone_form.rb b/app/forms/user_phone_form.rb index 3f7edba07af..aaa26676a22 100644 --- a/app/forms/user_phone_form.rb +++ b/app/forms/user_phone_form.rb @@ -5,11 +5,11 @@ class UserPhoneForm validates :otp_delivery_preference, inclusion: { in: %w[voice sms] } - attr_accessor :phone, :international_code, :otp_delivery_preference + attr_accessor :phone, :international_code, :otp_delivery_preference, :phone_configuration def initialize(user) self.user = user - phone_configuration = user.phone_configuration + self.phone_configuration = user.phone_configurations.first if phone_configuration.nil? self.otp_delivery_preference = user.otp_delivery_preference else @@ -59,7 +59,7 @@ def ingest_submitted_params(params) end def otp_delivery_preference_changed? - otp_delivery_preference != user.phone_configuration&.delivery_preference + otp_delivery_preference != phone_configuration&.delivery_preference end def update_otp_delivery_preference_for_user @@ -68,6 +68,6 @@ def update_otp_delivery_preference_for_user end def formatted_user_phone - Phonelib.parse(user.phone_configuration.phone).international + phone_configuration&.formatted_phone end end diff --git a/app/forms/webauthn_setup_form.rb b/app/forms/webauthn_setup_form.rb new file mode 100644 index 00000000000..6b4ee9ba013 --- /dev/null +++ b/app/forms/webauthn_setup_form.rb @@ -0,0 +1,58 @@ +class WebauthnSetupForm + include ActiveModel::Model + + validates :user, presence: true + validates :challenge, presence: true + validates :attestation_object, presence: true + validates :client_data_json, presence: true + validates :name, presence: true + validate :name_is_unique + + attr_reader :attestation_response, :name_taken + + def initialize(user, user_session) + @user = user + @challenge = user_session[:webauthn_challenge] + @attestation_object = nil + @client_data_json = nil + @attestation_response = nil + @name = nil + end + + def submit(protocol, params) + consume_parameters(params) + success = valid? && valid_attestation_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 + attr_accessor :user, :challenge, :attestation_object, :client_data_json, :name + + def consume_parameters(params) + @attestation_object = params[:attestation_object] + @client_data_json = params[:client_data_json] + @name = params[:name] + end + + def name_is_unique + return unless WebauthnConfiguration.exists?(user_id: @user.id, name: @name) + errors.add :name, I18n.t('errors.webauthn_setup.unique_name') + @name_taken = true + end + + def valid_attestation_response?(protocol) + @attestation_response = ::WebAuthn::AuthenticatorAttestationResponse.new( + attestation_object: Base64.decode64(@attestation_object), + client_data_json: Base64.decode64(@client_data_json) + ) + original_origin = "#{protocol}#{self.class.domain_name}" + @attestation_response.valid?(@challenge.pack('c*'), original_origin) + end +end diff --git a/app/javascript/app/radio-btn.js b/app/javascript/app/radio-btn.js index 452825fdaf0..1d1b9fa1146 100644 --- a/app/javascript/app/radio-btn.js +++ b/app/javascript/app/radio-btn.js @@ -4,7 +4,7 @@ function clearHighlight(name) { const radioGroup = document.querySelectorAll(`input[name='${name}']`); Array.prototype.forEach.call(radioGroup, (radio) => { - radio.parentNode.parentNode.classList.remove('bg-light-blue'); + radio.parentNode.parentNode.classList.remove('bg-lightest-blue'); }); } @@ -16,11 +16,11 @@ function highlightRadioBtn() { const label = radio.parentNode.parentNode; const name = radio.getAttribute('name'); - if (radio.checked) label.classList.add('bg-light-blue'); + if (radio.checked) label.classList.add('bg-lightest-blue'); radio.addEventListener('change', function() { clearHighlight(name); - if (radio.checked) label.classList.add('bg-light-blue'); + if (radio.checked) label.classList.add('bg-lightest-blue'); }); radio.addEventListener('focus', function() { diff --git a/app/javascript/packs/webauthn-setup.js b/app/javascript/packs/webauthn-setup.js new file mode 100644 index 00000000000..509e4e1e92b --- /dev/null +++ b/app/javascript/packs/webauthn-setup.js @@ -0,0 +1,53 @@ +function webauthn() { + 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 longToByteArray = function(long) { + const byteArray = new Uint8Array(8); + for (let index = 0; index < byteArray.length; index += 1) { + const byte = long & 0xff; // eslint-disable-line no-bitwise + byteArray[index] = byte; + long = (long - byte) / 256; + } + return byteArray; + }; + const userId = document.getElementById('user_id').value; + const userEmail = document.getElementById('user_email').value; + const challengeBytes = new Uint8Array(JSON.parse(document.getElementById('user_challenge').value)); + const createOptions = { + publicKey: { + challenge: challengeBytes, + rp: { name: window.location.hostname }, + user: { + id: longToByteArray(userId), + name: userEmail, + displayName: userEmail, + }, + pubKeyCredParams: [ + { + type: 'public-key', + alg: -7, + }, + ], + timeout: 800000, + attestation: 'direct', + excludeList: [], + }, + }; + const p = navigator.credentials.create(createOptions); + p.then((newCred) => { + document.getElementById('webauthn_id').value = arrayBufferToBase64(newCred.rawId); + document.getElementById('webauthn_public_key').value = newCred.id; + document.getElementById('attestation_object').value = arrayBufferToBase64(newCred.response.attestationObject); + document.getElementById('client_data_json').value = arrayBufferToBase64(newCred.response.clientDataJSON); + document.getElementById('spinner').className += ' hidden'; + document.getElementById('webauthn_name').classList.remove('hidden'); + }); +} +document.addEventListener('DOMContentLoaded', webauthn); diff --git a/app/models/account_reset_request.rb b/app/models/account_reset_request.rb index 0f123999a2d..db304cee1e1 100644 --- a/app/models/account_reset_request.rb +++ b/app/models/account_reset_request.rb @@ -1,11 +1,6 @@ class AccountResetRequest < ApplicationRecord belongs_to :user - def self.from_valid_granted_token(granted_token) - account_reset = AccountResetRequest.find_by(granted_token: granted_token) - account_reset&.granted_token_valid? ? account_reset : nil - end - def granted_token_valid? granted_token.present? && !granted_token_expired? end diff --git a/app/models/anonymous_user.rb b/app/models/anonymous_user.rb index eb15aa74eba..d5d7125245b 100644 --- a/app/models/anonymous_user.rb +++ b/app/models/anonymous_user.rb @@ -14,4 +14,6 @@ def phone_configuration def phone nil end + + def email; end end diff --git a/app/models/concerns/user_access_key_overrides.rb b/app/models/concerns/user_access_key_overrides.rb index cb604ad68e6..1918b907e31 100644 --- a/app/models/concerns/user_access_key_overrides.rb +++ b/app/models/concerns/user_access_key_overrides.rb @@ -5,6 +5,8 @@ module UserAccessKeyOverrides extend ActiveSupport::Concern + attr_reader :personal_key + def valid_password?(password) result = Encryption::PasswordVerifier.verify( password: password, @@ -27,14 +29,9 @@ def valid_personal_key?(normalized_personal_key) ) end - def personal_key - @personal_key - end - def personal_key=(new_personal_key) - @personal_key = new_personal_key - return if @personal_key.blank? - self.encrypted_recovery_code_digest = Encryption::PasswordVerifier.digest(@personal_key) + return if new_personal_key.blank? + self.encrypted_recovery_code_digest = Encryption::PasswordVerifier.digest(new_personal_key) end # This is a devise method, which we are overriding. This should not be removed diff --git a/app/models/phone_configuration.rb b/app/models/phone_configuration.rb index 80448e82c15..7e505ef8e96 100644 --- a/app/models/phone_configuration.rb +++ b/app/models/phone_configuration.rb @@ -1,11 +1,15 @@ class PhoneConfiguration < ApplicationRecord include EncryptableAttribute - belongs_to :user, inverse_of: :phone_configuration + belongs_to :user, inverse_of: :phone_configurations validates :user_id, presence: true validates :encrypted_phone, presence: true encrypted_attribute(name: :phone) enum delivery_preference: { sms: 0, voice: 1 } + + def formatted_phone + Phonelib.parse(phone).international + end end diff --git a/app/models/user.rb b/app/models/user.rb index b9357ebe893..3166f42bf4c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -3,6 +3,7 @@ class User < ApplicationRecord self.ignored_columns = %w[ encrypted_password password_salt password_cost encryption_key recovery_code recovery_cost recovery_salt + encrypted_phone phone_confirmed_at ] include NonNullUuid @@ -22,7 +23,6 @@ class User < ApplicationRecord include EncryptableAttribute - encrypted_attribute(name: :phone) encrypted_attribute(name: :otp_secret_key) encrypted_attribute_without_setter(name: :email) @@ -41,7 +41,8 @@ class User < ApplicationRecord has_many :profiles, dependent: :destroy has_many :events, dependent: :destroy has_one :account_reset_request, dependent: :destroy - has_one :phone_configuration, dependent: :destroy, inverse_of: :user + has_many :phone_configurations, dependent: :destroy, inverse_of: :user + has_many :webauthn_configurations, dependent: :destroy validates :x509_dn_uuid, uniqueness: true, allow_nil: true @@ -68,7 +69,8 @@ def need_two_factor_authentication?(_request) end def two_factor_enabled? - phone_configuration&.mfa_enabled? || totp_enabled? || piv_cac_enabled? + phone_configurations.any?(&:mfa_enabled?) || totp_enabled? || piv_cac_enabled? || + webauthn_configurations.any? end def send_two_factor_authentication_code(_code) @@ -158,5 +160,14 @@ 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 new file mode 100644 index 00000000000..7f99ec0bc7d --- /dev/null +++ b/app/models/webauthn_configuration.rb @@ -0,0 +1,7 @@ +class WebauthnConfiguration < ApplicationRecord + belongs_to :user + validates :user_id, presence: true + validates :name, presence: true + validates :credential_id, presence: true + validates :credential_public_key, presence: true +end diff --git a/app/policies/personal_key_for_new_user_policy.rb b/app/policies/personal_key_for_new_user_policy.rb new file mode 100644 index 00000000000..0067e07bd7c --- /dev/null +++ b/app/policies/personal_key_for_new_user_policy.rb @@ -0,0 +1,34 @@ +class PersonalKeyForNewUserPolicy + def initialize(user:, session:) + @user = user + @session = session + end + + # For new users who visit the site directly or via an LOA1 request, + # we show them their personal key after they set up 2FA for the first + # time during account creation. These users only see the personal key + # once during account creation. LOA3 users, on the other hand, need to + # confirm their personal key after verifying their identity because + # the key is used to encrypt their PII. Rather than making LOA3 users + # confirm personal keys twice, once after 2FA setup, and once after + # proofing, we only show it to them once after proofing. + def show_personal_key_after_initial_2fa_setup? + user_does_not_have_a_personal_key? && user_did_not_make_an_loa3_request? + end + + private + + attr_reader :user, :session + + def user_does_not_have_a_personal_key? + user.encrypted_recovery_code_digest.blank? + end + + def user_did_not_make_an_loa3_request? + sp_session.empty? || sp_session[:loa3] == false + end + + def sp_session + session.fetch(:sp, {}) + end +end diff --git a/app/policies/sms_login_option_policy.rb b/app/policies/sms_login_option_policy.rb index 45611708dbd..aee36acdf4c 100644 --- a/app/policies/sms_login_option_policy.rb +++ b/app/policies/sms_login_option_policy.rb @@ -5,7 +5,7 @@ def initialize(user) def configured? return false unless user - user.phone_configuration.present? + user.phone_configurations.any? end private diff --git a/app/policies/voice_login_option_policy.rb b/app/policies/voice_login_option_policy.rb index 55913a29704..24914c4c85b 100644 --- a/app/policies/voice_login_option_policy.rb +++ b/app/policies/voice_login_option_policy.rb @@ -12,7 +12,8 @@ def configured? attr_reader :user def user_has_a_phone_number_that_we_can_call? - phone = user.phone_configuration&.phone - phone.present? && !PhoneNumberCapabilities.new(phone).sms_only? + user.phone_configurations.any? do |phone_configuration| + !PhoneNumberCapabilities.new(phone_configuration.phone).sms_only? + end end end diff --git a/app/presenters/idv/otp_verification_presenter.rb b/app/presenters/idv/otp_verification_presenter.rb new file mode 100644 index 00000000000..82b0c50ec9c --- /dev/null +++ b/app/presenters/idv/otp_verification_presenter.rb @@ -0,0 +1,35 @@ +module Idv + class OtpVerificationPresenter + include ActionView::Helpers::UrlHelper + include ActionView::Helpers::TagHelper + include ActionView::Helpers::TranslationHelper + + attr_reader :idv_session + + def initialize(idv_session:) + @idv_session = idv_session + end + + def phone_number_message + t("instructions.mfa.#{otp_delivery_preference}.number_message", + number: content_tag(:strong, phone_number), + expiration: Figaro.env.otp_valid_for) + end + + def update_phone_link + phone_path = Rails.application.routes.url_helpers.idv_phone_path + link = link_to(t('forms.two_factor.try_again'), phone_path) + t('instructions.mfa.wrong_number_html', link: link) + end + + private + + def phone_number + PhoneFormatter.format(idv_session.params[:phone]) + end + + def otp_delivery_preference + idv_session.phone_confirmation_otp_delivery_method + end + end +end 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 286f71eb134..a1106863d9d 100644 --- a/app/presenters/two_factor_auth_code/phone_delivery_presenter.rb +++ b/app/presenters/two_factor_auth_code/phone_delivery_presenter.rb @@ -33,8 +33,6 @@ def cancel_link locale = LinkLocaleResolver.locale if confirmation_for_phone_change || reauthn account_path(locale: locale) - elsif confirmation_for_idv - idv_cancel_path(locale: locale) else sign_out_path(locale: locale) end @@ -49,8 +47,7 @@ def cancel_link :unconfirmed_phone, :account_reset_token, :confirmation_for_phone_change, - :voice_otp_delivery_unsupported, - :confirmation_for_idv + :voice_otp_delivery_unsupported ) def account_reset_link diff --git a/app/services/account_reset/cancel.rb b/app/services/account_reset/cancel.rb index 4171cd34e12..de85bbc54a9 100644 --- a/app/services/account_reset/cancel.rb +++ b/app/services/account_reset/cancel.rb @@ -54,7 +54,7 @@ def user end def phone - user.phone_configuration&.phone + 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 ac0ea23b662..71ced1cd6f6 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_configuration&.phone + phone = user.phone_configurations.first&.phone return unless phone SmsAccountResetNotifierJob.perform_now( phone: phone, diff --git a/app/services/account_reset/delete_account.rb b/app/services/account_reset/delete_account.rb new file mode 100644 index 00000000000..81a95f6b126 --- /dev/null +++ b/app/services/account_reset/delete_account.rb @@ -0,0 +1,41 @@ +module AccountReset + class DeleteAccount + include ActiveModel::Model + include GrantedTokenValidator + + def initialize(token) + @token = token + end + + def call + @success = valid? + + if success + notify_user_via_email_of_deletion + destroy_user + end + + FormResponse.new(success: success, errors: errors.messages, extra: extra_analytics_attributes) + end + + private + + attr_reader :success + + def destroy_user + user.destroy! + end + + def notify_user_via_email_of_deletion + UserMailer.account_reset_complete(user.email).deliver_later + end + + def extra_analytics_attributes + { + user_id: user.uuid, + event: 'delete', + email: user.email, + } + end + end +end diff --git a/app/services/account_reset/validate_granted_token.rb b/app/services/account_reset/validate_granted_token.rb new file mode 100644 index 00000000000..385416032e7 --- /dev/null +++ b/app/services/account_reset/validate_granted_token.rb @@ -0,0 +1,27 @@ +module AccountReset + class ValidateGrantedToken + include ActiveModel::Model + include GrantedTokenValidator + + 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 + + def extra_analytics_attributes + { + user_id: user.uuid, + event: 'granted token validation', + } + end + end +end diff --git a/app/services/analytics.rb b/app/services/analytics.rb index 788fa6f9edd..7bd725a87bb 100644 --- a/app/services/analytics.rb +++ b/app/services/analytics.rb @@ -73,6 +73,13 @@ def browser IDV_JURISDICTION_FORM = 'IdV: jurisdiction form submitted'.freeze IDV_PHONE_CONFIRMATION_FORM = 'IdV: phone confirmation form'.freeze IDV_PHONE_CONFIRMATION_VENDOR = 'IdV: phone confirmation vendor'.freeze + IDV_PHONE_CONFIRMATION_OTP_RATE_LIMIT_ATTEMPTS = 'Idv: Phone OTP attempts rate limited'.freeze + IDV_PHONE_CONFIRMATION_OTP_RATE_LIMIT_LOCKED_OUT = 'Idv: Phone OTP rate limited user'.freeze + IDV_PHONE_CONFIRMATION_OTP_RATE_LIMIT_SENDS = 'Idv: Phone OTP sends rate limited'.freeze + IDV_PHONE_CONFIRMATION_OTP_RESENT = 'IdV: phone confirmation otp resent'.freeze + IDV_PHONE_CONFIRMATION_OTP_SENT = 'IdV: phone confirmation otp sent'.freeze + IDV_PHONE_CONFIRMATION_OTP_SUBMITTED = 'IdV: phone confirmation otp submitted'.freeze + IDV_PHONE_CONFIRMATION_OTP_VISIT = 'IdV: phone confirmation otp visited'.freeze IDV_PHONE_OTP_DELIVERY_SELECTION_SUBMITTED = 'IdV: Phone OTP Delivery Selection Submitted'.freeze IDV_PHONE_OTP_DELIVERY_SELECTION_VISIT = 'IdV: Phone OTP delivery Selection Visited'.freeze IDV_PHONE_RECORD_VISIT = 'IdV: phone of record visited'.freeze @@ -127,5 +134,8 @@ def browser USER_REGISTRATION_PIV_CAC_DISABLED = 'User Registration: piv cac disabled'.freeze USER_REGISTRATION_PIV_CAC_ENABLED = 'User Registration: piv cac enabled'.freeze USER_REGISTRATION_PIV_CAC_SETUP_VISIT = 'User Registration: piv cac setup visited'.freeze + WEBAUTHN_DELETED = 'WebAuthn Deleted'.freeze + WEBAUTHN_SETUP_VISIT = 'WebAuthn Setup Visited'.freeze + WEBAUTHN_SETUP_SUBMITTED = 'WebAuthn Setup Submitted'.freeze # rubocop:enable Metrics/LineLength end diff --git a/app/services/idv/generate_phone_confirmation_otp.rb b/app/services/idv/generate_phone_confirmation_otp.rb new file mode 100644 index 00000000000..8d2e1282f96 --- /dev/null +++ b/app/services/idv/generate_phone_confirmation_otp.rb @@ -0,0 +1,8 @@ +module Idv + class GeneratePhoneConfirmationOtp + def self.call + digits = Devise.direct_otp_length + SecureRandom.random_number(10**digits).to_s.rjust(digits, '0') + end + end +end diff --git a/app/services/idv/send_phone_confirmation_otp.rb b/app/services/idv/send_phone_confirmation_otp.rb new file mode 100644 index 00000000000..414bd143696 --- /dev/null +++ b/app/services/idv/send_phone_confirmation_otp.rb @@ -0,0 +1,96 @@ +module Idv + # Ignore instance variable assumption on @user_locked_out :reek:InstanceVariableAssumption + class SendPhoneConfirmationOtp + def initialize(user:, idv_session:, locale:) + @user = user + @idv_session = idv_session + @locale = locale + end + + def call + otp_rate_limiter.reset_count_and_otp_last_sent_at if user.decorate.no_longer_locked_out? + + return too_many_otp_sends_response if rate_limit_exceeded? + otp_rate_limiter.increment + return too_many_otp_sends_response if rate_limit_exceeded? + + send_otp + FormResponse.new(success: true, errors: {}, extra: extra_analytics_attributes) + end + + def user_locked_out? + @user_locked_out + end + + def phone + @phone ||= PhoneFormatter.format(idv_session.params[:phone]) + end + + private + + attr_reader :user, :idv_session, :locale + + def too_many_otp_sends_response + FormResponse.new( + success: false, + errors: {}, + extra: extra_analytics_attributes + ) + end + + def rate_limit_exceeded? + if otp_rate_limiter.exceeded_otp_send_limit? + otp_rate_limiter.lock_out_user + return @user_locked_out = true + end + false + end + + def otp_rate_limiter + @otp_rate_limiter ||= OtpRateLimiter.new(user: user, phone: phone) + end + + def send_otp + idv_session.phone_confirmation_otp = GeneratePhoneConfirmationOtp.call + idv_session.phone_confirmation_otp_sent_at = Time.zone.now.to_s + if otp_delivery_preference == :sms + send_sms_otp + elsif otp_delivery_preference == :voice + send_voice_otp + end + end + + def send_sms_otp + SmsOtpSenderJob.perform_later( + code: idv_session.phone_confirmation_otp, + phone: phone, + otp_created_at: idv_session.phone_confirmation_otp_sent_at, + message: 'jobs.sms_otp_sender_job.verify_message', + locale: locale + ) + end + + def send_voice_otp + VoiceOtpSenderJob.perform_later( + code: idv_session.phone_confirmation_otp, + phone: phone, + otp_created_at: idv_session.phone_confirmation_otp_sent_at, + locale: locale + ) + end + + def otp_delivery_preference + idv_session.phone_confirmation_otp_delivery_method.to_sym + end + + def extra_analytics_attributes + parsed_phone = Phonelib.parse(phone) + { + otp_delivery_preference: otp_delivery_preference, + country_code: parsed_phone.country, + area_code: parsed_phone.area_code, + rate_limit_exceeded: rate_limit_exceeded?, + } + end + end +end diff --git a/app/services/idv/session.rb b/app/services/idv/session.rb index bdc4c12ecf9..a6e59634c29 100644 --- a/app/services/idv/session.rb +++ b/app/services/idv/session.rb @@ -8,6 +8,9 @@ class Session params vendor_phone_confirmation user_phone_confirmation + phone_confirmation_otp_delivery_method + phone_confirmation_otp_sent_at + phone_confirmation_otp pii profile_confirmation profile_id diff --git a/app/services/marketing_site.rb b/app/services/marketing_site.rb index 5b763741968..7146d446018 100644 --- a/app/services/marketing_site.rb +++ b/app/services/marketing_site.rb @@ -26,6 +26,10 @@ def self.help_authentication_app_url URI.join(BASE_URL, locale_segment, 'help/signing-in/what-is-an-authentication-app/').to_s end + def self.help_hardware_security_key_url + URI.join(BASE_URL, locale_segment, 'help/signing-in/what-is-a-hardware-security-key/').to_s + end + def self.help_privacy_and_security_url URI.join( BASE_URL, diff --git a/app/services/openid_connect_redirector.rb b/app/services/openid_connect_redirector.rb deleted file mode 100644 index 4e5d1b0ebbf..00000000000 --- a/app/services/openid_connect_redirector.rb +++ /dev/null @@ -1,97 +0,0 @@ -class OpenidConnectRedirector - include ActionView::Helpers::TranslationHelper - - def self.from_request_url(request_url) - params = URIService.params(request_url) - - new( - redirect_uri: params[:redirect_uri], - service_provider: ServiceProvider.from_issuer(params[:client_id]), - state: params[:state] - ) - end - - def initialize(redirect_uri:, service_provider:, state:, errors: nil, error_attr: :redirect_uri) - @redirect_uri = redirect_uri - @service_provider = service_provider - @state = state - @errors = errors || ActiveModel::Errors.new(self) - @error_attr = error_attr - end - - def valid? - validate - errors.blank? - end - - def validate - validate_redirect_uri - validate_redirect_uri_matches_sp_redirect_uri - end - - def success_redirect_uri(code:) - URIService.add_params(validated_input_redirect_uri, code: code, state: state) - end - - def decline_redirect_uri - URIService.add_params( - validated_input_redirect_uri, - error: 'access_denied', - state: state - ) - end - - def error_redirect_uri - URIService.add_params( - validated_input_redirect_uri, - error: 'invalid_request', - error_description: errors.full_messages.join(' '), - state: state - ) - end - - def logout_redirect_uri - URIService.add_params(validated_input_redirect_uri, state: state) - end - - def validated_input_redirect_uri - redirect_uri if redirect_uri_matches_sp_redirect_uri? - end - - private - - attr_reader :redirect_uri, :service_provider, :state, :errors, :error_attr - - def validate_redirect_uri - _uri = URI(redirect_uri) - rescue ArgumentError, URI::InvalidURIError - errors.add(error_attr, t('openid_connect.authorization.errors.redirect_uri_invalid')) - end - - def validate_redirect_uri_matches_sp_redirect_uri - return if redirect_uri_matches_sp_redirect_uri? - errors.add(error_attr, t('openid_connect.authorization.errors.redirect_uri_no_match')) - end - - # rubocop:disable Metrics/AbcSize - # rubocop:disable Metrics/CyclomaticComplexity - # rubocop:disable Metrics/MethodLength - def redirect_uri_matches_sp_redirect_uri? - return unless redirect_uri.present? && service_provider.active? - parsed_redirect_uri = URI(redirect_uri) - service_provider.redirect_uris.any? do |sp_redirect_uri| - parsed_sp_redirect_uri = URI(sp_redirect_uri) - - parsed_redirect_uri.scheme == parsed_sp_redirect_uri.scheme && - parsed_redirect_uri.port == parsed_sp_redirect_uri.port && - parsed_redirect_uri.host == parsed_sp_redirect_uri.host && - parsed_redirect_uri.path.start_with?(parsed_sp_redirect_uri.path) - end - rescue URI::Error - false - end - - # rubocop:enable Metrics/AbcSize - # rubocop:enable Metrics/CyclomaticComplexity - # rubocop:enable Metrics/MethodLength -end diff --git a/app/services/otp_delivery_preference_updater.rb b/app/services/otp_delivery_preference_updater.rb index 15c74563a4a..67a2a79e0bf 100644 --- a/app/services/otp_delivery_preference_updater.rb +++ b/app/services/otp_delivery_preference_updater.rb @@ -16,16 +16,12 @@ def call def should_update_user? return false unless user - otp_delivery_preference_changed? && !idv_context? + otp_delivery_preference_changed? end def otp_delivery_preference_changed? return true if preference != user.otp_delivery_preference - phone_configuration = user.phone_configuration + phone_configuration = user.phone_configurations.first phone_configuration.present? && preference != phone_configuration.delivery_preference end - - def idv_context? - context == 'idv' - end end diff --git a/app/services/pii/cacher.rb b/app/services/pii/cacher.rb index a16c548ec61..1c9c4d048f6 100644 --- a/app/services/pii/cacher.rb +++ b/app/services/pii/cacher.rb @@ -38,6 +38,9 @@ def rotate_fingerprints(profile) def rotate_encrypted_attributes KeyRotator::AttributeEncryption.new(user).rotate + user.phone_configurations.each do |phone_configuration| + KeyRotator::AttributeEncryption.new(phone_configuration).rotate + end end def stale_fingerprints?(profile) @@ -49,7 +52,7 @@ def stale_email_fingerprint? end def stale_attributes? - user.stale_encrypted_phone? || user.stale_encrypted_email? || + user.phone_configurations.any?(&:stale_encrypted_phone?) || user.stale_encrypted_email? || user.stale_encrypted_otp_secret_key? end diff --git a/app/services/populate_phone_configurations_table.rb b/app/services/populate_phone_configurations_table.rb deleted file mode 100644 index a1c123da5ec..00000000000 --- a/app/services/populate_phone_configurations_table.rb +++ /dev/null @@ -1,41 +0,0 @@ -class PopulatePhoneConfigurationsTable - def initialize - @count = 0 - @total = 0 - end - - # :reek:DuplicateMethodCall - def call - # we don't have a uniqueness constraint in the database to let us blindly insert - # everything in a single SQL statement. So we have to load by batches and copy - # over. Much slower, but doesn't duplicate information. - User.in_batches(of: 1000) do |relation| - sleep(1) - process_batch(relation) - Rails.logger.info "#{@count} / #{@total}" - end - Rails.logger.info "Processed #{@count} user phone configurations" - end - - private - - # :reek:FeatureEnvy - def process_batch(relation) - User.transaction do - relation.each do |user| - @total += 1 - next if user.phone_configuration.present? || user.encrypted_phone.blank? - user.create_phone_configuration(phone_info_for_user(user)) - @count += 1 - end - end - end - - def phone_info_for_user(user) - { - encrypted_phone: user.encrypted_phone, - confirmed_at: user.phone_confirmed_at, - delivery_preference: user.otp_delivery_preference, - } - end -end diff --git a/app/services/remember_device_cookie.rb b/app/services/remember_device_cookie.rb index 28a916d39d3..ad1b6b5f663 100644 --- a/app/services/remember_device_cookie.rb +++ b/app/services/remember_device_cookie.rb @@ -46,6 +46,8 @@ def expired? end def user_has_changed_phone?(user) - user.phone_configuration&.confirmed_at.to_i > created_at.to_i + user.phone_configurations.any? do |phone_configuration| + phone_configuration.confirmed_at.to_i > created_at.to_i + end end end diff --git a/app/services/twilio_service.rb b/app/services/twilio_service.rb index fb866c21b61..1e72e04d662 100644 --- a/app/services/twilio_service.rb +++ b/app/services/twilio_service.rb @@ -7,6 +7,7 @@ class Utils end def initialize + @http_client = Twilio::HTTP::Client.new(timeout: Figaro.env.twilio_timeout.to_i) @client = if FeatureManagement.telephony_disabled? NullTwilioClient.new else @@ -41,13 +42,10 @@ def from_number private - attr_reader :client + attr_reader :client, :http_client def twilio_client - telephony_service.new( - TWILIO_SID, - TWILIO_AUTH_TOKEN - ) + telephony_service.new(TWILIO_SID, TWILIO_AUTH_TOKEN, nil, nil, @http_client) end def random_phone_number @@ -59,6 +57,8 @@ def sanitize_errors rescue Twilio::REST::RestError => error sanitize_phone_number(error.message) raise + rescue Faraday::TimeoutError + raise Twilio::REST::RestError.new('timeout', TwilioTimeoutResponse.new) end DIGITS_TO_PRESERVE = 5 @@ -72,5 +72,15 @@ def sanitize_phone_number(str) end end end + + class TwilioTimeoutResponse + def status_code + 4_815_162_342 + end + + def body + {} + end + end end end diff --git a/app/services/update_user.rb b/app/services/update_user.rb index 1cc07776765..ff67ac120f8 100644 --- a/app/services/update_user.rb +++ b/app/services/update_user.rb @@ -5,7 +5,7 @@ def initialize(user:, attributes:) end def call - result = user.update!(attributes) + result = user.update!(attributes.except(:phone, :phone_confirmed_at)) manage_phone_configuration result end @@ -15,7 +15,7 @@ def call attr_reader :user, :attributes def manage_phone_configuration - if user.phone_configuration.present? + if user.phone_configurations.any? update_phone_configuration else create_phone_configuration @@ -23,26 +23,20 @@ def manage_phone_configuration end def update_phone_configuration - configuration = user.phone_configuration - if phone_attributes[:phone].present? - configuration.update!(phone_attributes) - else - configuration.destroy - user.reload - end + user.phone_configurations.first.update!(phone_attributes) end def create_phone_configuration return if phone_attributes[:phone].blank? - user.create_phone_configuration(phone_attributes) + user.phone_configurations.create(phone_attributes) end def phone_attributes @phone_attributes ||= { - phone: attribute(:phone), - confirmed_at: attribute(:phone_confirmed_at), + phone: attributes[:phone], + confirmed_at: attributes[:phone_confirmed_at], delivery_preference: attribute(:otp_delivery_preference), - } + }.delete_if { |_, value| value.nil? } end # This returns the named attribute if it's included in the changes, even if diff --git a/app/validators/account_reset/granted_token_validator.rb b/app/validators/account_reset/granted_token_validator.rb new file mode 100644 index 00000000000..e60ad74064e --- /dev/null +++ b/app/validators/account_reset/granted_token_validator.rb @@ -0,0 +1,38 @@ +module AccountReset + module GrantedTokenValidator + extend ActiveSupport::Concern + + included do + validates :token, presence: { message: I18n.t('errors.account_reset.granted_token_missing') } + validate :token_exists, if: :token_present? + validate :token_not_expired, if: :token_present? + end + + private + + attr_reader :token + + def token_exists + return if account_reset_request + + errors.add(:token, I18n.t('errors.account_reset.granted_token_invalid')) + end + + def token_not_expired + return unless account_reset_request&.granted_token_expired? + errors.add(:token, I18n.t('errors.account_reset.granted_token_expired')) + end + + def token_present? + token.present? + end + + def account_reset_request + @account_reset_request ||= AccountResetRequest.find_by(granted_token: token) + end + + def user + account_reset_request&.user || AnonymousUser.new + end + end +end diff --git a/app/validators/redirect_uri_validator.rb b/app/validators/redirect_uri_validator.rb new file mode 100644 index 00000000000..a28c91816c8 --- /dev/null +++ b/app/validators/redirect_uri_validator.rb @@ -0,0 +1,32 @@ +module RedirectUriValidator + extend ActiveSupport::Concern + + included do + attr_reader :redirect_uri, :post_logout_redirect_uri, :service_provider + + validate :allowed_redirect_uri + end + + private + + def allowed_redirect_uri + return if any_registered_sp_redirect_uris_identical_to_the_requested_uri? + + errors.add(:redirect_uri, t('openid_connect.authorization.errors.redirect_uri_no_match')) + end + + def any_registered_sp_redirect_uris_identical_to_the_requested_uri? + service_provider.redirect_uris.any? do |sp_redirect_uri| + parsed_sp_redirect_uri = URI.parse(sp_redirect_uri) + + parsed_sp_redirect_uri == parsed_redirect_uri + end + rescue ArgumentError, URI::InvalidURIError + errors.add(:redirect_uri, t('openid_connect.authorization.errors.redirect_uri_invalid')) + end + + def parsed_redirect_uri + requested_uri = post_logout_redirect_uri || redirect_uri + URI.parse(requested_uri) + end +end diff --git a/app/views/account_recovery_setup/index.html.slim b/app/views/account_recovery_setup/index.html.slim index d0dc36506b2..4fb2892b005 100644 --- a/app/views/account_recovery_setup/index.html.slim +++ b/app/views/account_recovery_setup/index.html.slim @@ -9,9 +9,9 @@ p.mt-tiny.mb3 = @presenter.info url: two_factor_options_path) do |f| .mb3 fieldset.m0.p0.border-none. - legend.mb1.h4.serif.bold = @presenter.label + legend.mb2.serif.bold = @presenter.label - @presenter.options.each do |option| - label.btn-border.col-12.mb1 for="two_factor_options_form_selection_#{option.type}" + 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, 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 5a72212e4bb..f08b4c9c699 100644 --- a/app/views/account_reset/confirm_delete_account/show.html.slim +++ b/app/views/account_reset/confirm_delete_account/show.html.slim @@ -5,4 +5,5 @@ class: 'absolute top-n24 left-0 right-0 mx-auto') h3 = t('account_reset.confirm_delete_account.title') p == t('account_reset.confirm_delete_account.info', \ - email: email, link: link_to(t('account_reset.confirm_delete_account.link_text'), root_path)) + email: email, \ + link: link_to(t('account_reset.confirm_delete_account.link_text'), sign_up_email_path)) diff --git a/app/views/account_reset/delete_account/show.html.slim b/app/views/account_reset/delete_account/show.html.slim index 592457d5d52..0ad05055252 100644 --- a/app/views/account_reset/delete_account/show.html.slim +++ b/app/views/account_reset/delete_account/show.html.slim @@ -7,7 +7,7 @@ br h4.my2 = t('account_reset.delete_account.are_you_sure') = button_to t('account_reset.delete_account.delete_button'), \ - account_reset_delete_account_path(token: session[:granted_token]), method: :delete, \ + account_reset_delete_account_path, method: :delete, \ class: 'btn btn-red col-6 p2 rounded-lg border bw2 bg-lightest-red border-red border-box' br br diff --git a/app/views/accounts/_webauthn.html.slim b/app/views/accounts/_webauthn.html.slim new file mode 100644 index 00000000000..3e480dc8b39 --- /dev/null +++ b/app/views/accounts/_webauthn.html.slim @@ -0,0 +1,15 @@ +.clearfix.border-top.border-blue-light + .p2.col.col-12 + .col.col-6.bold + = t('account.index.webauthn') + .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 + .col.col-8.sm-6.truncate + = cfg.name + .col.col-4.sm-6.right-align + = button_to(t('account.index.webauthn_delete'), webauthn_setup_path(id: cfg.id), + method: :delete, class: 'btn btn-link') + .clearfix diff --git a/app/views/accounts/show.html.slim b/app/views/accounts/show.html.slim index acd5c7d55b2..ebceab75168 100644 --- a/app/views/accounts/show.html.slim +++ b/app/views/accounts/show.html.slim @@ -36,7 +36,7 @@ h1.hide = t('titles.account') = render 'account_item', name: t('account.index.phone'), - content: current_user.phone_configuration&.phone, + content: current_user.phone_configurations.first&.phone, path: manage_phone_path, action: @view_model.edit_action_partial @@ -45,6 +45,9 @@ h1.hide = t('titles.account') content: content_tag(:em, @view_model.totp_content), action: @view_model.totp_partial + - if FeatureManagement.webauthn_enabled? + = render 'webauthn' + - if current_user.piv_cac_available? = render 'account_item', name: t('account.index.piv_cac_card'), diff --git a/app/views/devise/mailer/confirmation_instructions.html.slim b/app/views/devise/mailer/confirmation_instructions.html.slim index dfe170fb1b1..dc924ad4b9f 100644 --- a/app/views/devise/mailer/confirmation_instructions.html.slim +++ b/app/views/devise/mailer/confirmation_instructions.html.slim @@ -31,4 +31,4 @@ table.hr th |   -p= t('mailer.confirmation_instructions.footer', confirmation_period: @confirmation_period) +p = t('mailer.confirmation_instructions.footer', confirmation_period: @confirmation_period) diff --git a/app/views/devise/mailer/reset_password_instructions.html.slim b/app/views/devise/mailer/reset_password_instructions.html.slim index 71674e9e400..dcda6aa5f37 100644 --- a/app/views/devise/mailer/reset_password_instructions.html.slim +++ b/app/views/devise/mailer/reset_password_instructions.html.slim @@ -30,4 +30,4 @@ table.hr th |   -p= t('mailer.reset_password.footer', expires: Devise.reset_password_within / 3600) +p = t('mailer.reset_password.footer', expires: Devise.reset_password_within / 3600) diff --git a/app/views/exception_notifier/_session.text.erb b/app/views/exception_notifier/_session.text.erb index 082443ef447..6560cec71f6 100644 --- a/app/views/exception_notifier/_session.text.erb +++ b/app/views/exception_notifier/_session.text.erb @@ -18,6 +18,8 @@ Session: <%= session %> <% user = @kontroller.analytics_user || AnonymousUser.new %> User UUID: <%= user.uuid %> -User's Country (based on phone): <%= Phonelib.parse(user.phone_configuration.phone).country %> +<% if user.phone_configurations.any? %> + User's Country (based on first phone): <%= Phonelib.parse(user.phone_configurations.first.phone).country %> +<% end %> Visitor ID: <%= @request.cookies['ahoy_visitor'] %> diff --git a/app/views/idv/otp_verification/show.html.slim b/app/views/idv/otp_verification/show.html.slim new file mode 100644 index 00000000000..997a4007232 --- /dev/null +++ b/app/views/idv/otp_verification/show.html.slim @@ -0,0 +1,28 @@ +- title t('titles.enter_2fa_code') + +h1.h3.my0 = t('devise.two_factor_authentication.header_text') + +p == @presenter.phone_number_message + += form_tag(:idv_otp_verification, method: :put, role: 'form', class: 'mt3') do + = label_tag 'code', \ + t('simple_form.required.html') + t('forms.two_factor.code'), \ + class: 'block bold' + .col-12.sm-col-5.mb2.sm-mb0.sm-mr-20p.inline-block + = text_field_tag(:code, '', value: @code, required: true, + autofocus: true, pattern: '[0-9]*', class: 'col-12 field monospace mfa', + 'aria-describedby': 'code-instructs', maxlength: Devise.direct_otp_length, + autocomplete: 'off', type: 'tel') + = submit_tag t('forms.buttons.submit.default'), class: 'btn btn-primary align-top sm-col-6 col-12' + br + br += button_to(t('links.two_factor_authentication.get_another_code'), idv_resend_otp_path, + method: :post, + class: 'btn btn-link btn-border ico ico-refresh text-decoration-none', + form_class: 'inline-block') + +p.mt4 = @presenter.update_phone_link + +.mt3.border-top + .mt1 + = link_to t('links.cancel'), idv_cancel_path diff --git a/app/views/idv/sessions/new.html.slim b/app/views/idv/sessions/new.html.slim index 1471f12cca6..174f18a49cf 100644 --- a/app/views/idv/sessions/new.html.slim +++ b/app/views/idv/sessions/new.html.slim @@ -3,7 +3,8 @@ h1.h3 = t('idv.titles.sessions') p = link_to t('links.access_help'), - 'https://login.gov/help/privacy-and-security/how-does-logingov-protect-my-data/' + 'https://login.gov/help/privacy-and-security/how-does-logingov-protect-my-data/', + target: :_blank = simple_form_for(@idv_form, url: idv_session_path, html: { autocomplete: 'off', method: :put, role: 'form' }) do |f| @@ -33,19 +34,22 @@ p = link_to t('links.access_help'), p = t('idv.messages.sessions.id_information_message') fieldset.m0.p0.border-none - = f.label :state_id_type, label: t('idv.form.state_id_type_label'), class: 'bold', - id: 'profile_state_id_type_label', required: true - - state_id_types.each do |state_id_type| - = f.label 'profile[state_id_type]', class: 'block mb1', - for: "profile_state_id_type_#{state_id_type[1]}" - .radio - = radio_button_tag 'profile[state_id_type]', state_id_type[1], - state_id_type[1] == 'drivers_license', - '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'), required: true - = f.input :address1, label: t('idv.form.address1'), required: true + .mb1 + = f.label :state_id_type, label: t('idv.form.state_id_type_label'), class: 'bold', + id: 'profile_state_id_type_label', required: true + - state_id_types.each do |state_id_type| + = f.label 'profile[state_id_type]', class: 'block mb1', + for: "profile_state_id_type_#{state_id_type[1]}" + .radio + = radio_button_tag 'profile[state_id_type]', state_id_type[1], + state_id_type[1] == 'drivers_license', + '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 :address1, label: t('idv.form.address1'), wrapper_html: { class: 'mb1' }, + required: true = f.input :address2, label: t('idv.form.address2') = f.input :city, label: t('idv.form.city'), required: true diff --git a/app/views/two_factor_authentication/options/index.html.slim b/app/views/two_factor_authentication/options/index.html.slim index a035884faae..2a10779a082 100644 --- a/app/views/two_factor_authentication/options/index.html.slim +++ b/app/views/two_factor_authentication/options/index.html.slim @@ -9,9 +9,9 @@ p.mt-tiny.mb3 = @presenter.info url: login_two_factor_options_path) do |f| .mb3 fieldset.m0.p0.border-none. - legend.mb1.h4.serif.bold = @presenter.label + legend.mb2.serif.bold = @presenter.label - @presenter.options.each do |option| - label.btn-border.col-12.mb1 for="two_factor_options_form_selection_#{option.type}" + 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, diff --git a/app/views/two_factor_authentication/totp_verification/show.html.slim b/app/views/two_factor_authentication/totp_verification/show.html.slim index 57c41fa030e..4acee106973 100644 --- a/app/views/two_factor_authentication/totp_verification/show.html.slim +++ b/app/views/two_factor_authentication/totp_verification/show.html.slim @@ -7,7 +7,7 @@ h1.h3.my0 = @presenter.header = label_tag 'code', t('simple_form.required.html') + t('forms.two_factor.code'), class: 'block bold' .col-12.sm-col-5.mb4.sm-mb0.sm-mr-20p.inline-block - = text_field_tag :code, '', required: true, autofocus: true, + = text_field_tag :code, '', value: @code, required: true, autofocus: true, pattern: '[0-9]*', class: 'col-12 field monospace mfa', type: 'tel', 'aria-describedby': 'code-instructs', maxlength: Devise.otp_length, autocomplete: 'off' = submit_tag 'Submit', class: 'btn btn-primary align-top' diff --git a/app/views/user_mailer/account_reset_granted.html.slim b/app/views/user_mailer/account_reset_granted.html.slim index e244672df0a..0aa95c92bb5 100644 --- a/app/views/user_mailer/account_reset_granted.html.slim +++ b/app/views/user_mailer/account_reset_granted.html.slim @@ -21,7 +21,7 @@ table.hr tr th |   -p= t('mailer.confirmation_instructions.footer', confirmation_period: '24 hours') -p== t('user_mailer.account_reset_granted.help', +p = t('mailer.confirmation_instructions.footer', confirmation_period: '24 hours') +p == t('user_mailer.account_reset_granted.help', cancel_account_reset: link_to(t('user_mailer.account_reset_granted.cancel_link_text'), account_reset_cancel_url(token: @token))) diff --git a/app/views/user_mailer/signup_with_your_email.html.slim b/app/views/user_mailer/signup_with_your_email.html.slim index 6dd1e27b865..d361aaa9c57 100644 --- a/app/views/user_mailer/signup_with_your_email.html.slim +++ b/app/views/user_mailer/signup_with_your_email.html.slim @@ -14,7 +14,7 @@ table.button.expanded.large.radius class: 'float-center', align: 'center' td.expander -p= link_to @root_url, @root_url, target: '_blank' +p = link_to @root_url, @root_url, target: '_blank' table.spacer tbody diff --git a/app/views/users/two_factor_authentication_setup/index.html.slim b/app/views/users/two_factor_authentication_setup/index.html.slim index 75ebf46d66f..e2a3a16605f 100644 --- a/app/views/users/two_factor_authentication_setup/index.html.slim +++ b/app/views/users/two_factor_authentication_setup/index.html.slim @@ -9,18 +9,18 @@ p.mt-tiny.mb3 = @presenter.info url: two_factor_options_path) do |f| .mb3 fieldset.m0.p0.border-none. - legend.mb1.h4.serif.bold = @presenter.label + legend.mb2.serif.bold = @presenter.label - @presenter.options.each do |option| - label.btn-border.col-12.mb1 for="two_factor_options_form_selection_#{option.type}" + 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, @two_factor_options_form.selected?(option.type)) span.indicator.mt-tiny span.blue.bold.fs-20p = option.label - .regular.gray-dark.fs-10p.mb-tiny = option.info + .regular.gray-dark.fs-10p.mt0.mb-tiny = option.info div - = f.button :submit, t('forms.buttons.continue'), class: 'sm-col-6 col-12 btn-wide mb3' + = f.button :submit, t('forms.buttons.continue'), class: 'sm-col-6 col-12 btn-wide mb1' = render 'shared/cancel', link: destroy_user_session_path diff --git a/app/views/users/webauthn_setup/new.html.slim b/app/views/users/webauthn_setup/new.html.slim new file mode 100644 index 00000000000..0d09bfe4baa --- /dev/null +++ b/app/views/users/webauthn_setup/new.html.slim @@ -0,0 +1,50 @@ +- title t('titles.totp_setup.new') +- help_link = link_to t('links.what_is_webauthn'), + MarketingSite.help_hardware_security_key_url, target: :_blank + +h1.h3.my0 = t('headings.webauthn_setup.new') +p.mt-tiny.mb3 = t('forms.webauthn_setup.intro_html', link: help_link) +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') + li.py2.border-top.hidden[id='webauthn_name'] + .mb2 + .mr1.inline-block.circle.circle-number.bg-blue.white + | 3 + #totp-label.inline-block.bold = t('forms.webauthn_setup.step_3') + .sm-col-9.sm-ml-28p + = form_tag(webauthn_setup_path, method: :patch, role: 'form', class: 'mb1') do + .clearfix.mxn1 + .col.col-6.sm-col-7.px1 + = hidden_field_tag :user_id, current_user.id, id: 'user_id' + = hidden_field_tag :user_email, current_user.email, id: 'user_email' + = hidden_field_tag :user_challenge, + '[' + user_session[:webauthn_challenge].split.join(',') + ']', id: 'user_challenge' + = hidden_field_tag :webauthn_id, '', id: 'webauthn_id' + = hidden_field_tag :webauthn_public_key, '', id: 'webauthn_public_key' + = hidden_field_tag :attestation_object, '', id: 'attestation_object' + = hidden_field_tag :client_data_json, '', id: 'client_data_json' + = text_field_tag :name, '', required: true, id: 'name', + class: 'block col-12 field monospace', size: 16, maxlength: 20, + 'aria-labelledby': 'totp-label' + .col.col-6.sm-col-5.px1 + = submit_tag t('forms.buttons.submit.default'), + class: 'col-12 btn btn-primary align-top' +.spinner[id='spinner'] + div + = image_tag(asset_url('spinner.gif'), + srcset: asset_url('spinner@2x.gif'), + height: 144, + width: 144, + alt: '') += render 'shared/cancel_or_back_to_options' + +== javascript_pack_tag 'clipboard' +== javascript_pack_tag 'webauthn-setup' diff --git a/bin/release b/bin/release index 76ccacbb58e..ed86df81b7d 100755 --- a/bin/release +++ b/bin/release @@ -88,6 +88,9 @@ def clone_identity_devops_repo run "mkdir login-dot-gov" Dir.chdir "#{ENV['HOME']}/login-dot-gov" do run "git clone git@github.com:18F/identity-devops.git" + Dir.chdir "identity-devops" do + run "bundle install" + end end end end diff --git a/certs/sp/hud_prod.crt b/certs/sp/hud_prod.crt new file mode 100644 index 00000000000..a29c37bb2d9 --- /dev/null +++ b/certs/sp/hud_prod.crt @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIEdzCCA1+gAwIBAgIJAJBI9E3Tst0dMA0GCSqGSIb3DQEBCwUAMIHRMQswCQYD +VQQGEwJVUzEdMBsGA1UECAwURGlzdHJpY3Qgb2YgQ29sdW1iaWExEzARBgNVBAcM +Cldhc2hpbmd0b24xNzA1BgNVBAoMLlVTIERlcGFydG1lbnQgb2YgSG91c2luZyBh +bmQgVXJiYW4gRGV2ZWxvcG1lbnQxDTALBgNVBAsMBEZIRU8xJTAjBgNVBAMMHFNl +Y3Rpb24gMyBPcHBvcnR1bml0eSBQb3J0YWwxHzAdBgkqhkiG9w0BCQEWEHNlY3Rp +b24zQGh1ZC5nb3YwHhcNMTgwNzE3MTgwMzM2WhcNMTkwNzE3MTgwMzM2WjCB0TEL +MAkGA1UEBhMCVVMxHTAbBgNVBAgMFERpc3RyaWN0IG9mIENvbHVtYmlhMRMwEQYD +VQQHDApXYXNoaW5ndG9uMTcwNQYDVQQKDC5VUyBEZXBhcnRtZW50IG9mIEhvdXNp +bmcgYW5kIFVyYmFuIERldmVsb3BtZW50MQ0wCwYDVQQLDARGSEVPMSUwIwYDVQQD +DBxTZWN0aW9uIDMgT3Bwb3J0dW5pdHkgUG9ydGFsMR8wHQYJKoZIhvcNAQkBFhBz +ZWN0aW9uM0BodWQuZ292MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA +np5l3Xj/UOndOfwDNDr3Ip/lmDSULOJq9Vdb3ZCBNkmjunhlG9Naiw97qgTRfMAt +ZYJz+LsdR0kdyzTI73KtL6D43yiqFoMu9+08pOR/6UHUeOb5gluMxH6/AgZKl7Ki +vAU+DuUxi7NO4WHie2btQuSZ2gqa+M/fqp2qmQC8+nCKv79ns6m/DZjCDQGybQt9 +WUNERInbiUSs+TrqFtFO5avhw1jVasfFpAujPmb+FyGfcUMTHMwbfj4HCVEUcyod +YJQgfq1JXy5jc1oODMITA3Sb1/W1IuodOP4ispaUtUEmUdoL+lUuhStQ8KlnHc5G +w98Y7isIb2eWOH/FhczntQIDAQABo1AwTjAdBgNVHQ4EFgQUjx4ELkj/4JHXbh36 +LyRFJMoDOKUwHwYDVR0jBBgwFoAUjx4ELkj/4JHXbh36LyRFJMoDOKUwDAYDVR0T +BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEATnq+eBGOdXUn6gxnZ8LwPGI8UZry +JkW3/q5M/JtQx7iPpn8pT8pnXE0EKypC1YT42T0YHrULCa3vh7e1loptjfbVa9IY +qe3xhlZPZB3RfHdfQTr4Oj+28whtdV1e9Tm4spy08V7RBcR6wcxLQlIkU9P5VXQN +RklArkgZ+VqolmdOAZiK6sSgFhS0TDVjgn0//sgJUieCPGABelhb7ZMQoIMqkz6D +M/uVzV44FAQgXWzHbgvW036RRYLfvUJmyl0tqcRp8y4GnZy3sTOScV2sQfYJn7Mx +IoQkGvV+riTlHmZVTmAwslcfdXn3eZDm5T+9TrUu3gDvZ7CXsh3Ts9j6LQ== +-----END CERTIFICATE----- diff --git a/config/agencies.yml b/config/agencies.yml index 50edd768f5d..360b9bd796c 100644 --- a/config/agencies.yml +++ b/config/agencies.yml @@ -1,6 +1,6 @@ test: 1: - name: 'CBP' + name: 'DHS' 2: name: 'OPM' 3: @@ -12,7 +12,7 @@ test: 6: name: 'DOT' 7: - name: 'DHS' + name: 'USSS' 8: name: 'DOD' 9: @@ -21,10 +21,12 @@ test: name: 'DOE' 11: name: 'USFS' + 12: + name: 'HUD' development: 1: - name: 'CBP' + name: 'DHS' 2: name: 'OPM' 3: @@ -45,10 +47,12 @@ development: name: 'DOE' 11: name: 'USFS' + 12: + name: 'HUD' production: 1: - name: 'CBP' + name: 'DHS' 2: name: 'OPM' 3: @@ -69,3 +73,5 @@ production: name: 'DOE' 11: name: 'USFS' + 12: + name: 'HUD' diff --git a/config/application.rb b/config/application.rb index 1f24851f92d..dcdcb81eed2 100644 --- a/config/application.rb +++ b/config/application.rb @@ -60,7 +60,5 @@ class Application < Rails::Application Rack::TwilioWebhookAuthentication, Figaro.env.twilio_auth_token, '/api/voice/otp' ) - - config.middleware.use Rack::Timeout, service_timeout: Figaro.env.service_timeout.to_i end end diff --git a/config/application.yml.example b/config/application.yml.example index 77a0f3c89d9..ac959f5d3cd 100644 --- a/config/application.yml.example +++ b/config/application.yml.example @@ -48,7 +48,10 @@ queue_adapter_weights: '{"inline": 1}' recovery_code_length: '4' # How long (in seconds) to wait for requests before dropping them (via the rack_timeout gem). -service_timeout: '15' +# Note that the key name must be capitalized because the gem looks for +# ENV['RACK_TIMEOUT_SERVICE_TIMEOUT'], and Figaro does not automatically make lowercase keys +# available to ENV as uppercase keys. +RACK_TIMEOUT_SERVICE_TIMEOUT: '15' # Set the number of seconds before the session times out that the timeout # warning should appear. @@ -67,6 +70,7 @@ use_dashboard_service_providers: 'false' dashboard_url: 'https://dashboard.demo.login.gov' valid_authn_contexts: '["http://idmanagement.gov/ns/assurance/loa/1", "http://idmanagement.gov/ns/assurance/loa/3"]' +twilio_timeout: '5' usps_upload_sftp_timeout: '5' development: @@ -148,6 +152,7 @@ development: programmable_sms_countries: 'US,CA,MX' proofer_mock_fallback: 'true' rack_mini_profiler: 'off' + RACK_TIMEOUT_SERVICE_TIMEOUT: '30' reauthn_window: '120' recaptcha_enabled_percent: '0' recaptcha_site_key: 'key1' @@ -167,7 +172,6 @@ development: saml_secret_rotation_secret_key_password: scrypt_cost: '10000$8$1$' # These values are in hex for N, r, and p. secret_key_base: 'development_secret_key_base' - service_timeout: '30' session_encryption_key: '27bad3c25711099429c1afdfd1890910f3b59f5a4faec1c85e945cb8b02b02f261ba501d99cfbb4fab394e0102de6fecf8ffe260f322f610db3e96b2a775c120' session_timeout_in_minutes: '15' telephony_disabled: 'true' @@ -188,6 +192,7 @@ development: usps_upload_sftp_username: 'brady' usps_upload_sftp_password: 'test' usps_upload_token: '123ABC' + webauthn_enabled: 'true' # These values serve as defaults for all production-like environments, which # includes *.identitysandbox.gov and *.login.gov. @@ -299,6 +304,7 @@ production: usps_upload_sftp_username: usps_upload_sftp_password: usps_upload_token: + webauthn_enabled: 'false' test: aamva_cert_enabled: 'true' @@ -414,3 +420,4 @@ test: usps_upload_sftp_username: 'user' usps_upload_sftp_password: 'pass' usps_upload_token: 'test_token' + webauthn_enabled: 'true' diff --git a/config/environments/test.rb b/config/environments/test.rb index 9d3896121e2..9f56c86ad2a 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -35,7 +35,7 @@ Bullet.bullet_logger = true Bullet.raise = true Bullet.add_whitelist( - type: :n_plus_one_query, class_name: 'User', association: :phone_configuration + type: :n_plus_one_query, class_name: 'User', association: :phone_configurations ) end diff --git a/config/initializers/figaro.rb b/config/initializers/figaro.rb index 268bd73444f..92533f3f513 100644 --- a/config/initializers/figaro.rb +++ b/config/initializers/figaro.rb @@ -31,6 +31,7 @@ 'password_strength_enabled', 'programmable_sms_countries', 'queue_health_check_dead_interval_seconds', + 'RACK_TIMEOUT_SERVICE_TIMEOUT', 'reauthn_window', 'recovery_code_length', 'redis_url', @@ -41,7 +42,6 @@ 'saml_passphrase', 'scrypt_cost', 'secret_key_base', - 'service_timeout', 'session_encryption_key', 'session_timeout_in_minutes', 'twilio_numbers', @@ -49,6 +49,7 @@ 'twilio_auth_token', 'twilio_record_voice', 'twilio_messaging_service_sid', + 'twilio_timeout', 'use_kms', 'valid_authn_contexts' ) diff --git a/config/locales/account/en.yml b/config/locales/account/en.yml index 96ddbee1cbb..17476875454 100644 --- a/config/locales/account/en.yml +++ b/config/locales/account/en.yml @@ -25,6 +25,9 @@ en: instructions: Your account requires a secret code to be verified. reactivate_button: Enter the code you received via US mail success: Your account has been verified. + webauthn: Hardware security key + webauthn_add: "+ Add hardware security key" + webauthn_delete: Remove items: delete_your_account: Delete your account personal_key: Personal key diff --git a/config/locales/account/es.yml b/config/locales/account/es.yml index 239124a01dc..b32a03c4e7d 100644 --- a/config/locales/account/es.yml +++ b/config/locales/account/es.yml @@ -25,6 +25,9 @@ es: instructions: Su cuenta requiere que un código secreto sea verificado. reactivate_button: Ingrese el código que recibió por correo postal. success: Su cuenta ha sido verificada. + webauthn: Clave de seguridad de hardware + webauthn_add: "+ Agregar clave de seguridad de hardware" + webauthn_delete: Retirar items: delete_your_account: Eliminar su cuenta personal_key: Clave personal diff --git a/config/locales/account/fr.yml b/config/locales/account/fr.yml index 5df241818f7..1212575cb64 100644 --- a/config/locales/account/fr.yml +++ b/config/locales/account/fr.yml @@ -27,6 +27,9 @@ 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_delete: Retirer items: delete_your_account: Supprimer votre compte personal_key: Clé personnelle diff --git a/config/locales/devise/en.yml b/config/locales/devise/en.yml index 2f25bd393b2..2ed967d24f9 100644 --- a/config/locales/devise/en.yml +++ b/config/locales/devise/en.yml @@ -73,8 +73,6 @@ en: account_reset: cancel_link: Cancel your request link: deleting your account - link_expired: The link to delete your login.gov account has expired. Please - create another request to delete 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} @@ -131,10 +129,10 @@ en: 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. + without providing a phone number piv_cac: Government employees - piv_cac_info: Use your PIV/CAC card to secure your account. + 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. + sms_info: Get your security code via text message / SMS voice: Phone call - voice_info: Get your security code via 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 ab6d32c6982..005161c8b43 100644 --- a/config/locales/devise/es.yml +++ b/config/locales/devise/es.yml @@ -75,8 +75,6 @@ es: account_reset: cancel_link: Cancelar su solicitud link: eliminando su cuenta - link_expired: El enlace para eliminar su cuenta de login.gov ha caducado. - Crea otra solicitud para eliminar tu 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} @@ -130,10 +128,10 @@ es: 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. + 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. + 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: 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. + 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 07d87cb707b..6e186b1d2eb 100644 --- a/config/locales/devise/fr.yml +++ b/config/locales/devise/fr.yml @@ -81,8 +81,6 @@ fr: account_reset: cancel_link: Annuler votre demande link: supprimer votre compte - link_expired: Le lien pour supprimer votre compte login.gov a expiré. Veuillez - créer une autre demande pour 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. @@ -139,10 +137,10 @@ fr: 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. + 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. + 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. + voice_info: Obtenez votre code de sécurité par appel téléphonique diff --git a/config/locales/errors/en.yml b/config/locales/errors/en.yml index 0ec588520c6..aab2f394d27 100644 --- a/config/locales/errors/en.yml +++ b/config/locales/errors/en.yml @@ -4,6 +4,12 @@ en: account_reset: cancel_token_invalid: cancel token is invalid cancel_token_missing: cancel token is missing + granted_token_expired: The link to delete your login.gov account has expired. + Please create another request to delete your account. + granted_token_invalid: The link to delete your login.gov account is invalid. + Please try clicking the link in your email again. + granted_token_missing: The link to delete your login.gov account is invalid. + Please try clicking the link in your email again. confirm_password_incorrect: Incorrect password. invalid_authenticity_token: Oops, something went wrong. Please try again. invalid_totp: Invalid code. Please try again. @@ -38,9 +44,15 @@ en: phone_unsupported: Sorry, we are unable to send SMS at this time. Please try the phone call option below, or use your personal key. twilio_inbound_sms_invalid: The inbound Twilio SMS message failed validation. + twilio_timeout: The server took too long to respond. Please try again. unauthorized_authn_context: Unauthorized authentication context unauthorized_nameid_format: Unauthorized nameID format unauthorized_service_provider: Unauthorized Service Provider usps_otp_expired: Your confirmation code has expired. Please request another letter for a new code. 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 + try again. + unique_name: That name is already taken. Please choose a different name. diff --git a/config/locales/errors/es.yml b/config/locales/errors/es.yml index 303a54eaa22..45e05e16ae1 100644 --- a/config/locales/errors/es.yml +++ b/config/locales/errors/es.yml @@ -4,6 +4,10 @@ es: account_reset: cancel_token_invalid: NOT TRANSLATED YET cancel_token_missing: NOT TRANSLATED YET + granted_token_expired: El enlace para eliminar su cuenta de login.gov ha caducado. + Crea otra solicitud para eliminar tu cuenta. + granted_token_invalid: NOT TRANSLATED YET + granted_token_missing: NOT TRANSLATED YET confirm_password_incorrect: La contraseña es incorrecta. invalid_authenticity_token: "¡Oops! Algo salió mal. Inténtelo de nuevo." invalid_totp: El código es inválido. Vuelva a intentarlo. @@ -36,8 +40,14 @@ es: phone_unsupported: Lo sentimos, no podemos enviar SMS en este momento. Pruebe la opción de llamada telefónica a continuación o use su clave personal. twilio_inbound_sms_invalid: El mensaje de Twilio SMS de entrada falló la validación. + twilio_timeout: NOT TRANSLATED YET unauthorized_authn_context: Contexto de autenticación no autorizado unauthorized_nameid_format: NOT TRANSLATED YET unauthorized_service_provider: Proveedor de servicio no autorizado usps_otp_expired: NOT TRANSLATED YET weak_password: Su contraseña no es suficientemente segura. %{feedback} + webauthn_setup: + delete_last: Lo sentimos, no puedes eliminar tu última opción de MFA. + general_error: Hubo un error al agregar su clave de seguridad de hardware. Inténtalo + de nuevo. + unique_name: El nombre ya fue escogido. Por favor, elija un nombre diferente. diff --git a/config/locales/errors/fr.yml b/config/locales/errors/fr.yml index 038d0fd5486..5de9f9da324 100644 --- a/config/locales/errors/fr.yml +++ b/config/locales/errors/fr.yml @@ -4,6 +4,10 @@ fr: account_reset: cancel_token_invalid: NOT TRANSLATED YET cancel_token_missing: NOT TRANSLATED YET + granted_token_expired: Le lien pour supprimer votre compte login.gov a expiré. + Veuillez créer une autre demande pour supprimer votre compte. + granted_token_invalid: NOT TRANSLATED YET + granted_token_missing: NOT TRANSLATED YET confirm_password_incorrect: Mot de passe incorrect. invalid_authenticity_token: Oups, une erreur s'est produite. Veuillez essayer de nouveau. @@ -38,8 +42,14 @@ fr: le moment. S'il vous plaît essayez l'option d'appel téléphonique ci-dessous, ou utilisez votre clé personnelle. twilio_inbound_sms_invalid: Le message SMS Twilio entrant a échoué à la validation. + twilio_timeout: NOT TRANSLATED YET unauthorized_authn_context: Contexte d'authentification non autorisé unauthorized_nameid_format: NOT TRANSLATED YET unauthorized_service_provider: Fournisseur de service non autorisé usps_otp_expired: NOT TRANSLATED YET weak_password: Votre mot de passe n'est pas assez fort. %{feedback} + 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. + unique_name: Ce nom est déjà pris. Veuillez choisir un autre nom. diff --git a/config/locales/forms/en.yml b/config/locales/forms/en.yml index 2178aa5f29f..e4d16c50ce2 100644 --- a/config/locales/forms/en.yml +++ b/config/locales/forms/en.yml @@ -84,3 +84,9 @@ en: name: Confirmation code submit: Confirm account title: Confirm your account + webauthn_setup: + intro_html: When you sign in, you can use your hardware security key. %{link} + step_1: Insert your Security Key in your computer's USB port or connect it with + a USB cable. + step_2: Once connected, tap the button or gold disk if your key has one of them. + step_3: Enter a name for your security key diff --git a/config/locales/forms/es.yml b/config/locales/forms/es.yml index 71858c29cfe..30d79b31caa 100644 --- a/config/locales/forms/es.yml +++ b/config/locales/forms/es.yml @@ -84,3 +84,11 @@ es: name: Código de confirmación submit: Confirmar cuenta title: Confirme su cuenta + webauthn_setup: + intro_html: Cuando inicie sesión, puede usar su clave de seguridad de hardware. + %{link} + step_1: Inserte su clave de seguridad en el puerto USB de su computadora o conéctelo + con un cable USB. + step_2: Una vez conectado, toca el botón o el disco de oro si tu llave tiene + uno de ellos. + step_3: Ingrese un nombre para su clave de seguridad diff --git a/config/locales/forms/fr.yml b/config/locales/forms/fr.yml index 8b675d3331f..2e55658e0c5 100644 --- a/config/locales/forms/fr.yml +++ b/config/locales/forms/fr.yml @@ -88,3 +88,11 @@ fr: name: Code de confirmation submit: Confirmer le compte title: Confirmez votre compte + webauthn_setup: + intro_html: Lorsque vous vous connectez, vous pouvez utiliser votre clé de sécurité + matérielle. %{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 + clé en possède un. + step_3: Entrez un nom pour votre clé de sécurité diff --git a/config/locales/headings/en.yml b/config/locales/headings/en.yml index 93f1e4aa4c5..5f89dd03125 100644 --- a/config/locales/headings/en.yml +++ b/config/locales/headings/en.yml @@ -54,3 +54,5 @@ en: totp_setup: new: Enable an authentication app verify_email: Check your email + webauthn_setup: + new: Register your Hardware Security Key diff --git a/config/locales/headings/es.yml b/config/locales/headings/es.yml index a9211d8eae1..a6d77419541 100644 --- a/config/locales/headings/es.yml +++ b/config/locales/headings/es.yml @@ -54,3 +54,5 @@ es: totp_setup: new: Permita una app de autenticación verify_email: Revise su email + webauthn_setup: + new: Registre su clave de seguridad de hardware diff --git a/config/locales/headings/fr.yml b/config/locales/headings/fr.yml index 744e33d2600..cd236014104 100644 --- a/config/locales/headings/fr.yml +++ b/config/locales/headings/fr.yml @@ -54,3 +54,5 @@ fr: totp_setup: new: Activer une application d'authentification verify_email: Consultez vos courriels + webauthn_setup: + new: Enregistrez votre clé de sécurité matérielle diff --git a/config/locales/links/en.yml b/config/locales/links/en.yml index 6836a1fc029..4d334021e03 100644 --- a/config/locales/links/en.yml +++ b/config/locales/links/en.yml @@ -1,7 +1,7 @@ --- en: links: - access_help: Protect your data. + access_help: How login.gov protects your data. account: reactivate: with_key: I have my key @@ -26,3 +26,4 @@ en: app_option: Use an authentication application instead. get_another_code: Get another code what_is_totp: What is an authentication app? + what_is_webauthn: What is a hardware security key? diff --git a/config/locales/links/es.yml b/config/locales/links/es.yml index 953cf4703be..6bb67395759 100644 --- a/config/locales/links/es.yml +++ b/config/locales/links/es.yml @@ -1,7 +1,7 @@ --- es: links: - access_help: Protege tus datos. + access_help: Cómo login.gov protege sus datos. account: reactivate: with_key: Tengo mi clave @@ -26,3 +26,4 @@ es: app_option: Use una aplicación de autenticación en su lugar. get_another_code: Obtener otro código what_is_totp: "¿Qué es una app de autenticación?" + what_is_webauthn: "¿Qué es una clave de seguridad de hardware?" diff --git a/config/locales/links/fr.yml b/config/locales/links/fr.yml index fb07164119a..274cd199b08 100644 --- a/config/locales/links/fr.yml +++ b/config/locales/links/fr.yml @@ -1,7 +1,7 @@ --- fr: links: - access_help: Protégez vos données. + access_help: Comment login.gov protège vos données. account: reactivate: with_key: J'ai ma clé @@ -26,3 +26,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? diff --git a/config/locales/notices/en.yml b/config/locales/notices/en.yml index 7bf28b749f2..72abeff218c 100644 --- a/config/locales/notices/en.yml +++ b/config/locales/notices/en.yml @@ -45,5 +45,7 @@ en: use_diff_email: link: use a different email address text_html: Or, %{link} + webauthn_added: You added a hardware security key. + webauthn_deleted: You deleted a hardware security key. session_timedout: We signed you out. For your security, %{app} ends your session when you haven’t moved to a new page for %{minutes} minutes. diff --git a/config/locales/notices/es.yml b/config/locales/notices/es.yml index f7723b6ffe6..d19462fb9ba 100644 --- a/config/locales/notices/es.yml +++ b/config/locales/notices/es.yml @@ -45,5 +45,7 @@ es: use_diff_email: link: use un email diferente text_html: O %{link} + webauthn_added: Agregaste una clave de seguridad de hardware. + webauthn_deleted: Has eliminado una clave de seguridad de hardware. session_timedout: Hemos terminado su sesión. Para su seguridad, %{app} cierra su sesión cuando usted no pasa a una nueva página durante %{minutes} minutos. diff --git a/config/locales/notices/fr.yml b/config/locales/notices/fr.yml index bb406748314..3a39856f34b 100644 --- a/config/locales/notices/fr.yml +++ b/config/locales/notices/fr.yml @@ -47,6 +47,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. 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/routes.rb b/config/routes.rb index d4e78b4bad5..adc12757ff3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -123,6 +123,12 @@ get '/present_piv_cac' => 'users/piv_cac_authentication_setup#redirect_to_piv_cac_service', as: :redirect_to_piv_cac_service end + if FeatureManagement.webauthn_enabled? + get '/webauthn_setup' => 'users/webauthn_setup#new', as: :webauthn_setup + patch '/webauthn_setup' => 'users/webauthn_setup#confirm' + delete '/webauthn_setup' => 'users/webauthn_setup#delete' + end + delete '/authenticator_setup' => 'users/totp_setup#disable', as: :disable_totp get '/authenticator_setup' => 'users/totp_setup#new' patch '/authenticator_setup' => 'users/totp_setup#confirm' @@ -188,6 +194,9 @@ 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 + put '/phone_confirmation' => 'otp_verification#update', as: :nil get '/review' => 'review#new' put '/review' => 'review#create' get '/session' => 'sessions#new' diff --git a/config/service_providers.yml b/config/service_providers.yml index 915a326fa9b..7ec97a5db43 100644 --- a/config/service_providers.yml +++ b/config/service_providers.yml @@ -48,6 +48,7 @@ test: 'urn:gov:gsa:openidconnect:test': redirect_uris: - 'gov.gsa.openidconnect.test://result' + - 'gov.gsa.openidconnect.test://result/logout' cert: 'saml_test_sp' friendly_name: 'Example iOS App' agency: '18F' @@ -58,7 +59,7 @@ test: 'urn:gov:gsa:openidconnect:sp:server': agency_id: 2 redirect_uris: - - 'http://localhost:7654/' + - 'http://localhost:7654/auth/result' - 'https://example.com' cert: 'saml_test_sp' friendly_name: 'Test SP' @@ -779,3 +780,30 @@ production: - ssn - phone restrict_to_deploy_env: 'prod' + + # CBP I-94 + 'urn:gov:dhs.cbp.pspd.i94:openidconnect:prod:app': + agency_id: 1 + uuid_priority: 30 + friendly_name: 'CBP I-94' + agency: 'DHS' + logo: 'cbp.png' + redirect_uris: + - 'gov.dhs.cbp.pspd.i94.user.prod://result' + - 'gov.dhs.cbp.pspd.i94.user.prod://result/logout' + restrict_to_deploy_env: 'prod' + + # HUD Section 3 Opportunity Portal + 'urn:gov:gsa:openidconnect.profiles:sp:sso:HUD_FHEO:Section3OpportunityPortal': + agency_id: 12 + friendly_name: 'HUD Section 3 Opportunity Portal' + agency: 'HUD' + logo: 'hud.png' + return_to_sp_url: 'https://hudapps.hud.gov/OpportunityPortal' + redirect_uris: + - 'https://hudapps.hud.gov/OpportunityPortal/openid_connect_login' + - 'https://hudapps.hud.gov/OpportunityPortal/signedOut.action' + cert: 'hud_prod' + attribute_bundle: + - email + restrict_to_deploy_env: 'prod' diff --git a/db/migrate/20180724154947_drop_personal_key_columns_from_user.rb b/db/migrate/20180724154947_drop_personal_key_columns_from_user.rb new file mode 100644 index 00000000000..32e9337f9af --- /dev/null +++ b/db/migrate/20180724154947_drop_personal_key_columns_from_user.rb @@ -0,0 +1,17 @@ +class DropPersonalKeyColumnsFromUser < ActiveRecord::Migration[5.1] + def up + safety_assured do + remove_column :users, :recovery_code + remove_column :users, :encryption_key + remove_column :users, :recovery_salt + remove_column :users, :recovery_cost + end + end + + def down + add_column :users, :recovery_code, :string + add_column :users, :encryption_key, :string + add_column :users, :recovery_salt, :string + add_column :users, :recovery_cost, :string + end +end diff --git a/db/migrate/20180827225542_create_webauthn_configurations_table.rb b/db/migrate/20180827225542_create_webauthn_configurations_table.rb new file mode 100644 index 00000000000..7d5cac532f5 --- /dev/null +++ b/db/migrate/20180827225542_create_webauthn_configurations_table.rb @@ -0,0 +1,11 @@ +class CreateWebauthnConfigurationsTable < ActiveRecord::Migration[5.1] + def change + create_table :webauthn_configurations do |t| + t.references :user, null: false + t.string :name, null: false + t.text :credential_id, null: false + t.text :credential_public_key, null: false + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 32d03f67203..410c606a561 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: 20180728122856) do +ActiveRecord::Schema.define(version: 20180827225542) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -212,11 +212,7 @@ t.datetime "direct_otp_sent_at" t.datetime "idv_attempted_at" t.integer "idv_attempts", default: 0 - t.string "recovery_code" - t.string "encryption_key" t.string "unique_session_id" - t.string "recovery_salt" - t.string "recovery_cost" t.string "email_fingerprint", default: "", null: false t.text "encrypted_email", default: "", null: false t.string "attribute_cost" @@ -254,5 +250,15 @@ t.datetime "updated_at", null: false end + create_table "webauthn_configurations", force: :cascade do |t| + t.bigint "user_id", null: false + t.string "name", null: false + t.text "credential_id", null: false + t.text "credential_public_key", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_webauthn_configurations_on_user_id" + end + add_foreign_key "events", "users" end diff --git a/lib/feature_management.rb b/lib/feature_management.rb index 022665287d8..4ca5caf17e5 100644 --- a/lib/feature_management.rb +++ b/lib/feature_management.rb @@ -105,4 +105,8 @@ def self.disallow_all_web_crawlers? def self.account_reset_enabled? Figaro.env.account_reset_enabled != 'false' # if value not set it defaults to enabled end + + def self.webauthn_enabled? + Figaro.env.webauthn_enabled == 'true' + end end diff --git a/lib/tasks/create_test_accounts.rb b/lib/tasks/create_test_accounts.rb index 91ae6df50c6..aa2ac0ae785 100644 --- a/lib/tasks/create_test_accounts.rb +++ b/lib/tasks/create_test_accounts.rb @@ -15,12 +15,10 @@ def create_account(email: 'joe.smith@email.com', password: 'salty pickles', mfa_ user = User.create!(email: email) user.skip_confirmation! user.reset_password(password, password) - user.phone = mfa_phone || phone - user.phone_confirmed_at = Time.zone.now user.save! - user.create_phone_configuration( + user.phone_configurations.create( phone: mfa_phone || phone, - confirmed_at: user.phone_confirmed_at, + confirmed_at: Time.zone.now, delivery_preference: user.otp_delivery_preference ) Event.create(user_id: user.id, event_type: :account_created) diff --git a/lib/tasks/dev.rake b/lib/tasks/dev.rake index 82873dce3bc..50b78ae7577 100644 --- a/lib/tasks/dev.rake +++ b/lib/tasks/dev.rake @@ -9,6 +9,11 @@ namespace :dev do end end + ee = EncryptedAttribute.new_from_decrypted('totp@test.com') + User.find_or_create_by!(email_fingerprint: ee.fingerprint) do |user| + setup_totp_user(user, ee: ee, pw: pw) + end + loa3_user = User.find_by(email_fingerprint: fingerprint('test2@test.com')) profile = Profile.new(user: loa3_user) pii = Pii::Attributes.new_from_hash( @@ -83,9 +88,15 @@ namespace :dev do user.encrypted_email = args[:ee].encrypted user.skip_confirmation! user.reset_password(args[:pw], args[:pw]) - user.phone = format('+1 (415) 555-%04d', args[:num]) - user.phone_confirmed_at = Time.zone.now - create_phone_configuration_for(user) + user.phone_configurations.create(phone_configuration_data(user, args)) + Event.create(user_id: user.id, event_type: :account_created) + end + + def setup_totp_user(user, args) + user.encrypted_email = args[:ee].encrypted + user.skip_confirmation! + user.reset_password(args[:pw], args[:pw]) + user.otp_secret_key = ROTP::Base32.random_base32 Event.create(user_id: user.id, event_type: :account_created) end @@ -93,11 +104,11 @@ namespace :dev do Pii::Fingerprinter.fingerprint(email) end - def create_phone_configuration_for(user) - user.create_phone_configuration( - phone: user.phone, - confirmed_at: user.phone_confirmed_at, - delivery_preference: user.otp_delivery_preference - ) + def phone_configuration_data(user, args) + { + delivery_preference: user.otp_delivery_preference, + phone: format('+1 (415) 555-%04d', args[:num]), + confirmed_at: Time.zone.now, + } end end diff --git a/lib/tasks/migrate_phone_configurations.rake b/lib/tasks/migrate_phone_configurations.rake deleted file mode 100644 index f857e0e96f2..00000000000 --- a/lib/tasks/migrate_phone_configurations.rake +++ /dev/null @@ -1,7 +0,0 @@ -namespace :adhoc do - desc 'Copy phone configurations to the new table' - task populate_phone_configurations: :environment do - Rails.logger = Logger.new(STDOUT) - PopulatePhoneConfigurationsTable.new.call - end -end diff --git a/lib/tasks/rotate.rake b/lib/tasks/rotate.rake index 7acb2eab9e6..f5beb664915 100644 --- a/lib/tasks/rotate.rake +++ b/lib/tasks/rotate.rake @@ -13,8 +13,7 @@ namespace :rotate do users.each do |user| rotator = KeyRotator::AttributeEncryption.new(user) rotator.rotate - phone_configuration = user.phone_configuration - if phone_configuration.present? + user.phone_configurations.each do |phone_configuration| rotator = KeyRotator::AttributeEncryption.new(phone_configuration) rotator.rotate end diff --git a/spec/controllers/account_recovery_setup_controller_spec.rb b/spec/controllers/account_recovery_setup_controller_spec.rb index 4c77947abf2..2064124b04b 100644 --- a/spec/controllers/account_recovery_setup_controller_spec.rb +++ b/spec/controllers/account_recovery_setup_controller_spec.rb @@ -24,7 +24,7 @@ context 'user is piv_cac enabled but not phone enabled' do it 'redirects to account_url' do - user = build(:user, :signed_up, :with_piv_or_cac, phone: nil) + user = build(:user, :signed_up, :with_piv_or_cac, with: { mfa_enabled: false }) stub_sign_in(user) get :index diff --git a/spec/controllers/account_reset/delete_account_controller_spec.rb b/spec/controllers/account_reset/delete_account_controller_spec.rb index d219b7ad611..2675a82b723 100644 --- a/spec/controllers/account_reset/delete_account_controller_spec.rb +++ b/spec/controllers/account_reset/delete_account_controller_spec.rb @@ -11,53 +11,104 @@ session[:granted_token] = AccountResetRequest.all[0].granted_token stub_analytics - expect(@analytics).to receive(:track_event). - with(Analytics::ACCOUNT_RESET, event: :delete, token_valid: true, user_id: user.uuid) + properties = { + user_id: user.uuid, + event: 'delete', + success: true, + errors: {}, + } + expect(@analytics). + to receive(:track_event).with(Analytics::ACCOUNT_RESET, properties) delete :delete + + expect(response).to redirect_to account_reset_confirm_delete_account_url end - it 'logs a bad token to the analytics' do + it 'redirects to root if the token does not match one in the DB' do + session[:granted_token] = 'foo' stub_analytics - expect(@analytics).to receive(:track_event). - with(Analytics::ACCOUNT_RESET, event: :delete, token_valid: false) + properties = { + user_id: 'anonymous-uuid', + event: 'delete', + success: false, + errors: { token: [t('errors.account_reset.granted_token_invalid')] }, + } + expect(@analytics). + to receive(:track_event).with(Analytics::ACCOUNT_RESET, properties) - delete :delete, params: { token: 'FOO' } + delete :delete + + expect(response).to redirect_to(root_url) + expect(flash[:error]).to eq t('errors.account_reset.granted_token_invalid') end - it 'redirects to root if there is no token' do + it 'displays a flash and redirects to root if the token is missing' do + stub_analytics + properties = { + user_id: 'anonymous-uuid', + event: 'delete', + success: false, + errors: { token: [t('errors.account_reset.granted_token_missing')] }, + } + expect(@analytics).to receive(:track_event). + with(Analytics::ACCOUNT_RESET, properties) + delete :delete expect(response).to redirect_to(root_url) + expect(flash[:error]).to eq t('errors.account_reset.granted_token_missing') end - end - describe '#show' do - it 'prevents parameter leak' do + 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 - get :show, params: { token: AccountResetRequest.all[0].granted_token } + stub_analytics + properties = { + user_id: user.uuid, + event: 'delete', + success: false, + errors: { token: [t('errors.account_reset.granted_token_expired')] }, + } + expect(@analytics).to receive(:track_event). + with(Analytics::ACCOUNT_RESET, properties) + + Timecop.travel(Time.zone.now + 2.days) do + session[:granted_token] = AccountResetRequest.all[0].granted_token + delete :delete + end - expect(response).to redirect_to(account_reset_delete_account_url) + expect(response).to redirect_to(root_url) + expect(flash[:error]).to eq t('errors.account_reset.granted_token_expired') end - it 'redirects to root if the token is bad' do - get :show, params: { token: 'FOO' } + it 'redirects to root if feature is not enabled' do + allow(FeatureManagement).to receive(:account_reset_enabled?).and_return(false) - expect(response).to redirect_to(root_url) + delete :delete + + expect(response).to redirect_to root_url end + end - it 'renders the page' do - user = create(:user) - create_account_reset_request_for(user) - AccountResetService.new(user).grant_request - session[:granted_token] = AccountResetRequest.all[0].granted_token + 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: 'granted token validation', + success: false, + errors: { token: [t('errors.account_reset.granted_token_invalid')] }, + } + expect(@analytics). + to receive(:track_event).with(Analytics::ACCOUNT_RESET, properties) - get :show + get :show, params: { token: 'FOO' } - expect(response).to render_template(:show) + expect(response).to redirect_to(root_url) + expect(flash[:error]).to eq t('errors.account_reset.granted_token_invalid') end it 'displays a flash and redirects to root if the token is expired' do @@ -66,16 +117,35 @@ AccountResetService.new(user).grant_request stub_analytics + properties = { + user_id: user.uuid, + event: 'granted token validation', + success: false, + errors: { token: [t('errors.account_reset.granted_token_expired')] }, + } expect(@analytics).to receive(:track_event). - with(Analytics::ACCOUNT_RESET, - event: :delete, token_valid: true, expired: true, user_id: user.uuid) + with(Analytics::ACCOUNT_RESET, properties) Timecop.travel(Time.zone.now + 2.days) do get :show, params: { token: AccountResetRequest.all[0].granted_token } end expect(response).to redirect_to(root_url) - expect(flash[:error]).to eq t('devise.two_factor_authentication.account_reset.link_expired') + expect(flash[:error]).to eq t('errors.account_reset.granted_token_expired') + 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/concerns/user_session_context_spec.rb b/spec/controllers/concerns/user_session_context_spec.rb index 3d3129e1de5..cf73ff5fd3e 100644 --- a/spec/controllers/concerns/user_session_context_spec.rb +++ b/spec/controllers/concerns/user_session_context_spec.rb @@ -17,7 +17,6 @@ def user_session describe UserSessionContext do let(:controller) { DummyController.new } let(:confirmation) { { context: 'confirmation' } } - let(:idv) { { context: 'idv' } } after { controller.set({}) } @@ -35,31 +34,16 @@ def user_session it 'returns true when context is authentication, false otherwise' do expect(controller.authentication_context?).to be(true) expect(controller.confirmation_context?).to be(false) - expect(controller.idv_or_confirmation_context?).to be(false) end end describe '#confirmation_context?' do it 'returns true if context matches, false otherwise' do expect(controller.confirmation_context?).to be(false) - expect(controller.idv_or_confirmation_context?).to be(false) controller.set(confirmation) expect(controller.confirmation_context?).to be(true) - expect(controller.idv_or_confirmation_context?).to be(true) - end - end - - describe '#idv_context?' do - it 'returns true if context matches, false otherwise' do - expect(controller.idv_context?).to be(false) - expect(controller.idv_or_confirmation_context?).to be(false) - - controller.set(idv) - - expect(controller.idv_context?).to be(true) - expect(controller.idv_or_confirmation_context?).to be(true) end end end diff --git a/spec/controllers/idv/confirmations_controller_spec.rb b/spec/controllers/idv/confirmations_controller_spec.rb index c7afa4e695c..713e51d88f6 100644 --- a/spec/controllers/idv/confirmations_controller_spec.rb +++ b/spec/controllers/idv/confirmations_controller_spec.rb @@ -22,7 +22,6 @@ def stub_idv_session idv_session.profile_id = profile.id idv_session.personal_key = profile.personal_key allow(subject).to receive(:idv_session).and_return(idv_session) - allow(subject).to receive(:user_session).and_return(context: 'idv') end let(:password) { 'sekrit phrase' } @@ -111,7 +110,7 @@ def index context 'user used 2FA phone as phone of record' do before do - subject.idv_session.params['phone'] = user.phone_configuration.phone + subject.idv_session.params['phone'] = user.phone_configurations.first.phone end it 'tracks final IdV event' do diff --git a/spec/controllers/idv/otp_delivery_method_controller_spec.rb b/spec/controllers/idv/otp_delivery_method_controller_spec.rb index a5f3a3671c5..5faa5629464 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] = '5555555000' + subject.idv_session.params[:phone] = '2255555000' subject.idv_session.vendor_phone_confirmation = true subject.idv_session.user_phone_confirmation = false end @@ -108,7 +108,8 @@ context 'user has selected sms' do it 'redirects to the otp send path for sms' do post :create, params: params - expect(response).to redirect_to otp_send_path(params) + expect(subject.idv_session.phone_confirmation_otp_delivery_method).to eq('sms') + expect(response).to redirect_to idv_otp_verification_path end it 'tracks an analytics event' do @@ -139,7 +140,8 @@ it 'redirects to the otp send path for voice' do post :create, params: params - expect(response).to redirect_to otp_send_path(params) + expect(subject.idv_session.phone_confirmation_otp_delivery_method).to eq('voice') + expect(response).to redirect_to idv_otp_verification_path end it 'tracks an analytics event' do @@ -189,5 +191,68 @@ with(Analytics::IDV_PHONE_OTP_DELIVERY_SELECTION_SUBMITTED, result) end end + + context 'twilio raises an exception' do + let(:twilio_error_analytics_hash) do + { + error: "[HTTP 400] : error message\n\n", + code: '', + context: 'idv', + country: 'US', + } + end + let(:twilio_error) do + Twilio::REST::RestError.new('error message', FakeTwilioErrorResponse.new) + end + + before do + stub_analytics + allow(SmsOtpSenderJob).to receive(:perform_later).and_raise(twilio_error) + end + + context 'twilio rest error' do + it 'tracks an analytics events' do + expect(@analytics).to receive(:track_event).ordered.with( + Analytics::IDV_PHONE_OTP_DELIVERY_SELECTION_SUBMITTED, hash_including(success: true) + ) + expect(@analytics).to receive(:track_event).ordered.with( + Analytics::TWILIO_PHONE_VALIDATION_FAILED, twilio_error_analytics_hash + ) + + post :create, params: params + end + end + + context 'phone verification verify error' do + let(:twilio_error_analytics_hash) do + analytics_hash = super() + analytics_hash.merge( + error: 'error', + code: 60_033, + status: 400, + response: '{"error_code":"60004"}' + ) + end + let(:twilio_error) do + PhoneVerification::VerifyError.new( + code: 60_033, + message: 'error', + status: 400, + response: '{"error_code":"60004"}' + ) + end + + it 'tracks an analytics event' do + expect(@analytics).to receive(:track_event).ordered.with( + Analytics::IDV_PHONE_OTP_DELIVERY_SELECTION_SUBMITTED, hash_including(success: true) + ) + expect(@analytics).to receive(:track_event).ordered.with( + Analytics::TWILIO_PHONE_VALIDATION_FAILED, twilio_error_analytics_hash + ) + + post :create, params: params + end + end + end end end diff --git a/spec/controllers/idv/otp_verification_controller_spec.rb b/spec/controllers/idv/otp_verification_controller_spec.rb new file mode 100644 index 00000000000..fd516a2a51c --- /dev/null +++ b/spec/controllers/idv/otp_verification_controller_spec.rb @@ -0,0 +1,94 @@ +require 'rails_helper' + +describe Idv::OtpVerificationController do + let(:user) { build(:user) } + + let(:phone) { '2255555000' } + let(:user_phone_confirmation) { false } + let(:phone_confirmation_otp_delivery_method) { 'sms' } + let(:phone_confirmation_otp) { '777777' } + let(:phone_confirmation_otp_sent_at) { Time.zone.now.to_s } + + before do + stub_analytics + allow(@analytics).to receive(:track_event) + + sign_in(user) + stub_verify_steps_one_and_two(user) + subject.idv_session.params[: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 = + phone_confirmation_otp_delivery_method + subject.idv_session.phone_confirmation_otp = phone_confirmation_otp + subject.idv_session.phone_confirmation_otp_sent_at = phone_confirmation_otp_sent_at + end + + describe '#show' do + context 'the user has not been sent an otp' do + let(:phone_confirmation_otp) { nil } + let(:phone_confirmation_otp_sent_at) { nil } + + it 'redirects to the delivery method path' do + get :show + expect(response).to redirect_to(idv_otp_delivery_method_path) + end + end + + context 'the user has already confirmed their phone' do + let(:user_phone_confirmation) { true } + + it 'redirects to the review step' do + get :show + expect(response).to redirect_to(idv_review_path) + end + end + + it 'tracks an analytics event' do + get :show + + expect(@analytics).to have_received(:track_event).with( + Analytics::IDV_PHONE_CONFIRMATION_OTP_VISIT + ) + end + end + + describe '#update' do + context 'the user has not been sent an otp' do + let(:phone_confirmation_otp) { nil } + let(:phone_confirmation_otp_sent_at) { nil } + + it 'redirects to otp delivery method selection' do + put :update, params: { code: phone_confirmation_otp } + expect(response).to redirect_to(idv_otp_delivery_method_path) + end + end + + context 'the user has already confirmed their phone' do + let(:user_phone_confirmation) { true } + + it 'redirects to the review step' do + put :update, params: { code: phone_confirmation_otp } + expect(response).to redirect_to(idv_review_path) + end + end + + it 'tracks an analytics event' do + put :update, params: { code: phone_confirmation_otp } + + expected_result = { + success: true, + errors: {}, + code_expired: false, + code_matches: true, + second_factor_attempts_count: 0, + second_factor_locked_at: nil, + } + + expect(@analytics).to have_received(:track_event).with( + Analytics::IDV_PHONE_CONFIRMATION_OTP_SUBMITTED, + expected_result + ) + end + end +end diff --git a/spec/controllers/idv/phone_controller_spec.rb b/spec/controllers/idv/phone_controller_spec.rb index c7ded6be30b..ff2aac508a5 100644 --- a/spec/controllers/idv/phone_controller_spec.rb +++ b/spec/controllers/idv/phone_controller_spec.rb @@ -20,7 +20,10 @@ end describe '#new' do - let(:user) { build(:user, phone: good_phone, phone_confirmed_at: Time.zone.now) } + let(:user) do + build(:user, :with_phone, + with: { phone: good_phone, confirmed_at: Time.zone.now }) + end before do stub_verify_steps_one_and_two(user) @@ -64,7 +67,7 @@ describe '#create' do context 'when form is invalid' do before do - user = build(:user, phone: '+1 (415) 555-0130') + user = build(:user, :with_phone, with: { phone: '+1 (415) 555-0130' }) stub_verify_steps_one_and_two(user) stub_analytics allow(@analytics).to receive(:track_event) @@ -105,7 +108,7 @@ end it 'tracks event with valid phone' do - user = build(:user, phone: good_phone, phone_confirmed_at: Time.zone.now) + user = build(:user, :with_phone, with: { phone: good_phone, confirmed_at: Time.zone.now }) stub_verify_steps_one_and_two(user) put :create, params: { idv_phone_form: { phone: good_phone } } @@ -124,7 +127,9 @@ context 'when same as user phone' do it 'redirects to result page and sets phone_confirmed_at' do - user = build(:user, phone: good_phone, phone_confirmed_at: Time.zone.now) + user = build(:user, :with_phone, with: { + phone: good_phone, confirmed_at: Time.zone.now + }) stub_verify_steps_one_and_two(user) put :create, params: { idv_phone_form: { phone: good_phone } } @@ -133,7 +138,7 @@ expected_params = { phone: normalized_phone, - phone_confirmed_at: user.phone_configuration.confirmed_at, + phone_confirmed_at: user.phone_configurations.first.confirmed_at, } expect(subject.idv_session.params).to eq expected_params end @@ -141,7 +146,9 @@ context 'when different from user phone' do it 'redirects to otp page and does not set phone_confirmed_at' do - user = build(:user, phone: '+1 (415) 555-0130', phone_confirmed_at: Time.zone.now) + user = build(:user, :with_phone, with: { + phone: '+1 (415) 555-0130', confirmed_at: Time.zone.now + }) stub_verify_steps_one_and_two(user) put :create, params: { idv_phone_form: { phone: good_phone } } @@ -159,7 +166,9 @@ end describe '#show' do - let(:user) { build(:user, phone: good_phone, phone_confirmed_at: Time.zone.now) } + let(:user) do + build(:user, :with_phone, with: { phone: good_phone, confirmed_at: Time.zone.now }) + end let(:params) { { phone: good_phone } } before do @@ -232,7 +241,9 @@ end let(:params) { { phone: bad_phone } } - let(:user) { build(:user, phone: bad_phone, phone_confirmed_at: Time.zone.now) } + 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 @@ -257,7 +268,9 @@ context 'attempt window has expired, previous attempts == max-1' do let(:two_days_ago) { Time.zone.now - 2.days } - let(:user) { build(:user, phone: good_phone, phone_confirmed_at: Time.zone.now) } + 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 @@ -275,7 +288,8 @@ end it 'passes the normalized phone to the background job' do - user = build(:user, phone: good_phone, phone_confirmed_at: Time.zone.now) + 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 } diff --git a/spec/controllers/idv/resend_otp_controller_spec.rb b/spec/controllers/idv/resend_otp_controller_spec.rb new file mode 100644 index 00000000000..7d73377f7c1 --- /dev/null +++ b/spec/controllers/idv/resend_otp_controller_spec.rb @@ -0,0 +1,116 @@ +require 'rails_helper' + +describe Idv::ResendOtpController do + let(:user) { build(:user) } + + let(:phone) { '2255555000' } + let(:user_phone_confirmation) { false } + let(:phone_confirmation_otp_delivery_method) { 'sms' } + + before do + stub_analytics + allow(@analytics).to receive(:track_event) + + sign_in(user) + stub_verify_steps_one_and_two(user) + subject.idv_session.params[: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 = + phone_confirmation_otp_delivery_method + end + + describe '#create' do + context 'the user has not selected a delivery method' do + let(:phone_confirmation_otp_delivery_method) { nil } + + it 'redirects to otp delivery method selection' do + post :create + expect(response).to redirect_to(idv_otp_delivery_method_path) + end + end + + context 'the user has already confirmed their phone' do + let(:user_phone_confirmation) { true } + + it 'redirects to the review step' do + post :create + expect(response).to redirect_to(idv_review_path) + end + end + + it 'tracks an analytics event' do + post :create + + expected_result = { + success: true, + errors: {}, + otp_delivery_preference: :sms, + country_code: 'US', + area_code: '225', + rate_limit_exceeded: false, + } + + expect(@analytics).to have_received(:track_event).with( + Analytics::IDV_PHONE_CONFIRMATION_OTP_RESENT, + expected_result + ) + end + + context 'twilio raises an exception' do + let(:twilio_error_analytics_hash) do + { + error: "[HTTP 400] : error message\n\n", + code: '', + context: 'idv', + country: 'US', + } + end + let(:twilio_error) do + Twilio::REST::RestError.new('error message', FakeTwilioErrorResponse.new) + end + + before do + stub_analytics + allow(SmsOtpSenderJob).to receive(:perform_later).and_raise(twilio_error) + end + + context 'twilio rest error' do + it 'tracks an analytics events' do + expect(@analytics).to receive(:track_event).ordered.with( + Analytics::TWILIO_PHONE_VALIDATION_FAILED, twilio_error_analytics_hash + ) + + post :create + end + end + + context 'phone verification verify error' do + let(:twilio_error_analytics_hash) do + super().merge( + error: 'error', + code: 60_033, + status: 400, + response: '{"error_code":"60004"}' + ) + end + let(:twilio_error) do + PhoneVerification::VerifyError.new( + code: 60_033, + message: 'error', + status: 400, + response: '{"error_code":"60004"}' + ) + end + + it 'tracks an analytics event' do + expect(@analytics).to receive(:track_event).ordered.with( + Analytics::TWILIO_PHONE_VALIDATION_FAILED, twilio_error_analytics_hash + ) + + post :create + end + end + end + end +end diff --git a/spec/controllers/idv/review_controller_spec.rb b/spec/controllers/idv/review_controller_spec.rb index bfa4d713cc9..b981a6d942f 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_configuration&.phone, + phone: user.phone_configurations.first&.phone, ssn: '12345678', } end @@ -130,9 +130,7 @@ def show it 'redirects to phone confirmation' do get :show - expect(response).to redirect_to otp_send_path( - otp_delivery_selection_form: { otp_delivery_preference: :sms } - ) + expect(response).to redirect_to idv_otp_verification_path end end end diff --git a/spec/controllers/openid_connect/logout_controller_spec.rb b/spec/controllers/openid_connect/logout_controller_spec.rb index 38500e8c69f..67bda6e2382 100644 --- a/spec/controllers/openid_connect/logout_controller_spec.rb +++ b/spec/controllers/openid_connect/logout_controller_spec.rb @@ -80,11 +80,15 @@ it 'tracks analytics' do stub_analytics + + errors = { + redirect_uri: [t('openid_connect.authorization.errors.redirect_uri_no_match')], + } expect(@analytics).to receive(:track_event). with(Analytics::LOGOUT_INITIATED, success: false, client_id: service_provider, - errors: hash_including(:post_logout_redirect_uri), + errors: errors, sp_initiated: true, oidc: true) 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 2ce79386881..da6606f3cb7 100644 --- a/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb +++ b/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb @@ -33,7 +33,7 @@ end it 'tracks the page visit and context' do - user = build_stubbed(:user, phone: '+1 (703) 555-0100') + user = build_stubbed(:user, :with_phone, with: { phone: '+1 (703) 555-0100' }) stub_sign_in_before_2fa(user) stub_analytics @@ -264,7 +264,7 @@ 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_configuration&.confirmed_at + @previous_phone_confirmed_at = subject.current_user.phone_configurations.first&.confirmed_at subject.current_user.create_direct_otp stub_analytics allow(@analytics).to receive(:track_event) @@ -272,7 +272,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_configuration&.phone + @previous_phone = subject.current_user.phone_configurations.first&.phone end context 'user has an existing phone number' do @@ -322,11 +322,9 @@ end it 'does not update user phone or phone_confirmed_at attributes' do - expect(subject.current_user.phone).to eq('+1 202-555-1212') - expect(subject.current_user.phone_confirmed_at).to eq(@previous_phone_confirmed_at) - expect(subject.current_user.phone_configuration.phone).to eq('+1 202-555-1212') + expect(subject.current_user.phone_configurations.first.phone).to eq('+1 202-555-1212') expect( - subject.current_user.phone_configuration.confirmed_at + subject.current_user.phone_configurations.first.confirmed_at ).to eq(@previous_phone_confirmed_at) end @@ -355,10 +353,7 @@ context 'when user does not have an existing phone number' do before do - subject.current_user.phone = nil - subject.current_user.phone_confirmed_at = nil - subject.current_user.phone_configuration.destroy - subject.current_user.phone_configuration = nil + subject.current_user.phone_configurations.clear subject.current_user.create_direct_otp end @@ -432,144 +427,5 @@ end end end - - context 'idv phone confirmation' do - before do - user = sign_in_as_user - idv_session = Idv::Session.new( - user_session: subject.user_session, current_user: user, issuer: nil - ) - idv_session.params = { 'phone' => '+1 (703) 555-5555' } - subject.user_session[:unconfirmed_phone] = '+1 (703) 555-5555' - subject.user_session[:context] = 'idv' - @previous_phone_confirmed_at = subject.current_user.phone_configuration&.confirmed_at - allow(subject).to receive(:idv_session).and_return(idv_session) - stub_analytics - allow(@analytics).to receive(:track_event) - allow(subject).to receive(:create_user_event) - subject.current_user.create_direct_otp - allow(UserMailer).to receive(:phone_changed) - end - - context 'user enters a valid code' do - before do - post( - :create, - params: { - code: subject.current_user.direct_otp, - otp_delivery_preference: 'sms', - } - ) - end - - it 'resets otp session data' do - expect(subject.user_session[:unconfirmed_phone]).to be_nil - expect(subject.user_session[:context]).to eq 'authentication' - end - - it 'tracks the OTP verification event' do - properties = { - success: true, - errors: {}, - confirmation_for_phone_change: false, - context: 'idv', - multi_factor_auth_method: 'sms', - } - - expect(@analytics).to have_received(:track_event). - with(Analytics::MULTI_FACTOR_AUTH, properties) - - expect(subject).to have_received(:create_user_event).with(:phone_confirmed) - end - - it 'does not track a phone change event' do - expect(subject).to_not have_received(:create_user_event).with(:phone_changed) - end - - it 'updates idv session phone_confirmed_at attribute' do - expect(subject.user_session[:idv][:params]['phone_confirmed_at']).to_not be_nil - end - - it 'updates idv session user_phone_confirmation attributes' do - expect(subject.user_session[:idv][:user_phone_confirmation]).to eq(true) - end - - it 'does not update user phone attributes' do - expect(subject.current_user.reload.phone).to eq '+1 202-555-1212' - expect(subject.current_user.reload.phone_confirmed_at).to eq @previous_phone_confirmed_at - - configuration = subject.current_user.reload.phone_configuration - expect(configuration.phone).to eq '+1 202-555-1212' - expect(configuration.confirmed_at).to eq @previous_phone_confirmed_at - end - - it 'redirects to idv_review_path' do - expect(response).to redirect_to(idv_review_path) - end - - it 'does not call UserMailer' do - expect(UserMailer).to_not have_received(:phone_changed) - end - end - - context 'user enters an invalid code' do - before { post :create, params: { code: '999', otp_delivery_preference: 'sms' } } - - it 'increments second_factor_attempts_count' do - expect(subject.current_user.reload.second_factor_attempts_count).to eq 1 - end - - it 'does not clear session data' do - expect(subject.user_session[:unconfirmed_phone]).to eq('+1 (703) 555-5555') - end - - it 'does not update user phone or phone_confirmed_at attributes' do - expect(subject.current_user.phone).to eq('+1 202-555-1212') - expect(subject.current_user.phone_confirmed_at).to eq(@previous_phone_confirmed_at) - - configuration = subject.current_user.reload.phone_configuration - expect(configuration.phone).to eq '+1 202-555-1212' - expect(configuration.confirmed_at).to eq @previous_phone_confirmed_at - - expect(subject.idv_session.params['phone_confirmed_at']).to be_nil - end - - it 'renders :show' do - expect(response).to render_template(:show) - end - - it 'displays error flash notice' do - expect(flash[:error]).to eq t('devise.two_factor_authentication.invalid_otp') - end - - it 'tracks an event' do - properties = { - success: false, - errors: {}, - confirmation_for_phone_change: false, - context: 'idv', - multi_factor_auth_method: 'sms', - } - - expect(@analytics).to have_received(:track_event). - with(Analytics::MULTI_FACTOR_AUTH, properties) - end - end - - context 'with remember_device in the params' do - it 'ignores the param and does not save an encrypted cookie' do - post( - :create, - params: { - code: subject.current_user.direct_otp, - otp_delivery_preference: 'sms', - remember_device: 'true', - } - ) - - expect(cookies[:remember_device]).to be_nil - end - end - end end 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 3615ee49fb1..84ee076916e 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 @@ -65,13 +65,14 @@ it 'generates a new personal key after the user signs in with their old one' do user = create(:user) - old_key = PersonalKeyGenerator.new(user).create + raw_key = PersonalKeyGenerator.new(user).create + old_key = user.reload.encrypted_recovery_code_digest stub_sign_in_before_2fa(user) - post :create, params: { personal_key_form: { personal_key: old_key } } + post :create, params: { personal_key_form: { personal_key: raw_key } } user.reload - expect(user.personal_key).to_not be_nil - expect(user.personal_key).to_not eq old_key + expect(user.encrypted_recovery_code_digest).to_not be_nil + expect(user.encrypted_recovery_code_digest).to_not eq old_key end context 'when the personal key field is empty' do @@ -79,7 +80,7 @@ let(:payload) { { personal_key_form: personal_key } } before do - stub_sign_in_before_2fa(build(:user, phone: '+1 (703) 555-1212')) + stub_sign_in_before_2fa(build(:user, :with_phone, with: { phone: '+1 (703) 555-1212' })) form = instance_double(PersonalKeyForm) response = FormResponse.new( success: false, errors: {}, extra: { multi_factor_auth_method: 'personal key' } @@ -99,7 +100,7 @@ context 'when the user enters an invalid personal key' do before do - stub_sign_in_before_2fa(build(:user, phone: '+1 (703) 555-1212')) + stub_sign_in_before_2fa(build(:user, :with_phone, with: { phone: '+1 (703) 555-1212' })) form = instance_double(PersonalKeyForm) response = FormResponse.new( success: false, errors: {}, extra: { multi_factor_auth_method: 'personal key' } @@ -140,12 +141,13 @@ end it 'does not generate a new personal key if the user enters an invalid key' do - user = create(:user, personal_key: 'ABCD-EFGH-IJKL-MNOP') + user = create(:user, :with_personal_key) + old_key = user.reload.encrypted_recovery_code_digest stub_sign_in_before_2fa(user) post :create, params: payload user.reload - expect(user.personal_key).to eq 'ABCD-EFGH-IJKL-MNOP' + expect(user.encrypted_recovery_code_digest).to eq old_key end end end diff --git a/spec/controllers/two_factor_authentication/piv_cac_verification_controller_spec.rb b/spec/controllers/two_factor_authentication/piv_cac_verification_controller_spec.rb index bc0f98abbfc..3f7dbc95674 100644 --- a/spec/controllers/two_factor_authentication/piv_cac_verification_controller_spec.rb +++ b/spec/controllers/two_factor_authentication/piv_cac_verification_controller_spec.rb @@ -3,7 +3,7 @@ describe TwoFactorAuthentication::PivCacVerificationController do let(:user) do create(:user, :signed_up, :with_piv_or_cac, - phone: '+1 (703) 555-0000') + with: { phone: '+1 (703) 555-0000' }) end let(:nonce) { 'once' } diff --git a/spec/controllers/users/phones_controller_spec.rb b/spec/controllers/users/phones_controller_spec.rb index 16dc088fd7a..fbb5b11a0c9 100644 --- a/spec/controllers/users/phones_controller_spec.rb +++ b/spec/controllers/users/phones_controller_spec.rb @@ -5,8 +5,8 @@ include Features::LocalizationHelper describe '#phone' do - let(:user) { create(:user, :signed_up, phone: '+1 (202) 555-1234') } - let(:second_user) { create(:user, :signed_up, phone: '+1 (202) 555-5678') } + let(:user) { create(:user, :signed_up, with: { phone: '+1 (202) 555-1234' }) } + let(:second_user) { create(:user, :signed_up, with: { phone: '+1 (202) 555-5678' }) } let(:new_phone) { '202-555-4321' } context 'user changes phone' do @@ -25,8 +25,7 @@ it 'lets user know they need to confirm their new phone' do expect(flash[:notice]).to eq t('devise.registrations.phone_update_needs_confirmation') - expect(user.reload.phone).to_not eq '+1 202-555-4321' - expect(user.reload.phone_configuration.phone).to_not eq '+1 202-555-4321' + expect(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( @@ -48,8 +47,7 @@ otp_delivery_preference: 'sms' }, } - expect(user.reload.phone).to be_present - expect(user.reload.phone_configuration.phone).to be_present + expect(user.phone_configurations.reload.first).to be_present expect(response).to render_template(:edit) end end @@ -62,7 +60,7 @@ allow(@analytics).to receive(:track_event) put :update, params: { - user_phone_form: { phone: second_user.phone, + user_phone_form: { phone: second_user.phone_configurations.first.phone, international_code: 'US', otp_delivery_preference: 'sms' }, } @@ -70,8 +68,9 @@ it 'processes successfully and informs user' do expect(flash[:notice]).to eq t('devise.registrations.phone_update_needs_confirmation') - expect(user.reload.phone).to_not eq second_user.phone - expect(user.reload.phone_configuration.phone).to_not eq second_user.phone + expect(user.phone_configurations.reload.first.phone).to_not eq( + second_user.phone_configurations.first.phone + ) expect(@analytics).to have_received(:track_event). with(Analytics::PHONE_CHANGE_REQUESTED) expect(response).to redirect_to( @@ -86,7 +85,7 @@ context 'user updates with invalid phone' do it 'does not change the user phone number' do invalid_phone = '123' - user = build(:user, phone: '123-123-1234') + user = build(:user, :with_phone, with: { phone: '123-123-1234' }) stub_sign_in(user) put :update, params: { @@ -95,8 +94,7 @@ otp_delivery_preference: 'sms' }, } - expect(user.phone).not_to eq invalid_phone - expect(user.phone_configuration.phone).not_to eq invalid_phone + expect(user.phone_configurations.first.phone).not_to eq invalid_phone expect(response).to render_template(:edit) end end @@ -106,7 +104,7 @@ stub_sign_in(user) put :update, params: { - user_phone_form: { phone: user.phone, + user_phone_form: { phone: user.phone_configurations.first.phone, international_code: 'US', otp_delivery_preference: 'sms' }, } diff --git a/spec/controllers/users/piv_cac_authentication_setup_controller_spec.rb b/spec/controllers/users/piv_cac_authentication_setup_controller_spec.rb index bd282961ad3..ab7ef139bfe 100644 --- a/spec/controllers/users/piv_cac_authentication_setup_controller_spec.rb +++ b/spec/controllers/users/piv_cac_authentication_setup_controller_spec.rb @@ -32,7 +32,7 @@ describe 'when signing in' do before(:each) { stub_sign_in_before_2fa(user) } let(:user) do - create(:user, :signed_up, :with_piv_or_cac, phone: '+1 (703) 555-0000') + create(:user, :signed_up, :with_piv_or_cac, with: { phone: '+1 (703) 555-0000' }) end describe 'GET index' do @@ -55,7 +55,7 @@ context 'without associated piv/cac' do let(:user) do - create(:user, :signed_up, phone: '+1 (703) 555-0000') + create(:user, :signed_up, with: { phone: '+1 (703) 555-0000' }) end before(:each) do diff --git a/spec/controllers/users/totp_setup_controller_spec.rb b/spec/controllers/users/totp_setup_controller_spec.rb index d564f335671..3414c76b037 100644 --- a/spec/controllers/users/totp_setup_controller_spec.rb +++ b/spec/controllers/users/totp_setup_controller_spec.rb @@ -15,7 +15,7 @@ context 'user is setting up authenticator app after account creation' do before do stub_analytics - user = build(:user, phone: '703-555-1212') + user = build(:user, :with_phone, with: { phone: '703-555-1212' }) stub_sign_in(user) allow(@analytics).to receive(:track_event) get :new diff --git a/spec/controllers/users/two_factor_authentication_controller_spec.rb b/spec/controllers/users/two_factor_authentication_controller_spec.rb index 522182857bb..2287da46a7f 100644 --- a/spec/controllers/users/two_factor_authentication_controller_spec.rb +++ b/spec/controllers/users/two_factor_authentication_controller_spec.rb @@ -101,7 +101,7 @@ def index context 'when the user has already set up 2FA' do it 'sends OTP via otp_delivery_preference and prompts for OTP' do - stub_sign_in_before_2fa(build(:user, phone: '+1 (703) 555-1212')) + stub_sign_in_before_2fa(build(:user, :with_phone, with: { phone: '+1 (703) 555-1212' })) get :show @@ -134,7 +134,7 @@ def index expect(SmsOtpSenderJob).to have_received(:perform_later).with( code: subject.current_user.direct_otp, - phone: subject.current_user.phone_configuration.phone, + phone: 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 +151,7 @@ def index expect(SmsOtpSenderJob).to have_received(:perform_later).with( code: subject.current_user.direct_otp, - phone: subject.current_user.phone_configuration.phone, + phone: 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 +180,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_configuration.phone, + phone: @user.phone_configurations.first.phone, user: @user ).and_return(otp_rate_limiter) @@ -218,7 +218,7 @@ def index expect(VoiceOtpSenderJob).to have_received(:perform_later).with( code: subject.current_user.direct_otp, - phone: subject.current_user.phone_configuration.phone, + phone: 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/webauthn_setup_controller_spec.rb b/spec/controllers/users/webauthn_setup_controller_spec.rb new file mode 100644 index 00000000000..2ac17d1fe70 --- /dev/null +++ b/spec/controllers/users/webauthn_setup_controller_spec.rb @@ -0,0 +1,117 @@ +require 'rails_helper' + +describe Users::WebauthnSetupController do + include WebauthnHelper + + describe 'before_actions' do + it 'includes appropriate before_actions' do + expect(subject).to have_actions( + :before, + :authenticate_user!, + [:confirm_two_factor_authenticated, if: :two_factor_enabled?] + ) + end + end + + describe 'when not signed in' do + describe 'GET new' do + it 'redirects to root url' do + get :new + + 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' do + before do + stub_analytics + stub_sign_in + end + + describe 'GET new' do + it 'saves challenge in session' do + get :new + + expect(subject.user_session[:webauthn_challenge].length).to eq(16) + end + + it 'tracks page visit' do + stub_sign_in + stub_analytics + + expect(@analytics).to receive(:track_event).with(Analytics::WEBAUTHN_SETUP_VISIT) + + get :new + end + 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' do + patch :confirm, params: params + + expect(response).to redirect_to(account_url) + expect(flash.now[:success]).to eq t('notices.webauthn_added') + end + + it 'tracks the submission' do + result = { success: true, errors: {} } + expect(@analytics).to receive(:track_event). + with(Analytics::WEBAUTHN_SETUP_SUBMITTED, result) + + patch :confirm, params: params + end + end + + describe 'delete' do + before do + allow(controller.current_user).to receive(:total_mfa_options_enabled).and_return(2) + end + + it 'deletes a webauthn configuration' do + cfg = create_webauthn_configuration(controller.current_user, 'key1', 'id1', 'foo1') + delete :delete, params: { id: cfg.id } + + expect(response).to redirect_to(account_url) + expect(flash.now[:success]).to eq t('notices.webauthn_deleted') + expect(WebauthnConfiguration.count).to eq(0) + end + + it 'tracks the delete' do + cfg = create_webauthn_configuration(controller.current_user, 'key1', 'id1', 'foo1') + + result = { success: true, mfa_options_enabled: 2 } + expect(@analytics).to receive(:track_event).with(Analytics::WEBAUTHN_DELETED, result) + + delete :delete, params: { id: cfg.id } + end + end + end + + def create_webauthn_configuration(user, name, id, key) + WebauthnConfiguration.create(user_id: user.id, + credential_public_key: key, + credential_id: id, + name: name) + end +end diff --git a/spec/decorators/user_decorator_spec.rb b/spec/decorators/user_decorator_spec.rb index 6f5b5e08236..72252376dd3 100644 --- a/spec/decorators/user_decorator_spec.rb +++ b/spec/decorators/user_decorator_spec.rb @@ -204,42 +204,6 @@ end end - describe '#should_acknowledge_personal_key?' do - context 'user has no personal key' do - context 'service provider with loa1' do - it 'returns true' do - user_decorator = UserDecorator.new(User.new) - session = { sp: { loa3: false } } - - expect(user_decorator.should_acknowledge_personal_key?(session)).to eq true - end - end - - context 'no service provider' do - it 'returns true' do - user_decorator = UserDecorator.new(User.new) - session = {} - - expect(user_decorator.should_acknowledge_personal_key?(session)).to eq true - end - end - - it 'returns false when the user has a personal key' do - user_decorator = UserDecorator.new(User.new(personal_key: 'foo')) - session = {} - - expect(user_decorator.should_acknowledge_personal_key?(session)).to eq false - end - - it 'returns false if the user is loa3' do - user_decorator = UserDecorator.new(User.new) - session = { sp: { loa3: true } } - - expect(user_decorator.should_acknowledge_personal_key?(session)).to eq false - end - end - end - describe '#recent_events' do let!(:user) { create(:user, :signed_up, created_at: Time.zone.now - 100.days) } let(:decorated_user) { user.decorate } diff --git a/spec/factories/authorizations.rb b/spec/factories/authorizations.rb index bb59a4c0947..1832018523b 100644 --- a/spec/factories/authorizations.rb +++ b/spec/factories/authorizations.rb @@ -1,7 +1,7 @@ FactoryBot.define do factory :authorization do - provider 'saml' - uid '1234' - user_id 1 + provider { 'saml' } + uid { '1234' } + user_id { 1 } end end diff --git a/spec/factories/events.rb b/spec/factories/events.rb index e9c07e39e2b..6726efc75e2 100644 --- a/spec/factories/events.rb +++ b/spec/factories/events.rb @@ -1,6 +1,6 @@ FactoryBot.define do factory :event do - user_id 1 - event_type :account_created + user_id { 1 } + event_type { :account_created } end end diff --git a/spec/factories/identities.rb b/spec/factories/identities.rb index 347ba5135d9..b2f6f6766f9 100644 --- a/spec/factories/identities.rb +++ b/spec/factories/identities.rb @@ -1,10 +1,10 @@ FactoryBot.define do factory :identity do uuid { SecureRandom.uuid } - service_provider 'https://serviceprovider.com' + service_provider { 'https://serviceprovider.com' } end trait :active do - last_authenticated_at Time.zone.now + last_authenticated_at { Time.zone.now } end end diff --git a/spec/factories/otp_presenter.rb b/spec/factories/otp_presenter.rb index e30038bffb1..34f9184d33c 100644 --- a/spec/factories/otp_presenter.rb +++ b/spec/factories/otp_presenter.rb @@ -1,8 +1,8 @@ FactoryBot.define do factory :generic_otp_presenter, class: Hash do - otp_delivery_preference 'sms' - phone_number '***-***-1212' - code_value '12777' - unconfirmed_user false + otp_delivery_preference { 'sms' } + phone_number { '***-***-1212' } + code_value { '12777' } + unconfirmed_user { false } end end diff --git a/spec/factories/phone_configurations.rb b/spec/factories/phone_configurations.rb index 9c64b38e7f4..804dcb09241 100644 --- a/spec/factories/phone_configurations.rb +++ b/spec/factories/phone_configurations.rb @@ -2,8 +2,9 @@ Faker::Config.locale = :en factory :phone_configuration do - confirmed_at Time.zone.now - phone '+1 202-555-1212' - user + confirmed_at { Time.zone.now } + phone { '+1 202-555-1212' } + mfa_enabled { true } + user { association :user } end end diff --git a/spec/factories/profiles.rb b/spec/factories/profiles.rb index 0aaa89cf04a..5370a87defb 100644 --- a/spec/factories/profiles.rb +++ b/spec/factories/profiles.rb @@ -2,16 +2,16 @@ factory :profile do association :user, factory: %i[user signed_up] transient do - pii false + pii { false } end trait :active do - active true - activated_at Time.zone.now + active { true } + activated_at { Time.zone.now } end trait :verified do - verified_at Time.zone.now + verified_at { Time.zone.now } end after(:build) do |profile, evaluator| diff --git a/spec/factories/service_providers.rb b/spec/factories/service_providers.rb index 58cb0799fe0..4f7210ace88 100644 --- a/spec/factories/service_providers.rb +++ b/spec/factories/service_providers.rb @@ -3,9 +3,9 @@ factory :service_provider do cert { 'saml_test_sp' } - friendly_name 'Test Service Provider' + friendly_name { 'Test Service Provider' } issuer { SecureRandom.uuid } - return_to_sp_url '/' - agency 'Test Agency' + return_to_sp_url { '/' } + agency { 'Test Agency' } end end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 10a64ff94f1..f9d0be59544 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -2,33 +2,55 @@ Faker::Config.locale = :en factory :user do - confirmed_at Time.zone.now + transient do + with { {} } + end + + confirmed_at { Time.zone.now } email { Faker::Internet.safe_email } - password '!1a Z@6s' * 16 # Maximum length password. + password { '!1a Z@6s' * 16 } # Maximum length password. - after :build do |user| - if user.phone - user.build_phone_configuration( - phone: user.phone, - confirmed_at: user.phone_confirmed_at, - delivery_preference: user.otp_delivery_preference - ) + trait :with_phone do + after(:build) do |user, evaluator| + if user.phone_configurations.empty? + user.save! + if user.id.present? + create(:phone_configuration, + { user: user, delivery_preference: user.otp_delivery_preference }.merge( + evaluator.with.slice(:phone, :confirmed_at, :delivery_preference, :mfa_enabled) + )) + user.reload + else + user.phone_configurations << build( + :phone_configuration, + { delivery_preference: user.otp_delivery_preference }.merge( + evaluator.with.slice(:phone, :confirmed_at, :delivery_preference, :mfa_enabled) + ) + ) + end + end end - end - after :stub do |user| - if user.phone - user.phone_configuration = build_stubbed(:phone_configuration, - user: user, - phone: user.phone, - confirmed_at: user.phone_confirmed_at, - delivery_preference: user.otp_delivery_preference) + after(:create) do |user, evaluator| + if user.phone_configurations.empty? + create(:phone_configuration, + { user: user, delivery_preference: user.otp_delivery_preference }.merge( + evaluator.with.slice(:phone, :confirmed_at, :delivery_preference, :mfa_enabled) + )) + user.reload + end end - end - trait :with_phone do - phone '+1 202-555-1212' - phone_confirmed_at Time.zone.now + after(:stub) do |user, evaluator| + if user.phone_configurations.empty? + user.phone_configurations << build( + :phone_configuration, + { delivery_preference: user.otp_delivery_preference }.merge( + evaluator.with.slice(:phone, :confirmed_at, :delivery_preference, :mfa_enabled) + ) + ) + end + end end trait :with_piv_or_cac do @@ -43,15 +65,15 @@ trait :with_authentication_app do with_personal_key - otp_secret_key 'abc123' + otp_secret_key { ROTP::Base32.random_base32 } end trait :admin do - role :admin + role { :admin } end trait :tech_support do - role :tech + role { :tech } end trait :signed_up do @@ -60,8 +82,8 @@ end trait :unconfirmed do - confirmed_at nil - password nil + confirmed_at { nil } + password { nil } end end end diff --git a/spec/factories/usps_confirmation_codes.rb b/spec/factories/usps_confirmation_codes.rb index 5ddf28cf118..8a00f8d6eea 100644 --- a/spec/factories/usps_confirmation_codes.rb +++ b/spec/factories/usps_confirmation_codes.rb @@ -3,6 +3,6 @@ factory :usps_confirmation_code do profile - otp_fingerprint Pii::Fingerprinter.fingerprint('ABCDE12345') + otp_fingerprint { Pii::Fingerprinter.fingerprint('ABCDE12345') } end end diff --git a/spec/features/accessibility/user_pages_spec.rb b/spec/features/accessibility/user_pages_spec.rb index 9db6fc614b6..fb2d88d423c 100644 --- a/spec/features/accessibility/user_pages_spec.rb +++ b/spec/features/accessibility/user_pages_spec.rb @@ -53,7 +53,7 @@ describe 'SMS' do scenario 'enter 2fa phone OTP code page' do - user = create(:user, phone: '+1 (202) 555-1212') + user = create(:user, :with_phone, with: { phone: '+1 (202) 555-1212' }) sign_in_before_2fa(user) visit login_two_factor_path(otp_delivery_preference: 'sms') @@ -64,7 +64,7 @@ describe 'Voice' do scenario 'enter 2fa phone OTP code page' do - user = create(:user, phone: '+1 (202) 555-1212') + user = create(:user, :with_phone, with: { phone: '+1 (202) 555-1212' }) sign_in_before_2fa(user) visit login_two_factor_path(otp_delivery_preference: 'voice') diff --git a/spec/features/account_reset/delete_account_spec.rb b/spec/features/account_reset/delete_account_spec.rb index 7daa30108fe..6d260c2c291 100644 --- a/spec/features/account_reset/delete_account_spec.rb +++ b/spec/features/account_reset/delete_account_spec.rb @@ -44,6 +44,11 @@ ) expect(page).to have_current_path(account_reset_confirm_delete_account_path) expect(User.where(id: user.id)).to be_empty + expect(last_email.subject).to eq t('user_mailer.account_reset_complete.subject') + + click_link t('account_reset.confirm_delete_account.link_text') + + expect(page).to have_current_path(sign_up_email_path) end end end diff --git a/spec/features/idv/account_creation_spec.rb b/spec/features/idv/account_creation_spec.rb new file mode 100644 index 00000000000..a76067bc3cd --- /dev/null +++ b/spec/features/idv/account_creation_spec.rb @@ -0,0 +1,9 @@ +require 'rails_helper' + +describe 'LOA3 account creation' do + include IdvHelper + include SamlAuthHelper + + 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 +end diff --git a/spec/features/idv/phone_otp_rate_limiting_spec.rb b/spec/features/idv/phone_otp_rate_limiting_spec.rb new file mode 100644 index 00000000000..713333e2596 --- /dev/null +++ b/spec/features/idv/phone_otp_rate_limiting_spec.rb @@ -0,0 +1,120 @@ +require 'rails_helper' + +feature 'phone otp rate limiting', :idv_job do + include IdvStepHelper + + let(:user) { user_with_2fa } + let(:otp_code) { '777777' } + + before do + allow(Idv::GeneratePhoneConfirmationOtp).to receive(:call).and_return(otp_code) + end + + describe 'otp sends' do + let(:max_attempts) { Figaro.env.otp_delivery_blocklist_maxretry.to_i + 1 } + + it 'rate limits sends from the otp delivery method step' do + start_idv_from_sp + complete_idv_steps_before_phone_otp_delivery_selection_step(user) + + (max_attempts - 1).times do + choose_idv_otp_delivery_method_sms + visit idv_otp_delivery_method_path + end + choose_idv_otp_delivery_method_sms + + expect_max_otp_request_rate_limiting + end + + it 'rate limits resends from the otp verification step' do + start_idv_from_sp + complete_idv_steps_before_phone_otp_verification_step(user) + + (max_attempts - 1).times do + click_on t('links.two_factor_authentication.get_another_code') + end + + expect_max_otp_request_rate_limiting + end + + it 'rate limits sends from the otp delivery methods and verification step in combination' do + send_attempts = max_attempts - 2 + + start_idv_from_sp + complete_idv_steps_before_phone_otp_delivery_selection_step(user) + + # (n - 2)th attempt + send_attempts.times do + choose_idv_otp_delivery_method_sms + visit idv_otp_delivery_method_path + end + + # (n - 1)th attempt + choose_idv_otp_delivery_method_sms + + # nth attempt + click_on t('links.two_factor_authentication.get_another_code') + + expect_max_otp_request_rate_limiting + end + + 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' + ) + + expect_rate_limit_circumvention_to_be_disallowed(user) + expect_rate_limit_to_expire(user) + end + end + + describe 'otp attempts' do + let(:max_attempts) { 3 } + + it 'rate limits otp attempts at the otp verification step' do + start_idv_from_sp + complete_idv_steps_before_phone_otp_verification_step(user) + + max_attempts.times do + fill_in('code', with: 'bad-code') + click_button t('forms.buttons.submit.default') + end + + expect(page).to have_content t('titles.account_locked') + expect(page). + to have_content t('devise.two_factor_authentication.max_otp_login_attempts_reached') + + expect_rate_limit_circumvention_to_be_disallowed(user) + expect_rate_limit_to_expire(user) + end + end + + def expect_rate_limit_circumvention_to_be_disallowed(user) + # Attempting to send another OTP does not send an OTP and shows lockout message + allow(SmsOtpSenderJob).to receive(:perform_now) + allow(SmsOtpSenderJob).to receive(:perform_later) + + start_idv_from_sp + complete_idv_steps_before_phone_otp_delivery_selection_step(user) + + expect(page).to have_content t('titles.account_locked') + expect(SmsOtpSenderJob).to_not have_received(:perform_now) + expect(SmsOtpSenderJob).to_not have_received(:perform_later) + end + + def expect_rate_limit_to_expire(user) + # Returning after session and lockout expires allows you to try again + retry_minutes = Figaro.env.lockout_period_in_minutes.to_i + 1 + Timecop.travel retry_minutes.minutes.from_now do + start_idv_from_sp + complete_idv_steps_before_phone_otp_verification_step(user) + + fill_in(:code, with: otp_code) + click_submit_default + + expect(page).to have_content(t('idv.titles.session.review')) + expect(current_path).to eq(idv_review_path) + end + end +end diff --git a/spec/features/idv/steps/phone_otp_delivery_selection_step_spec.rb b/spec/features/idv/steps/phone_otp_delivery_selection_step_spec.rb index 4639c6bf133..f765761b47c 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 deleivery method selection', :idv_job do +feature 'IdV phone OTP delivery method selection', :idv_job do include IdvStepHelper context 'the users chooses sms' do @@ -13,7 +13,7 @@ choose_idv_otp_delivery_method_sms expect(page).to have_content(t('devise.two_factor_authentication.header_text')) - expect(current_path).to eq(login_two_factor_path(otp_delivery_preference: :sms)) + expect(current_path).to eq(idv_otp_verification_path) end end @@ -27,7 +27,7 @@ choose_idv_otp_delivery_method_voice expect(page).to have_content(t('devise.two_factor_authentication.header_text')) - expect(current_path).to eq(login_two_factor_path(otp_delivery_preference: :voice)) + expect(current_path).to eq(idv_otp_verification_path) end end @@ -49,6 +49,49 @@ end end + it 'does not modify the otp column on the user model when sending an OTP' do + user = user_with_2fa + + start_idv_from_sp + complete_idv_steps_before_phone_otp_delivery_selection_step(user) + + old_direct_otp = user.direct_otp + choose_idv_otp_delivery_method_sms + user.reload + + expect(user.direct_otp).to eq(old_direct_otp) + end + + it 'redirects back to the step with an error if twilio raises an error' do + user = user_with_2fa + + start_idv_from_sp + complete_idv_steps_before_phone_otp_delivery_selection_step(user) + + generic_exception = Twilio::REST::RestError.new( + '', FakeTwilioErrorResponse.new(123) + ) + allow(SmsOtpSenderJob).to receive(:perform_later).and_raise(generic_exception) + + choose_idv_otp_delivery_method_sms + + expect(page).to have_content(t('errors.messages.otp_failed')) + expect(page).to have_current_path(idv_phone_path) + + fill_out_phone_form_ok + click_idv_continue + + calling_area_exception = Twilio::REST::RestError.new( + '', FakeTwilioErrorResponse.new(21_215) + ) + allow(SmsOtpSenderJob).to receive(:perform_later).and_raise(calling_area_exception) + + choose_idv_otp_delivery_method_sms + + expect(page).to have_content(t('errors.messages.invalid_calling_area')) + expect(page).to have_current_path(idv_phone_path) + end + context 'cancelling IdV' do it_behaves_like 'cancel at idv step', :phone_otp_delivery_selection it_behaves_like 'cancel at idv step', :phone_otp_delivery_selection, :oidc 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 86b348aec14..80302062dd1 100644 --- a/spec/features/idv/steps/phone_otp_verification_step_spec.rb +++ b/spec/features/idv/steps/phone_otp_verification_step_spec.rb @@ -3,6 +3,12 @@ feature 'phone otp verification step spec', :idv_job do include IdvStepHelper + let(:otp_code) { '777777' } + + before do + allow(Idv::GeneratePhoneConfirmationOtp).to receive(:call).and_return(otp_code) + end + it 'requires the user to enter the correct otp before continuing' do user = user_with_2fa @@ -11,22 +17,86 @@ # Attempt to bypass the step visit idv_review_path - expect(current_path).to eq(login_two_factor_path(otp_delivery_preference: :sms)) + expect(current_path).to eq(idv_otp_verification_path) # Enter an incorrect otp fill_in 'code', with: '000000' click_submit_default expect(page).to have_content(t('devise.two_factor_authentication.invalid_otp')) - expect(current_path).to eq(login_two_factor_path(otp_delivery_preference: :sms)) + expect(current_path).to eq(idv_otp_verification_path) # Enter the correct code - enter_correct_otp_code_for_user(user) + fill_in 'code', with: '777777' + click_submit_default + + expect(page).to have_content(t('idv.titles.session.review')) + expect(page).to have_current_path(idv_review_path) + end + + it 'rejects OTPs after they are expired' do + expiration_minutes = Figaro.env.otp_valid_for.to_i + 1 + + start_idv_from_sp + complete_idv_steps_before_phone_otp_verification_step + + Timecop.travel(expiration_minutes.minutes.from_now) do + 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_current_path(idv_otp_verification_path) + end + end + + it 'allows the user to resend the otp' do + start_idv_from_sp + complete_idv_steps_before_phone_otp_verification_step + + expect(SmsOtpSenderJob).to receive(:perform_later) + + click_on t('links.two_factor_authentication.get_another_code') + + expect(current_path).to eq(idv_otp_verification_path) + + fill_in 'code', with: '777777' + click_submit_default expect(page).to have_content(t('idv.titles.session.review')) expect(page).to have_current_path(idv_review_path) end + it 'redirects back to the step with an error if twilio raises an error on resend' do + start_idv_from_sp + complete_idv_steps_before_phone_otp_verification_step + + generic_exception = Twilio::REST::RestError.new( + '', FakeTwilioErrorResponse.new(123) + ) + allow(SmsOtpSenderJob).to receive(:perform_later).and_raise(generic_exception) + + click_on t('links.two_factor_authentication.get_another_code') + + expect(page).to have_content(t('errors.messages.otp_failed')) + expect(page).to have_current_path(idv_phone_path) + + allow(SmsOtpSenderJob).to receive(:perform_later).and_call_original + + fill_out_phone_form_ok + click_idv_continue + choose_idv_otp_delivery_method_sms + + calling_area_exception = Twilio::REST::RestError.new( + '', FakeTwilioErrorResponse.new(21_215) + ) + allow(SmsOtpSenderJob).to receive(:perform_later).and_raise(calling_area_exception) + + click_on t('links.two_factor_authentication.get_another_code') + + expect(page).to have_content(t('errors.messages.invalid_calling_area')) + expect(page).to have_current_path(idv_phone_path) + end + context 'cancelling IdV' do it_behaves_like 'cancel at idv step', :phone_otp_verification it_behaves_like 'cancel at idv step', :phone_otp_verification, :oidc diff --git a/spec/features/idv/steps/phone_step_spec.rb b/spec/features/idv/steps/phone_step_spec.rb index bbb61e1eb74..7a3a0bbb20b 100644 --- a/spec/features/idv/steps/phone_step_spec.rb +++ b/spec/features/idv/steps/phone_step_spec.rb @@ -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_configuration.phone) + fill_out_phone_form_ok(user.phone_configurations.first.phone) click_idv_continue expect(page).to have_content(t('idv.titles.session.review')) @@ -71,6 +71,8 @@ end it 'is not re-entrant after confirming OTP' do + allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) + user = user_with_2fa start_idv_from_sp @@ -78,7 +80,7 @@ fill_out_phone_form_ok click_idv_continue choose_idv_otp_delivery_method_sms - enter_correct_otp_code_for_user(user) + click_submit_default visit idv_phone_path expect(page).to have_content(t('idv.titles.session.review')) diff --git a/spec/features/idv/steps/usps_step_spec.rb b/spec/features/idv/steps/usps_step_spec.rb index e8e1686fa0a..fdd31b3cb4b 100644 --- a/spec/features/idv/steps/usps_step_spec.rb +++ b/spec/features/idv/steps/usps_step_spec.rb @@ -43,6 +43,7 @@ def complete_idv_and_return_to_usps_step click_continue click_acknowledge_personal_key visit root_path + click_on t('idv.buttons.cancel') first(:link, t('links.sign_out')).click sign_in_live_with_2fa(user) click_on t('idv.messages.usps.resend') diff --git a/spec/features/idv/usps_disabled_spec.rb b/spec/features/idv/usps_disabled_spec.rb index ee42002642b..c5f59e2ae72 100644 --- a/spec/features/idv/usps_disabled_spec.rb +++ b/spec/features/idv/usps_disabled_spec.rb @@ -18,6 +18,8 @@ end it 'allows verification without the option to confirm address with usps' do + allow(Idv::GeneratePhoneConfirmationOtp).to receive(:call).and_return('777777') + user = user_with_2fa start_idv_from_sp complete_idv_steps_before_phone_step(user) @@ -32,7 +34,8 @@ expect(page).to_not have_content(t('idv.form.activate_by_mail')) choose_idv_otp_delivery_method_sms - enter_correct_otp_code_for_user(user) + fill_in(:code, with: '777777') + click_submit_default fill_in 'Password', with: user.password click_continue click_acknowledge_personal_key diff --git a/spec/features/openid_connect/openid_connect_spec.rb b/spec/features/openid_connect/openid_connect_spec.rb index eeea3adcfa3..314a4ca0a65 100644 --- a/spec/features/openid_connect/openid_connect_spec.rb +++ b/spec/features/openid_connect/openid_connect_spec.rb @@ -437,7 +437,7 @@ def sign_in_get_id_token response_type: 'code', acr_values: Saml::Idp::Constants::LOA1_AUTHN_CONTEXT_CLASSREF, scope: 'openid email', - redirect_uri: 'gov.gsa.openidconnect.test://result/auth', + redirect_uri: 'gov.gsa.openidconnect.test://result', state: state, prompt: 'select_account', nonce: nonce, diff --git a/spec/features/openid_connect/redirect_uri_validation_spec.rb b/spec/features/openid_connect/redirect_uri_validation_spec.rb new file mode 100644 index 00000000000..2a3f4949044 --- /dev/null +++ b/spec/features/openid_connect/redirect_uri_validation_spec.rb @@ -0,0 +1,259 @@ +require 'rails_helper' + +describe 'redirect_uri validation' do + context 'when the redirect_uri in the request does not match one that is registered' do + it 'displays error instead of branded landing page' do + visit_idp_from_sp_with_loa1_with_disallowed_redirect_uri + current_host = URI.parse(page.current_url).host + + expect(current_host).to eq 'www.example.com' + expect(page). + to have_content t('openid_connect.authorization.errors.redirect_uri_no_match') + end + end + + context 'when the redirect_uri is not a valid URI' do + it 'displays error instead of branded landing page' do + visit_idp_from_sp_with_loa1_with_invalid_redirect_uri + current_host = URI.parse(page.current_url).host + + expect(current_host).to eq 'www.example.com' + expect(page). + to have_content t('openid_connect.authorization.errors.redirect_uri_invalid') + end + end + + context 'when the service_provider is not active' do + it 'displays error instead of branded landing page' do + visit_idp_from_inactive_sp + current_host = URI.parse(page.current_url).host + + expect(current_host).to eq 'www.example.com' + expect(page). + to have_content t('openid_connect.authorization.errors.bad_client_id') + end + end + + context 'when redirect_uri is present in params but the request is not from an SP' do + it 'does not provide a link to the redirect_uri' do + visit sign_up_start_path(request_id: '123', redirect_uri: 'evil.com') + + expect(page).to_not have_link t('links.back_to_sp') + + visit new_user_session_path(request_id: '123', redirect_uri: 'evil.com') + + expect(page).to_not have_link t('links.back_to_sp') + end + end + + context 'when new non-SP request with redirect_uri is made after initial SP request' do + it 'does not provide a link to the new redirect_uri' do + state = SecureRandom.hex + visit_idp_from_sp_with_loa1_with_valid_redirect_uri(state: state) + visit sign_up_start_path(request_id: '123', redirect_uri: 'evil.com') + sp_redirect_uri = "http://localhost:7654/auth/result?error=access_denied&state=#{state}" + + expect(page). + to have_link(t('links.back_to_sp', sp: 'Test SP'), href: sp_redirect_uri) + + visit new_user_session_path(request_id: '123', redirect_uri: 'evil.com') + + expect(page). + to have_link(t('links.back_to_sp', sp: 'Test SP'), href: sp_redirect_uri) + end + end + + context 'when the user is already signed in directly' do + it 'displays error instead of redirecting' do + sign_in_and_2fa_user + + visit_idp_from_inactive_sp + current_host = URI.parse(page.current_url).host + + expect(current_host).to eq 'www.example.com' + expect(page). + to have_content t('openid_connect.authorization.errors.bad_client_id') + + visit_idp_from_sp_with_loa1_with_invalid_redirect_uri + current_host = URI.parse(page.current_url).host + + expect(current_host).to eq 'www.example.com' + expect(page). + to have_content t('openid_connect.authorization.errors.redirect_uri_invalid') + + visit_idp_from_sp_with_loa1_with_disallowed_redirect_uri + current_host = URI.parse(page.current_url).host + + expect(current_host).to eq 'www.example.com' + expect(page). + to have_content t('openid_connect.authorization.errors.redirect_uri_no_match') + end + end + + context 'when the user is already signed in via an SP' do + it 'displays error instead of redirecting' do + allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) + user = create(:user, :signed_up) + visit_idp_from_sp_with_loa1_with_valid_redirect_uri + click_link t('links.sign_in') + fill_in_credentials_and_submit(user.email, user.password) + click_submit_default + click_continue + + visit_idp_from_inactive_sp + current_host = URI.parse(page.current_url).host + + expect(current_host).to eq 'www.example.com' + expect(page). + to have_content t('openid_connect.authorization.errors.bad_client_id') + + visit_idp_from_sp_with_loa1_with_invalid_redirect_uri + current_host = URI.parse(page.current_url).host + + expect(current_host).to eq 'www.example.com' + expect(page). + to have_content t('openid_connect.authorization.errors.redirect_uri_invalid') + + visit_idp_from_sp_with_loa1_with_disallowed_redirect_uri + current_host = URI.parse(page.current_url).host + + expect(current_host).to eq 'www.example.com' + expect(page). + to have_content t('openid_connect.authorization.errors.redirect_uri_no_match') + end + end + + context 'when the SP has multiple registered redirect_uris and the second one is requested' do + it 'considers the request valid and redirects to the one requested' do + allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) + user = create(:user, :signed_up) + visit_idp_from_sp_with_loa1_with_second_valid_redirect_uri + click_link t('links.sign_in') + fill_in_credentials_and_submit(user.email, user.password) + click_submit_default + click_continue + + redirect_host = URI.parse(current_url).host + redirect_scheme = URI.parse(current_url).scheme + + expect(redirect_host).to eq('example.com') + expect(redirect_scheme).to eq('https') + end + end + + context 'when the SP does not have any registered redirect_uris' do + it 'considers the request invalid and does not redirect if the user signs in' do + allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) + user = create(:user, :signed_up) + visit_idp_from_sp_that_does_not_have_redirect_uris + current_host = URI.parse(page.current_url).host + + expect(current_host).to eq 'www.example.com' + expect(page). + to have_content t('openid_connect.authorization.errors.redirect_uri_no_match') + + visit new_user_session_path + fill_in_credentials_and_submit(user.email, user.password) + click_submit_default + click_continue + + expect(page).to have_current_path account_path + end + end + + def visit_idp_from_sp_with_loa1_with_disallowed_redirect_uri(state: SecureRandom.hex) + client_id = 'urn:gov:gsa:openidconnect:sp:server' + nonce = SecureRandom.hex + + visit openid_connect_authorize_path( + client_id: client_id, + response_type: 'code', + acr_values: Saml::Idp::Constants::LOA1_AUTHN_CONTEXT_CLASSREF, + scope: 'openid email', + redirect_uri: 'https://example.com.evil.com/auth/result', + state: state, + prompt: 'select_account', + nonce: nonce + ) + end + + def visit_idp_from_sp_with_loa1_with_invalid_redirect_uri(state: SecureRandom.hex) + client_id = 'urn:gov:gsa:openidconnect:sp:server' + nonce = SecureRandom.hex + + visit openid_connect_authorize_path( + client_id: client_id, + response_type: 'code', + acr_values: Saml::Idp::Constants::LOA1_AUTHN_CONTEXT_CLASSREF, + scope: 'openid email', + redirect_uri: ':aaaa', + state: state, + prompt: 'select_account', + nonce: nonce + ) + end + + def visit_idp_from_inactive_sp(state: SecureRandom.hex) + client_id = 'inactive' + nonce = SecureRandom.hex + + visit openid_connect_authorize_path( + client_id: client_id, + response_type: 'code', + acr_values: Saml::Idp::Constants::LOA1_AUTHN_CONTEXT_CLASSREF, + scope: 'openid email', + redirect_uri: 'http://localhost:7654/auth/result', + state: state, + prompt: 'select_account', + nonce: nonce + ) + end + + def visit_idp_from_sp_with_loa1_with_valid_redirect_uri(state: SecureRandom.hex) + client_id = 'urn:gov:gsa:openidconnect:sp:server' + nonce = SecureRandom.hex + + visit openid_connect_authorize_path( + client_id: client_id, + response_type: 'code', + acr_values: Saml::Idp::Constants::LOA1_AUTHN_CONTEXT_CLASSREF, + scope: 'openid email', + redirect_uri: 'http://localhost:7654/auth/result', + state: state, + prompt: 'select_account', + nonce: nonce + ) + end + + def visit_idp_from_sp_with_loa1_with_second_valid_redirect_uri(state: SecureRandom.hex) + client_id = 'urn:gov:gsa:openidconnect:sp:server' + nonce = SecureRandom.hex + + visit openid_connect_authorize_path( + client_id: client_id, + response_type: 'code', + acr_values: Saml::Idp::Constants::LOA1_AUTHN_CONTEXT_CLASSREF, + scope: 'openid email', + redirect_uri: 'https://example.com', + state: state, + prompt: 'select_account', + nonce: nonce + ) + end + + def visit_idp_from_sp_that_does_not_have_redirect_uris(state: SecureRandom.hex) + client_id = 'http://test.host' + nonce = SecureRandom.hex + + visit openid_connect_authorize_path( + client_id: client_id, + response_type: 'code', + acr_values: Saml::Idp::Constants::LOA1_AUTHN_CONTEXT_CLASSREF, + scope: 'openid email', + redirect_uri: 'http://test.host', + state: state, + prompt: 'select_account', + nonce: nonce + ) + end +end diff --git a/spec/features/saml/redirect_uri_validation_spec.rb b/spec/features/saml/redirect_uri_validation_spec.rb new file mode 100644 index 00000000000..8b1c5199428 --- /dev/null +++ b/spec/features/saml/redirect_uri_validation_spec.rb @@ -0,0 +1,27 @@ +require 'rails_helper' + +describe 'redirect_uri validation' do + include SamlAuthHelper + + context 'when redirect_uri param is included in SAML request' do + it 'uses the return_to_sp_url URL and not the redirect_uri' do + allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) + user = create(:user, :signed_up) + visit api_saml_auth_path( + SAMLRequest: CGI.unescape(saml_request(saml_settings)), redirect_uri: 'http://evil.com' + ) + sp = ServiceProvider.find_by(issuer: 'http://localhost:3000') + + expect(page). + to have_link t('links.back_to_sp', sp: sp.friendly_name), href: sp.return_to_sp_url + + click_link t('links.sign_in') + fill_in_credentials_and_submit(user.email, user.password) + click_submit_default + click_continue + click_submit_default + + expect(current_url).to eq sp.acs_url + end + end +end diff --git a/spec/features/sign_in/two_factor_options_spec.rb b/spec/features/sign_in/two_factor_options_spec.rb index c1b49981453..38828d07ca5 100644 --- a/spec/features/sign_in/two_factor_options_spec.rb +++ b/spec/features/sign_in/two_factor_options_spec.rb @@ -43,7 +43,8 @@ context 'when the user only has SMS configured with a number that we cannot call' do it 'only displays SMS and Personal key' do - user = create(:user, :signed_up, otp_delivery_preference: 'sms', phone: '+12423270143') + user = create(:user, :signed_up, + otp_delivery_preference: 'sms', with: { phone: '+12423270143' }) sign_in_user(user) click_link t('two_factor_authentication.login_options_link_text') @@ -63,7 +64,8 @@ context "the user's otp_delivery_preference is voice but number is unsupported" do it 'only displays SMS and Personal key' do - user = create(:user, :signed_up, otp_delivery_preference: 'voice', phone: '+12423270143') + user = create(:user, :signed_up, + otp_delivery_preference: 'voice', with: { phone: '+12423270143' }) sign_in_user(user) click_link t('two_factor_authentication.login_options_link_text') diff --git a/spec/features/two_factor_authentication/change_factor_spec.rb b/spec/features/two_factor_authentication/change_factor_spec.rb index 06a53905f73..3d2cf3aa677 100644 --- a/spec/features/two_factor_authentication/change_factor_spec.rb +++ b/spec/features/two_factor_authentication/change_factor_spec.rb @@ -20,7 +20,7 @@ mailer = instance_double(ActionMailer::MessageDelivery, deliver_later: true) allow(UserMailer).to receive(:phone_changed).with(user).and_return(mailer) - @previous_phone_confirmed_at = user.reload.phone_confirmed_at + @previous_phone_confirmed_at = user.phone_configurations.reload.first.confirmed_at new_phone = '+1 703-555-0100' visit manage_phone_path @@ -39,8 +39,7 @@ enter_incorrect_otp_code expect(page).to have_content t('devise.two_factor_authentication.invalid_otp') - expect(user.reload.phone).to_not eq new_phone - expect(user.reload.phone_configuration.phone).to_not eq new_phone + expect(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,9 +48,8 @@ expect(UserMailer).to have_received(:phone_changed).with(user) expect(mailer).to have_received(:deliver_later) expect(page).to have_content new_phone - expect(user.reload.phone_confirmed_at).to_not eq(@previous_phone_confirmed_at) expect( - user.reload.phone_configuration.confirmed_at + user.phone_configurations.reload.first.confirmed_at ).to_not eq(@previous_phone_confirmed_at) visit login_two_factor_path(otp_delivery_preference: 'sms') @@ -60,7 +58,7 @@ scenario 'editing phone number with no voice otp support only allows sms delivery' do user.update(otp_delivery_preference: 'voice') - user.phone_configuration.update(delivery_preference: 'voice') + user.phone_configurations.first.update(delivery_preference: 'voice') unsupported_phone = '242-327-0143' visit manage_phone_path @@ -83,7 +81,7 @@ allow(SmsOtpSenderJob).to receive(:perform_later) user = sign_in_and_2fa_user - old_phone = user.phone_configuration.phone + old_phone = user.phone_configurations.first.phone visit manage_phone_path update_phone_number @@ -110,7 +108,7 @@ allow(SmsOtpSenderJob).to receive(:perform_later) user = sign_in_and_2fa_user - old_phone = user.phone_configuration.phone + old_phone = user.phone_configurations.first.phone Timecop.travel(Figaro.env.reauthn_window.to_i + 1) do visit manage_phone_path @@ -188,7 +186,7 @@ PhoneVerification.adapter = FakeAdapter allow(FakeAdapter).to receive(:post).and_return(FakeAdapter::ErrorResponse.new) - user = create(:user, :signed_up, phone: '+17035551212') + user = create(:user, :signed_up, with: { phone: '+17035551212' }) visit new_user_session_path sign_in_live_with_2fa(user) 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 297348d158a..888ad1d5a4d 100644 --- a/spec/features/two_factor_authentication/remember_device_spec.rb +++ b/spec/features/two_factor_authentication/remember_device_spec.rb @@ -89,7 +89,7 @@ def remember_device_and_sign_out_user end it 'requires 2FA and does not offer the option to remember device' do - expect(current_path).to eq(login_two_factor_path(otp_delivery_preference: :sms)) + expect(current_path).to eq(idv_otp_verification_path) expect(page).to_not have_content( t('forms.messages.remember_device', duration: Figaro.env.remember_device_expiration_days!) ) diff --git a/spec/features/two_factor_authentication/sign_in_spec.rb b/spec/features/two_factor_authentication/sign_in_spec.rb index a6eaaa5b6a8..31ce0e50b10 100644 --- a/spec/features/two_factor_authentication/sign_in_spec.rb +++ b/spec/features/two_factor_authentication/sign_in_spec.rb @@ -34,8 +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.reload.phone).to_not eq '+1 (703) 555-1212' - expect(user.reload.phone_configuration).to be_nil + expect(user.phone_configurations).to be_empty expect(user.sms?).to eq true end @@ -242,7 +241,7 @@ def submit_prefilled_otp_code scenario 'the user cannot change delivery method if phone is unsupported' do unsupported_phone = '+1 (242) 327-0143' - user = create(:user, :signed_up, phone: unsupported_phone) + user = create(:user, :signed_up, with: { phone: unsupported_phone }) sign_in_before_2fa(user) expect(page).to_not have_link t('links.two_factor_authentication.voice') @@ -350,7 +349,7 @@ def submit_prefilled_otp_code expect(current_path).to eq account_path - phone_fingerprint = Pii::Fingerprinter.fingerprint(user.phone_configuration.phone) + phone_fingerprint = Pii::Fingerprinter.fingerprint(user.phone_configurations.first.phone) rate_limited_phone = OtpRequestsTracker.find_by(phone_fingerprint: phone_fingerprint) # let findtime period expire @@ -374,8 +373,8 @@ def submit_prefilled_otp_code context '2 users with same phone number request OTP too many times within findtime' do it 'locks both users out' do allow(Figaro.env).to receive(:otp_delivery_blocklist_maxretry).and_return('3') - first_user = create(:user, :signed_up, phone: '+1 703-555-1212') - second_user = create(:user, :signed_up, phone: '+1 703-555-1212') + first_user = create(:user, :signed_up, with: { phone: '+1 703-555-1212' }) + second_user = create(:user, :signed_up, with: { phone: '+1 703-555-1212' }) max_attempts = Figaro.env.otp_delivery_blocklist_maxretry.to_i sign_in_before_2fa(first_user) @@ -387,7 +386,9 @@ 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_configuration.phone) + phone_fingerprint = Pii::Fingerprinter.fingerprint( + first_user.phone_configurations.first.phone + ) rate_limited_phone = OtpRequestsTracker.find_by(phone_fingerprint: phone_fingerprint) expect(current_path).to eq otp_send_path @@ -410,7 +411,7 @@ def submit_prefilled_otp_code context 'When setting up 2FA for the first time' do it 'enforces rate limiting only for current phone' do - second_user = create(:user, :signed_up, phone: '202-555-1212') + second_user = create(:user, :signed_up, with: { phone: '202-555-1212' }) sign_in_before_2fa max_attempts = Figaro.env.otp_delivery_blocklist_maxretry.to_i @@ -552,7 +553,7 @@ def submit_prefilled_otp_code PhoneVerification.adapter = FakeAdapter allow(SmsOtpSenderJob).to receive(:perform_later) - user = create(:user, :signed_up, phone: '+212 661-289324') + user = create(:user, :signed_up, with: { phone: '+212 661-289324' }) sign_in_user(user) expect(SmsOtpSenderJob).to have_received(:perform_later).with( @@ -574,7 +575,7 @@ def submit_prefilled_otp_code PhoneVerification.adapter = FakeAdapter allow(FakeAdapter).to receive(:post).and_return(FakeAdapter::ErrorResponse.new) - user = create(:user, :signed_up, phone: '+212 661-289324') + user = create(:user, :signed_up, with: { phone: '+212 661-289324' }) sign_in_user(user) expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'sms') @@ -593,7 +594,9 @@ def submit_prefilled_otp_code PhoneVerification.adapter = FakeAdapter allow(FakeAdapter).to receive(:post).and_return(FakeAdapter::ErrorResponse.new) - user = create(:user, :signed_up, phone: '+17035551212', otp_delivery_preference: 'voice') + user = create(:user, :signed_up, + otp_delivery_preference: 'voice', + with: { phone: '+17035551212', delivery_preference: 'voice' }) sign_in_user(user) expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'voice') 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 9d0d05793a0..fa68d848a1a 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 @@ -3,18 +3,16 @@ feature 'Signing in via one-time use personal key' do it 'destroys old key, displays new one, and redirects to profile after acknowledging' do user = create(:user, :signed_up) - sign_in_before_2fa(user) - - personal_key = PersonalKeyGenerator.new(user).create + raw_key = PersonalKeyGenerator.new(user).create + old_key = user.reload.encrypted_recovery_code_digest + sign_in_before_2fa(user) choose_another_security_option('personal_key') - - enter_personal_key(personal_key: personal_key) - + enter_personal_key(personal_key: raw_key) click_submit_default click_acknowledge_personal_key - expect(user.reload.personal_key).to_not eq personal_key + expect(user.reload.encrypted_recovery_code_digest).to_not eq old_key expect(current_path).to eq account_path end diff --git a/spec/features/users/piv_cac_management_spec.rb b/spec/features/users/piv_cac_management_spec.rb index 1d39546ab11..ceec1f6ca59 100644 --- a/spec/features/users/piv_cac_management_spec.rb +++ b/spec/features/users/piv_cac_management_spec.rb @@ -13,7 +13,7 @@ def find_form(page, attributes) context 'with no piv/cac associated yet' do let(:uuid) { SecureRandom.uuid } - let(:user) { create(:user, :signed_up, phone: '+1 202-555-1212') } + let(:user) { create(:user, :signed_up, :with_phone, with: { phone: '+1 202-555-1212' }) } context 'with a service provider allowed to use piv/cac' do let(:identity_with_sp) do @@ -106,9 +106,9 @@ def find_form(page, attributes) allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) stub_piv_cac_service - user.update(phone: nil, otp_secret_key: 'secret') - user.phone_configuration.destroy - user.phone_configuration = nil + user.update(otp_secret_key: 'secret') + user.phone_configurations.clear + expect(user.phone_configurations).to be_empty sign_in_and_2fa_user(user) visit account_path click_link t('forms.buttons.enable'), href: setup_piv_cac_url @@ -156,7 +156,7 @@ def find_form(page, attributes) scenario "doesn't allow unassociation of a piv/cac" do stub_piv_cac_service - user = create(:user, :signed_up, phone: '+1 202-555-1212') + user = create(:user, :signed_up, :with_phone, with: { phone: '+1 202-555-1212' }) sign_in_and_2fa_user(user) visit account_path form = find_form(page, action: disable_piv_cac_url) @@ -167,7 +167,7 @@ def find_form(page, attributes) context 'with a piv/cac associated and no identities allowing piv/cac' do let(:user) do - create(:user, :signed_up, :with_piv_or_cac, phone: '+1 202-555-1212') + create(:user, :signed_up, :with_piv_or_cac, :with_phone, with: { phone: '+1 202-555-1212' }) end scenario "doesn't allow association of another piv/cac with the account" do diff --git a/spec/features/users/sign_in_spec.rb b/spec/features/users/sign_in_spec.rb index 04f72777622..3e27ae5d831 100644 --- a/spec/features/users/sign_in_spec.rb +++ b/spec/features/users/sign_in_spec.rb @@ -255,19 +255,20 @@ expect { signin(email, password) }. to raise_error Encryption::EncryptionError, 'unable to decrypt attribute with any key' - user = User.find_with_email(email) + user = user.reload expect(user.encrypted_email).to eq encrypted_email end end context 'KMS is on and user enters incorrect password' do it 'redirects to root_path with user-friendly error message, not a 500 error' do + user = create(:user) + email = user.email allow(FeatureManagement).to receive(:use_kms?).and_return(true) stub_aws_kms_client_invalid_ciphertext allow(SessionEncryptorErrorHandler).to receive(:call) - user = create(:user) - signin(user.email, 'invalid') + signin(email, 'invalid') link_url = new_user_password_url @@ -320,7 +321,8 @@ it 'falls back to SMS with an error message' do allow(SmsOtpSenderJob).to receive(:perform_later) allow(VoiceOtpSenderJob).to receive(:perform_later) - user = create(:user, :signed_up, phone: '+1 441-295-9644', otp_delivery_preference: 'voice') + user = create(:user, :signed_up, + otp_delivery_preference: 'voice', with: { phone: '+1 441-295-9644' }) signin(user.email, user.password) expect(VoiceOtpSenderJob).to_not have_received(:perform_later) @@ -339,7 +341,8 @@ it 'displays an error message but does not send an SMS' do allow(SmsOtpSenderJob).to receive(:perform_later) allow(VoiceOtpSenderJob).to receive(:perform_later) - user = create(:user, :signed_up, phone: '+91 1234567890', otp_delivery_preference: 'sms') + user = create(:user, :signed_up, + otp_delivery_preference: 'sms', with: { phone: '+91 1234567890' }) signin(user.email, user.password) visit login_two_factor_path(otp_delivery_preference: 'voice', reauthn: false) @@ -359,7 +362,8 @@ it 'displays an error message but does not send an SMS' do allow(SmsOtpSenderJob).to receive(:perform_later) allow(VoiceOtpSenderJob).to receive(:perform_later) - user = create(:user, :signed_up, phone: '+91 1234567890', otp_delivery_preference: 'sms') + user = create(:user, :signed_up, + otp_delivery_preference: 'sms', with: { phone: '+91 1234567890' }) signin(user.email, user.password) visit otp_send_path( otp_delivery_selection_form: { otp_delivery_preference: 'voice', resend: true } @@ -381,7 +385,8 @@ it 'displays an error message but does not send an SMS' do allow(SmsOtpSenderJob).to receive(:perform_later) allow(VoiceOtpSenderJob).to receive(:perform_later) - user = create(:user, :signed_up, phone: '+91 1234567890', otp_delivery_preference: 'voice') + user = create(:user, :signed_up, + otp_delivery_preference: 'voice', with: { phone: '+91 1234567890' }) signin(user.email, user.password) visit otp_send_path( otp_delivery_selection_form: { otp_delivery_preference: 'voice', resend: true } diff --git a/spec/features/users/user_profile_spec.rb b/spec/features/users/user_profile_spec.rb index 53819705d0d..c3288adbaab 100644 --- a/spec/features/users/user_profile_spec.rb +++ b/spec/features/users/user_profile_spec.rb @@ -65,13 +65,17 @@ end it 'allows credentials to be reused for sign up' do + expect(User.count).to eq 0 pii = { ssn: '1234', dob: '1920-01-01' } profile = create(:profile, :active, :verified, pii: pii) + expect(User.count).to eq 1 sign_in_live_with_2fa(profile.user) visit account_path click_link(t('account.links.delete_account')) click_button t('users.delete.actions.delete') + expect(User.count).to eq 0 + profile = create(:profile, :active, :verified, pii: pii) sign_in_live_with_2fa(profile.user) expect(User.count).to eq 1 diff --git a/spec/features/users/webauthn_management_spec.rb b/spec/features/users/webauthn_management_spec.rb new file mode 100644 index 00000000000..1b7c89156d0 --- /dev/null +++ b/spec/features/users/webauthn_management_spec.rb @@ -0,0 +1,156 @@ +require 'rails_helper' + +feature 'Webauthn Management' do + include WebauthnHelper + + let(:user) { create(:user, :signed_up, with: { phone: '+1 202-555-1212' }) } + + context 'with no webauthn associated yet' do + it 'allows user to add a webauthn configuration' do + mock_challenge + sign_in_and_2fa_user(user) + visit account_path + expect(current_path).to eq account_path + + 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 + click_submit_default + + expect(current_path).to eq account_path + expect(page).to have_content t('notices.webauthn_added') + end + + it 'gives an error if the challenge/secret is incorrect' do + sign_in_and_2fa_user(user) + visit account_path + expect(current_path).to eq account_path + + 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 + click_submit_default + + expect(current_path).to eq account_path + expect(page).to have_content t('errors.webauthn_setup.general_error') + end + + it 'gives an error if the hardware key button has not been pressed' do + mock_challenge + sign_in_and_2fa_user(user) + visit account_path + expect(current_path).to eq account_path + + click_link t('account.index.webauthn_add'), href: webauthn_setup_url + expect(current_path).to eq webauthn_setup_path + + click_submit_default + + expect(current_path).to eq account_path + expect(page).to have_content t('errors.webauthn_setup.general_error') + end + + it 'gives an error if name is taken and stays on the configuration screen' do + mock_challenge + sign_in_and_2fa_user(user) + + visit account_path + expect(current_path).to eq account_path + + 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 + click_submit_default + + expect(current_path).to eq account_path + expect(page).to have_content t('notices.webauthn_added') + + 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 + click_submit_default + + expect(current_path).to eq webauthn_setup_path + expect(page).to have_content t('errors.webauthn_setup.unique_name') + end + + it 'displays a link to add a hardware security key' do + sign_in_and_2fa_user(user) + + visit account_path + expect(page).to have_link(t('account.index.webauthn_add'), href: webauthn_setup_url) + end + end + + context 'with webauthn associations' do + it 'displays the user supplied names of the webauthn keys' do + create_webauthn_configuration(user, 'key1', '1', 'foo1') + create_webauthn_configuration(user, 'key2', '2', 'bar2') + + sign_in_and_2fa_user(user) + visit account_path + + expect(page).to have_content 'key1' + expect(page).to have_content 'key2' + end + + it 'allows the user to delete the webauthn key' do + create_webauthn_configuration(user, 'key1', '1', 'foo1') + + sign_in_and_2fa_user(user) + visit account_path + + expect(page).to have_content 'key1' + + click_button t('account.index.webauthn_delete') + + expect(page).to_not have_content 'key1' + expect(page).to have_content t('notices.webauthn_deleted') + end + + it 'prevents a user from deleting the last key' do + create_webauthn_configuration(user, 'key1', '1', 'foo1') + + sign_in_and_2fa_user(user) + PhoneConfiguration.first.update(mfa_enabled: false) + visit account_path + + expect(page).to have_content 'key1' + + click_button t('account.index.webauthn_delete') + + expect(page).to have_content 'key1' + expect(page).to have_content t('errors.webauthn_setup.delete_last') + 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, + credential_id: id, + name: name) + end +end diff --git a/spec/features/visitors/phone_confirmation_spec.rb b/spec/features/visitors/phone_confirmation_spec.rb index de472a7d4c0..4915e1a9462 100644 --- a/spec/features/visitors/phone_confirmation_spec.rb +++ b/spec/features/visitors/phone_confirmation_spec.rb @@ -22,8 +22,7 @@ it 'updates phone_confirmed_at and redirects to acknowledge personal key' do click_button t('forms.buttons.submit.default') - expect(@user.reload.phone_confirmed_at).to be_present - expect(@user.reload.phone_configuration.confirmed_at).to be_present + expect(@user.phone_configurations.reload.first.confirmed_at).to be_present expect(current_path).to eq sign_up_personal_key_path click_acknowledge_personal_key @@ -61,7 +60,8 @@ @existing_user = create(:user, :signed_up) @user = sign_in_before_2fa select_2fa_option('sms') - fill_in 'user_phone_form_phone', with: @existing_user.phone_configuration.phone + fill_in 'user_phone_form_phone', + with: @existing_user.phone_configurations.detect(&:mfa_enabled?).phone click_send_security_code end @@ -76,8 +76,7 @@ fill_in 'code', with: 'foobar' click_submit_default - expect(@user.reload.phone_confirmed_at).to be_nil - expect(@user.reload.phone_configuration).to be_nil + expect(@user.phone_configurations.reload).to be_empty expect(page).to have_content t('devise.two_factor_authentication.invalid_otp') expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'sms') end diff --git a/spec/forms/idv/phone_confirmation_otp_verification_form_spec.rb b/spec/forms/idv/phone_confirmation_otp_verification_form_spec.rb new file mode 100644 index 00000000000..549c7ca0447 --- /dev/null +++ b/spec/forms/idv/phone_confirmation_otp_verification_form_spec.rb @@ -0,0 +1,107 @@ +require 'rails_helper' + +describe Idv::PhoneConfirmationOtpVerificationForm do + let(:user) { create(:user, :signed_up) } + let(:idv_session) { double(Idv::Session) } + let(:phone_confirmation_otp) { '123456' } + let(:phone_confirmation_otp_sent_at) { Time.zone.now.to_s } + + before do + allow(idv_session).to receive(:phone_confirmation_otp). + and_return(phone_confirmation_otp) + allow(idv_session).to receive(:phone_confirmation_otp_sent_at). + and_return(phone_confirmation_otp_sent_at) + end + + describe '#submit' do + def try_submit(code) + described_class.new( + user: user, idv_session: idv_session + ).submit(code: code) + end + + context 'when the code matches' do + it 'returns a successful result' do + expect(idv_session).to receive(:user_phone_confirmation=).with(true) + + result = try_submit(phone_confirmation_otp) + + expect(result.success?).to eq(true) + end + + it 'clears the second factor attempts' do + expect(idv_session).to receive(:user_phone_confirmation=).with(true) + + user.update(second_factor_attempts_count: 4) + + try_submit(phone_confirmation_otp) + + expect(user.reload.second_factor_attempts_count).to eq(0) + end + end + + context 'when the code does not match' do + it 'returns an unsuccessful result' do + expect(idv_session).to_not receive(:user_phone_confirmation=) + + result = try_submit('xxxxxx') + + expect(result.success?).to eq(false) + end + + it 'increments second factor attempts' do + 2.times do + try_submit('xxxxxx') + end + + user.reload + + expect(user.second_factor_attempts_count).to eq(2) + expect(user.second_factor_locked_at).to eq(nil) + + try_submit('xxxxxx') + + expect(user.second_factor_attempts_count).to eq(3) + expect(user.second_factor_locked_at).to be_within(1.second).of(Time.zone.now) + end + end + + context 'when the code is expired' do + let(:phone_confirmation_otp_sent_at) { 11.minutes.ago.to_s } + + it 'returns an unsuccessful result' do + expect(idv_session).to_not receive(:user_phone_confirmation=) + + result = try_submit(phone_confirmation_otp) + + expect(result.success?).to eq(false) + end + + it 'increment second factor attempts and locks out user after too many' do + 2.times do + try_submit(phone_confirmation_otp) + end + + user.reload + + expect(user.second_factor_attempts_count).to eq(2) + expect(user.second_factor_locked_at).to eq(nil) + + try_submit(phone_confirmation_otp) + + expect(user.second_factor_attempts_count).to eq(3) + expect(user.second_factor_locked_at).to be_within(1.second).of(Time.zone.now) + end + end + + it 'handles nil and empty codes' do + result = try_submit(nil) + + expect(result.success?).to eq(false) + + result = try_submit('') + + expect(result.success?).to eq(false) + end + end +end diff --git a/spec/forms/idv/phone_form_spec.rb b/spec/forms/idv/phone_form_spec.rb index 00412d34256..22667076b30 100644 --- a/spec/forms/idv/phone_form_spec.rb +++ b/spec/forms/idv/phone_form_spec.rb @@ -45,21 +45,21 @@ expected_params = { phone: '2025551212', - phone_confirmed_at: user.phone_configuration.confirmed_at, + 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, phone: '7035551234') + user = build_stubbed(:user, :signed_up, with: { phone: '7035551234' }) subject = Idv::PhoneForm.new({}, 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, phone: '+81 54 354 3643') + user = build_stubbed(:user, :signed_up, with: { phone: '+81 54 354 3643' }) subject = Idv::PhoneForm.new({}, user) expect(subject.phone).to eq(nil) diff --git a/spec/forms/openid_connect_authorize_form_spec.rb b/spec/forms/openid_connect_authorize_form_spec.rb index 6522593872d..4ff1fa7fd0e 100644 --- a/spec/forms/openid_connect_authorize_form_spec.rb +++ b/spec/forms/openid_connect_authorize_form_spec.rb @@ -180,9 +180,9 @@ end end - context 'with a redirect_uri that adds on to the registered redirect_uri' do + context 'with a redirect_uri that only partially matches any registered redirect_uri' do let(:redirect_uri) { 'gov.gsa.openidconnect.test://result/more/extra' } - it { expect(valid?).to eq(true) } + it { expect(valid?).to eq(false) } end end diff --git a/spec/forms/openid_connect_logout_form_spec.rb b/spec/forms/openid_connect_logout_form_spec.rb index 1a385df42ed..09f9914e908 100644 --- a/spec/forms/openid_connect_logout_form_spec.rb +++ b/spec/forms/openid_connect_logout_form_spec.rb @@ -137,7 +137,7 @@ it 'is not valid' do expect(valid?).to eq(false) - expect(form.errors[:post_logout_redirect_uri]).to be_present + expect(form.errors[:redirect_uri]).to be_present end end @@ -146,7 +146,7 @@ it 'is not valid' do expect(valid?).to eq(false) - expect(form.errors[:post_logout_redirect_uri]). + expect(form.errors[:redirect_uri]). to include(t('openid_connect.authorization.errors.redirect_uri_no_match')) end end diff --git a/spec/forms/personal_key_form_spec.rb b/spec/forms/personal_key_form_spec.rb index 6398f43997d..83120fc7bb9 100644 --- a/spec/forms/personal_key_form_spec.rb +++ b/spec/forms/personal_key_form_spec.rb @@ -6,7 +6,7 @@ it 'returns FormResponse with success: true' do user = create(:user) raw_code = PersonalKeyGenerator.new(user).create - old_key = user.reload.personal_key + old_code = user.reload.encrypted_recovery_code_digest form = PersonalKeyForm.new(user, raw_code) result = instance_double(FormResponse) @@ -15,7 +15,7 @@ expect(FormResponse).to receive(:new). with(success: true, errors: {}, extra: extra).and_return(result) expect(form.submit).to eq result - expect(user.reload.personal_key).to eq old_key + expect(user.reload.encrypted_recovery_code_digest).to eq old_code end end @@ -31,7 +31,7 @@ expect(FormResponse).to receive(:new). with(success: false, errors: errors, extra: extra).and_return(result) expect(form.submit).to eq result - expect(user.personal_key).to_not be_nil + expect(user.encrypted_recovery_code_digest).to_not be_nil expect(form.personal_key).to be_nil end end diff --git a/spec/forms/user_phone_form_spec.rb b/spec/forms/user_phone_form_spec.rb index b64bd59bd11..5badd5ac3e8 100644 --- a/spec/forms/user_phone_form_spec.rb +++ b/spec/forms/user_phone_form_spec.rb @@ -17,19 +17,19 @@ it 'loads initial values from the user object' do user = build_stubbed( - :user, - phone: '+1 (703) 500-5000', + :user, :with_phone, + with: { phone: '+1 (703) 500-5000' }, otp_delivery_preference: 'voice' ) subject = UserPhoneForm.new(user) - expect(subject.phone).to eq(user.phone_configuration.phone) + expect(subject.phone).to eq(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, phone: '+81 744 21 1234') + user = build_stubbed(:user, :with_phone, with: { phone: '+81 744 21 1234' }) subject = UserPhoneForm.new(user) expect(subject.international_code).to eq('JP') @@ -78,8 +78,7 @@ subject.submit(params) user.reload - expect(user.phone).to_not eq('+1 504 444 1643') - expect(user.phone_configuration).to be_nil + expect(user.phone_configurations).to be_empty end it 'preserves the format of the submitted phone number if phone is invalid' do @@ -212,10 +211,21 @@ end it 'returns false if the user phone has not changed' do - params[:phone] = user.phone_configuration.phone + params[:phone] = user.phone_configurations.first.phone subject.submit(params) expect(subject.phone_changed?).to eq(false) end + + context 'when a user has no phone' do + it 'returns true' do + user.phone_configurations.clear + + params[:phone] = '+1 504 444 1643' + subject.submit(params) + + expect(subject.phone_changed?).to eq(true) + end + end end end diff --git a/spec/forms/webauthn_setup_form_spec.rb b/spec/forms/webauthn_setup_form_spec.rb new file mode 100644 index 00000000000..7eda6bd7158 --- /dev/null +++ b/spec/forms/webauthn_setup_form_spec.rb @@ -0,0 +1,42 @@ +require 'rails_helper' + +describe WebauthnSetupForm do + include WebauthnHelper + + let(:user) { create(:user) } + let(:user_session) { { webauthn_challenge: challenge } } + let(:subject) { WebauthnSetupForm.new(user, user_session) } + + describe '#submit' do + 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 = { + attestation_object: attestation_object, + client_data_json: client_data_json, + name: 'mykey', + } + + 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 = { + attestation_object: attestation_object, + client_data_json: client_data_json, + name: 'mykey', + } + + 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/lib/feature_management_spec.rb b/spec/lib/feature_management_spec.rb index cd2b68e5120..a9e61d67ba6 100644 --- a/spec/lib/feature_management_spec.rb +++ b/spec/lib/feature_management_spec.rb @@ -439,4 +439,26 @@ end end end + + describe '#webauthn_enabled?' do + context 'when enabled' do + before do + allow(Figaro.env).to receive(:webauthn_enabled).and_return('true') + end + + it 'enables the feature' do + expect(FeatureManagement.webauthn_enabled?).to eq(true) + end + end + + context 'when disabled' do + before do + allow(Figaro.env).to receive(:webauthn_enabled).and_return('false') + end + + it 'disables the feature' do + expect(FeatureManagement.webauthn_enabled?).to eq(false) + end + end + end end diff --git a/spec/lib/tasks/dev_rake_spec.rb b/spec/lib/tasks/dev_rake_spec.rb index 8b3078bfb56..5c40b182417 100644 --- a/spec/lib/tasks/dev_rake_spec.rb +++ b/spec/lib/tasks/dev_rake_spec.rb @@ -12,7 +12,7 @@ it 'runs successfully' do Rake::Task['dev:prime'].invoke - expect(User.count).to eq 2 + expect(User.count).to eq 3 end end diff --git a/spec/lib/tasks/rotate_rake_spec.rb b/spec/lib/tasks/rotate_rake_spec.rb index 505df392fe6..0f757beb498 100644 --- a/spec/lib/tasks/rotate_rake_spec.rb +++ b/spec/lib/tasks/rotate_rake_spec.rb @@ -2,7 +2,7 @@ require 'rake' describe 'rotate' do - let(:user) { create(:user, phone: '703-555-5555') } + let(:user) { create(:user, :with_phone, with: { phone: '703-555-5555' }) } before do Rake.application.rake_require('lib/tasks/rotate', [Rails.root.to_s]) Rake::Task.define_task(:environment) @@ -15,26 +15,20 @@ describe 'attribute_encryption_key' do it 'runs successfully' do old_email = user.email - old_phone = user.phone + old_phone = user.phone_configurations.first.phone old_encrypted_email = user.encrypted_email - old_encrypted_phone = user.encrypted_phone - old_encrypted_configuration_phone = user.phone_configuration.encrypted_phone + old_encrypted_phone = user.phone_configurations.first.encrypted_phone rotate_attribute_encryption_key Rake::Task['rotate:attribute_encryption_key'].execute user.reload - user.phone_configuration.reload - expect(user.phone).to eq old_phone - expect(user.phone_configuration.phone).to eq old_phone + user.phone_configurations.reload + expect(user.phone_configurations.first.phone).to eq old_phone expect(user.email).to eq old_email expect(user.encrypted_email).to_not eq old_encrypted_email - expect(user.encrypted_phone).to_not eq old_encrypted_phone - expect(user.phone_configuration.encrypted_phone).to_not eq old_encrypted_configuration_phone - expect(user.phone_configuration.phone).to eq user.phone - # this double checks that we're not using the same IV for both - expect(user.phone_configuration.encrypted_phone).to_not eq user.encrypted_phone + expect(user.phone_configurations.first.encrypted_phone).to_not eq old_encrypted_phone end it 'does not raise an exception when encrypting/decrypting a user' do diff --git a/spec/models/account_reset_request_spec.rb b/spec/models/account_reset_request_spec.rb index 404c7259986..1e1f9bee061 100644 --- a/spec/models/account_reset_request_spec.rb +++ b/spec/models/account_reset_request_spec.rb @@ -50,25 +50,4 @@ expect(subject.granted_token_expired?).to eq(false) end end - - describe '.from_valid_granted_token' do - it 'returns nil if the token does not exist' do - expect(AccountResetRequest.from_valid_granted_token('123')).to eq(nil) - end - - it 'returns nil if the token is expired' do - granted_at = Time.zone.now - 7.days - AccountResetRequest.create(id: 1, user_id: 2, granted_token: '123', granted_at: granted_at) - - expect(AccountResetRequest.from_valid_granted_token('123')).to eq(nil) - end - - it 'returns the record if the token is valid' do - arr = AccountResetRequest.create( - id: 1, user_id: 2, granted_token: '123', granted_at: Time.zone.now - ) - - expect(AccountResetRequest.from_valid_granted_token('123')).to eq(arr) - end - end end diff --git a/spec/models/phone_configuration_spec.rb b/spec/models/phone_configuration_spec.rb index 4bef0f7e17f..32b37b31acd 100644 --- a/spec/models/phone_configuration_spec.rb +++ b/spec/models/phone_configuration_spec.rb @@ -12,8 +12,23 @@ let(:phone_configuration) { create(:phone_configuration, phone: phone) } describe 'creation' do - it 'stores an encrypted form of the password' do + it 'stores an encrypted form of the phone number' do expect(phone_configuration.encrypted_phone).to_not be_blank end end + + describe 'encrypted attributes' do + it 'decrypts phone' do + expect(phone_configuration.phone).to eq phone + end + + context 'with unnormalized phone' do + let(:phone) { ' 555 555 5555 ' } + let(:normalized_phone) { '555 555 5555' } + + it 'normalizes phone' do + expect(phone_configuration.phone).to eq normalized_phone + end + end + end end diff --git a/spec/models/profile_spec.rb b/spec/models/profile_spec.rb index a64f522c80c..35524caa210 100644 --- a/spec/models/profile_spec.rb +++ b/spec/models/profile_spec.rb @@ -30,12 +30,12 @@ it 'generates new personal key' do expect(profile.encrypted_pii_recovery).to be_nil - initial_personal_key = user.personal_key + initial_personal_key = user.encrypted_recovery_code_digest profile.encrypt_pii(pii, user.password) expect(profile.encrypted_pii_recovery).to_not be_nil - expect(user.personal_key).to_not eq initial_personal_key + expect(user.reload.encrypted_recovery_code_digest).to_not eq initial_personal_key end end @@ -43,13 +43,13 @@ it 'generates new personal key' do expect(profile.encrypted_pii_recovery).to be_nil - initial_personal_key = user.personal_key + initial_personal_key = user.encrypted_recovery_code_digest profile.encrypt_recovery_pii(pii) expect(profile.encrypted_pii_recovery).to_not be_nil - expect(user.personal_key).to_not eq initial_personal_key - expect(profile.personal_key).to_not eq user.personal_key + expect(user.reload.encrypted_recovery_code_digest).to_not eq initial_personal_key + expect(profile.personal_key).to_not eq user.encrypted_recovery_code_digest end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index bd029347a3b..0a31acfe469 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -11,7 +11,8 @@ it { is_expected.to have_many(:profiles) } it { is_expected.to have_many(:events) } it { is_expected.to have_one(:account_reset_request) } - it { is_expected.to have_one(:phone_configuration) } + it { is_expected.to have_many(:phone_configurations) } + it { is_expected.to have_many(:webauthn_configurations) } end it 'does not send an email when #create is called' do @@ -375,20 +376,11 @@ expect(user.email).to eq 'foo@example.org' end - - it 'normalizes phone' do - user = create(:user, phone: ' 555 555 5555 ') - - expect(user.phone).to eq '555 555 5555' - expect(user.phone_configuration.phone).to eq '555 555 5555' - end end - it 'decrypts phone and otp_secret_key' do - user = create(:user, phone: '+1 (202) 555-1212', otp_secret_key: 'abc123') + it 'decrypts otp_secret_key' do + user = create(:user, otp_secret_key: 'abc123') - expect(user.phone).to eq '+1 (202) 555-1212' - expect(user.phone_configuration.phone).to eq '+1 (202) 555-1212' expect(user.otp_secret_key).to eq 'abc123' end end diff --git a/spec/policies/personal_key_for_new_user_policy_spec.rb b/spec/policies/personal_key_for_new_user_policy_spec.rb new file mode 100644 index 00000000000..27064f50b77 --- /dev/null +++ b/spec/policies/personal_key_for_new_user_policy_spec.rb @@ -0,0 +1,55 @@ +require 'rails_helper' + +describe PersonalKeyForNewUserPolicy do + describe '#show_personal_key_after_initial_2fa_setup?' do + context 'user has no personal key and made LOA1 request' do + it 'returns true' do + user = User.new + session = { sp: { loa3: false } } + policy = PersonalKeyForNewUserPolicy.new(user: user, session: session) + + expect(policy.show_personal_key_after_initial_2fa_setup?).to eq true + end + end + + context 'user has no personal key and visited the site directly' do + it 'returns true' do + user = User.new + session = {} + policy = PersonalKeyForNewUserPolicy.new(user: user, session: session) + + expect(policy.show_personal_key_after_initial_2fa_setup?).to eq true + end + end + + context 'user has a personal key' do + it 'returns false' do + user = User.new(personal_key: 'foo') + session = {} + policy = PersonalKeyForNewUserPolicy.new(user: user, session: session) + + expect(policy.show_personal_key_after_initial_2fa_setup?).to eq false + end + end + + context 'user does not have a personal key and made an LOA3 request' do + it 'returns false' do + user = User.new + session = { sp: { loa3: true } } + policy = PersonalKeyForNewUserPolicy.new(user: user, session: session) + + expect(policy.show_personal_key_after_initial_2fa_setup?).to eq false + end + end + + context 'user has a personal key and made an LOA3 request' do + it 'returns false' do + user = User.new(personal_key: 'foo') + session = { sp: { loa3: true } } + policy = PersonalKeyForNewUserPolicy.new(user: user, session: session) + + expect(policy.show_personal_key_after_initial_2fa_setup?).to eq false + end + end + end +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 878b0759c4b..4650062ec07 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 @@ -7,11 +7,10 @@ let(:data) do { confirmation_for_phone_change: false, - confirmation_for_idv: false, phone_number: '5555559876', code_value: '999999', otp_delivery_preference: 'sms', - reenter_phone_number_path: '/idv/phone', + reenter_phone_number_path: '/manage/phone', unconfirmed_phone: true, totp_enabled: false, personal_key_unavailable: true, @@ -45,11 +44,6 @@ data[:confirmation_for_phone_change] = true expect(presenter.cancel_link).to eq account_path end - - it 'returns the verification cancel path during identity verification' do - data[:confirmation_for_idv] = true - expect(presenter.cancel_link).to eq idv_cancel_path - end end describe '#phone_number_message' do @@ -63,14 +57,6 @@ end end - def presenter_with_locale(locale) - TwoFactorAuthCode::PhoneDeliveryPresenter.new( - data: data.clone.merge(reenter_phone_number_path: - "#{locale == :en ? nil : '/' + locale.to_s}/idv/phone"), - view: view - ) - end - def account_reset_cancel_link(account_reset_token) I18n.t('devise.two_factor_authentication.account_reset.pending_html', cancel_link: view.link_to(t('devise.two_factor_authentication.account_reset.cancel_link'), diff --git a/spec/requests/edit_user_spec.rb b/spec/requests/edit_user_spec.rb index 4a015d25343..057c8ed7894 100644 --- a/spec/requests/edit_user_spec.rb +++ b/spec/requests/edit_user_spec.rb @@ -4,7 +4,7 @@ include Features::MailerHelper include Features::ActiveJobHelper - let(:user) { create(:user, :signed_up, phone: '+1 (202) 555-1213') } + let(:user) { create(:user, :signed_up, with: { phone: '+1 (202) 555-1213' }) } def user_session session['warden.user.user.session'] diff --git a/spec/requests/openid_connect_authorize_spec.rb b/spec/requests/openid_connect_authorize_spec.rb index 8b5dcb1e1a0..efd42de8c34 100644 --- a/spec/requests/openid_connect_authorize_spec.rb +++ b/spec/requests/openid_connect_authorize_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' describe 'user signs in partially and visits openid_connect/authorize' do - let(:user) { create(:user, :signed_up, phone: '+1 (202) 555-1213') } + let(:user) { create(:user, :signed_up, with: { phone: '+1 (202) 555-1213' }) } it 'prompts the user to 2FA' do openid_test('select_account') diff --git a/spec/services/account_reset/cancel_spec.rb b/spec/services/account_reset/cancel_spec.rb index 377acedfc91..85bd56db94e 100644 --- a/spec/services/account_reset/cancel_spec.rb +++ b/spec/services/account_reset/cancel_spec.rb @@ -21,6 +21,10 @@ 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) + end + it 'notifies the user via SMS of the account reset cancellation' do token = create_account_reset_request_for(user) allow(SmsAccountResetCancellationNotifierJob).to receive(:perform_now) @@ -28,7 +32,7 @@ AccountReset::Cancel.new(token).call expect(SmsAccountResetCancellationNotifierJob). - to have_received(:perform_now).with(phone: user.phone_configuration.phone) + to have_received(:perform_now).with(phone: user.phone_configurations.first.phone) end end @@ -36,9 +40,7 @@ it 'does not notify the user via SMS' do token = create_account_reset_request_for(user) allow(SmsAccountResetCancellationNotifierJob).to receive(:perform_now) - user.update!(phone: nil) - user.phone_configuration.destroy! - user.reload + user.phone_configurations.clear AccountReset::Cancel.new(token).call @@ -84,7 +86,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.update!(phone: nil) + user.phone_configurations.first.update!(mfa_enabled: false) AccountReset::Cancel.new('foo').call diff --git a/spec/services/agency_seeder_spec.rb b/spec/services/agency_seeder_spec.rb index 280592200a9..ff5a3780a8b 100644 --- a/spec/services/agency_seeder_spec.rb +++ b/spec/services/agency_seeder_spec.rb @@ -16,7 +16,7 @@ it 'inserts agencies in the proper order from agencies.yml' do run - expect(Agency.find_by(id: 1).name).to eq('CBP') + expect(Agency.find_by(id: 1).name).to eq('DHS') expect(Agency.find_by(id: 2).name).to eq('OPM') expect(Agency.find_by(id: 3).name).to eq('EOP') end @@ -29,7 +29,7 @@ it 'updates the attributes based on the current value of the yml file' do expect(Agency.find_by(id: 1).name).to eq('FOO') run - expect(Agency.find_by(id: 1).name).to eq('CBP') + expect(Agency.find_by(id: 1).name).to eq('DHS') end end diff --git a/spec/services/idv/send_phone_confirmation_otp_spec.rb b/spec/services/idv/send_phone_confirmation_otp_spec.rb new file mode 100644 index 00000000000..1acb84c319e --- /dev/null +++ b/spec/services/idv/send_phone_confirmation_otp_spec.rb @@ -0,0 +1,121 @@ +require 'rails_helper' + +describe Idv::SendPhoneConfirmationOtp do + let(:phone) { '2255555000' } + let(:parsed_phone) { '+1 225-555-5000' } + let(:otp_delivery_preference) { 'sms' } + let(:phone_confirmation_otp) { '777777' } + let(:idv_session) { Idv::Session.new(user_session: {}, current_user: user, issuer: '') } + + let(:user) { create(:user, :signed_up) } + + let(:exceeded_otp_send_limit) { false } + let(:otp_rate_limiter) { OtpRateLimiter.new(user: user, phone: phone) } + + before do + # Setup Idv::Session + idv_session.params[:phone] = phone + idv_session.phone_confirmation_otp_delivery_method = otp_delivery_preference + + # Mock Idv::GeneratePhoneConfirmationOtp + allow(Idv::GeneratePhoneConfirmationOtp).to receive(:call). + and_return(phone_confirmation_otp) + + # Mock OtpRateLimiter + allow(OtpRateLimiter).to receive(:new).with(user: user, phone: parsed_phone). + and_return(otp_rate_limiter) + allow(otp_rate_limiter).to receive(:exceeded_otp_send_limit?). + and_return(exceeded_otp_send_limit) + end + + subject { described_class.new(user: user, idv_session: idv_session, locale: 'en') } + + describe '#call' do + context 'with sms' do + it 'sends an sms' do + allow(SmsOtpSenderJob).to receive(:perform_later) + + result = subject.call + + expect(result.success?).to eq(true) + + sent_at = Time.zone.parse(idv_session.phone_confirmation_otp_sent_at) + + expect(idv_session.phone_confirmation_otp).to eq(phone_confirmation_otp) + expect(sent_at).to be_within(1.second).of(Time.zone.now) + expect(SmsOtpSenderJob).to have_received(:perform_later).with( + otp_created_at: idv_session.phone_confirmation_otp_sent_at, + code: phone_confirmation_otp, + phone: parsed_phone, + message: 'jobs.sms_otp_sender_job.verify_message', + locale: 'en' + ) + end + end + + context 'with voice' do + let(:otp_delivery_preference) { 'voice' } + + it 'makes a phone call' do + allow(VoiceOtpSenderJob).to receive(:perform_later) + + result = subject.call + + expect(result.success?).to eq(true) + + sent_at = Time.zone.parse(idv_session.phone_confirmation_otp_sent_at) + + expect(idv_session.phone_confirmation_otp).to eq(phone_confirmation_otp) + expect(sent_at).to be_within(1.second).of(Time.zone.now) + expect(VoiceOtpSenderJob).to have_received(:perform_later).with( + otp_created_at: idv_session.phone_confirmation_otp_sent_at, + code: phone_confirmation_otp, + phone: parsed_phone, + locale: 'en' + ) + end + end + + context 'when the user has requested too many otps' do + let(:exceeded_otp_send_limit) { true } + + it 'does not make a phone call or send an sms' do + expect(SmsOtpSenderJob).to_not receive(:perform_later) + expect(SmsOtpSenderJob).to_not receive(:perform_now) + expect(VoiceOtpSenderJob).to_not receive(:perform_later) + expect(VoiceOtpSenderJob).to_not receive(:perform_now) + + result = subject.call + + expect(result.success?).to eq(false) + expect(idv_session.phone_confirmation_otp).to be_nil + expect(idv_session.phone_confirmation_otp_sent_at).to be_nil + end + end + end + + describe '#user_locked_out?' do + before do + allow(otp_rate_limiter).to receive(:exceeded_otp_send_limit?). + and_return(exceeded_otp_send_limit) + end + + context 'the user is locked out' do + let(:exceeded_otp_send_limit) { true } + + it 'returns true' do + subject.call + + expect(subject.user_locked_out?).to eq(true) + end + end + + context 'the user is not locked out' do + it 'returns false' do + subject.call + + expect(subject.user_locked_out?).to be_falsey + end + end + end +end diff --git a/spec/services/key_rotator/attribute_encryption_spec.rb b/spec/services/key_rotator/attribute_encryption_spec.rb index 7e1e49c8a12..a863b46bf1e 100644 --- a/spec/services/key_rotator/attribute_encryption_spec.rb +++ b/spec/services/key_rotator/attribute_encryption_spec.rb @@ -2,25 +2,22 @@ describe KeyRotator::AttributeEncryption do describe '#rotate' do + let(:rotator) { described_class.new(user) } + let(:user) { create(:user) } + it 're-encrypts email and phone' do - user = create(:user, phone: '213-555-5555') - rotator = described_class.new(user) old_encrypted_email = user.encrypted_email - old_encrypted_phone = user.encrypted_phone rotate_attribute_encryption_key rotator.rotate expect(user.encrypted_email).to_not eq old_encrypted_email - expect(user.encrypted_phone).to_not eq old_encrypted_phone end it 'does not change the `updated_at` timestamp' do - user = create(:user) old_updated_timestamp = user.updated_at rotate_attribute_encryption_key - rotator = described_class.new(user) rotator.rotate expect(user.updated_at).to eq old_updated_timestamp diff --git a/spec/services/link_agency_identities_spec.rb b/spec/services/link_agency_identities_spec.rb index 0567bc90c76..9bbdf500291 100644 --- a/spec/services/link_agency_identities_spec.rb +++ b/spec/services/link_agency_identities_spec.rb @@ -45,7 +45,7 @@ create_identity(user, 'urn:gov:gsa:openidconnect:test', 'UUID2') LinkAgencyIdentities.new.link report = LinkAgencyIdentities.report - expect(report[0]['name']).to eq('CBP') + expect(report[0]['name']).to eq('DHS') expect(report[0]['old_uuid']).to eq('UUID2') expect(report[0]['new_uuid']).to eq('UUID1') expect(report.cmd_tuples).to eq(1) @@ -56,7 +56,7 @@ create_identity(user, 'http://localhost:3000', 'UUID1') LinkAgencyIdentities.new.link report = LinkAgencyIdentities.report - expect(report[0]['name']).to eq('CBP') + expect(report[0]['name']).to eq('DHS') expect(report[0]['old_uuid']).to eq('UUID2') expect(report[0]['new_uuid']).to eq('UUID1') expect(report.cmd_tuples).to eq(1) diff --git a/spec/services/openid_connect_redirector_spec.rb b/spec/services/openid_connect_redirector_spec.rb deleted file mode 100644 index 0c1c621e956..00000000000 --- a/spec/services/openid_connect_redirector_spec.rb +++ /dev/null @@ -1,150 +0,0 @@ -require 'rails_helper' - -RSpec.describe OpenidConnectRedirector do - include Rails.application.routes.url_helpers - - let(:redirect_uri) { 'http://localhost:7654/' } - let(:state) { SecureRandom.hex } - let(:service_provider) { ServiceProvider.from_issuer('urn:gov:gsa:openidconnect:sp:server') } - let(:errors) { ActiveModel::Errors.new(nil) } - - subject(:redirector) do - OpenidConnectRedirector.new( - redirect_uri: redirect_uri, - service_provider: service_provider, - state: state, - errors: errors - ) - end - - describe '.from_request_url' do - it 'builds a redirector from an OpenID request_url' do - request_url = openid_connect_authorize_url( - client_id: service_provider.issuer, - redirect_uri: redirect_uri, - state: state - ) - - result = OpenidConnectRedirector.from_request_url(request_url) - - expect(result).to be_a(OpenidConnectRedirector) - expect(result.send(:redirect_uri)).to eq(redirect_uri) - expect(result.send(:service_provider)).to eq(service_provider) - expect(result.send(:state)).to eq(state) - end - end - - describe '#validate' do - context 'with a redirect_uri that spoofs a hostname' do - let(:redirect_uri) { 'https://example.com.evilish.com/' } - - it 'is invalid' do - redirector.validate - expect(errors[:redirect_uri]). - to include(t('openid_connect.authorization.errors.redirect_uri_no_match')) - end - end - - context 'with a valid redirect_uri' do - let(:redirect_uri) { 'http://localhost:7654/result/more/extra' } - it 'is valid' do - redirector.validate - expect(errors).to be_empty - end - end - - context 'with a malformed redirect_uri' do - let(:redirect_uri) { ':aaaa' } - it 'has errors' do - redirector.validate - expect(errors[:redirect_uri]). - to include(t('openid_connect.authorization.errors.redirect_uri_invalid')) - end - end - - context 'with a redirect_uri not registered to the service provider' do - let(:redirect_uri) { 'http://localhost:3000/test' } - it 'has errors' do - redirector.validate - expect(errors[:redirect_uri]). - to include(t('openid_connect.authorization.errors.redirect_uri_no_match')) - end - end - end - - describe '#success_redirect_uri' do - it 'adds the code and state to the URL' do - code = SecureRandom.hex - expect(redirector.success_redirect_uri(code: code)). - to eq(URIService.add_params(redirect_uri, code: code, state: state)) - end - end - - describe '#decline_redirect_uri' do - it 'adds the state and access_denied to the URL' do - expect(redirector.decline_redirect_uri). - to eq(URIService.add_params(redirect_uri, state: state, error: 'access_denied')) - end - end - - describe '#error_redirect_uri' do - before { expect(errors).to receive(:full_messages).and_return(['some attribute is missing']) } - - it 'adds the errors to the URL' do - expect(redirector.error_redirect_uri). - to eq(URIService.add_params(redirect_uri, - state: state, - error: 'invalid_request', - error_description: 'some attribute is missing')) - end - end - - describe '#logout_redirect_uri' do - it 'adds the state to the URL' do - expect(redirector.logout_redirect_uri). - to eq(URIService.add_params(redirect_uri, state: state)) - end - end - - describe '#validated_input_redirect_uri' do - let(:service_provider) { ServiceProvider.new(redirect_uris: redirect_uris, active: true) } - - subject(:validated_input_redirect_uri) { redirector.validated_input_redirect_uri } - - context 'when the service provider has no redirect URIs' do - let(:redirect_uris) { [] } - - it 'is nil' do - expect(validated_input_redirect_uri).to be_nil - end - end - - context 'when the service provider has 2 redirect URIs' do - let(:redirect_uris) { %w[http://localhost:1234/result my-app://result] } - - context 'when a URL matching the first redirect_uri is passed in' do - let(:redirect_uri) { 'http://localhost:1234/result/more' } - - it 'is that URL' do - expect(validated_input_redirect_uri).to eq(redirect_uri) - end - end - - context 'when a URL matching the second redirect_uri is passed in' do - let(:redirect_uri) { 'my-app://result/more' } - - it 'is that URL' do - expect(validated_input_redirect_uri).to eq(redirect_uri) - end - end - - context 'when a URL matching the neither redirect_uri is passed in' do - let(:redirect_uri) { 'https://example.com' } - - it 'is nil' do - expect(validated_input_redirect_uri).to be_nil - end - end - end - end -end diff --git a/spec/services/otp_delivery_preference_updater_spec.rb b/spec/services/otp_delivery_preference_updater_spec.rb index d8b28dc88b4..aa48a0e1e1b 100644 --- a/spec/services/otp_delivery_preference_updater_spec.rb +++ b/spec/services/otp_delivery_preference_updater_spec.rb @@ -40,38 +40,6 @@ end end - context 'with idv context' do - context 'when otp_delivery_preference is the same as the user otp_delivery_preference' do - it 'does not update the user' do - user = build_stubbed(:user, otp_delivery_preference: 'sms') - updater = OtpDeliveryPreferenceUpdater.new( - user: user, - preference: 'sms', - context: 'idv' - ) - - expect(UpdateUser).to_not receive(:new) - - updater.call - end - end - - context 'when otp_delivery_preference is different from the user otp_delivery_preference' do - it 'does not update the user' do - user = build_stubbed(:user, otp_delivery_preference: 'voice') - updater = OtpDeliveryPreferenceUpdater.new( - user: user, - preference: 'sms', - context: 'idv' - ) - - expect(UpdateUser).to_not receive(:new) - - updater.call - end - end - end - context 'when user is nil' do it 'does not update the user' do updater = OtpDeliveryPreferenceUpdater.new( diff --git a/spec/services/otp_rate_limiter_spec.rb b/spec/services/otp_rate_limiter_spec.rb index 0a3c37268e4..51ce271c314 100644 --- a/spec/services/otp_rate_limiter_spec.rb +++ b/spec/services/otp_rate_limiter_spec.rb @@ -3,10 +3,12 @@ RSpec.describe OtpRateLimiter do let(:current_user) { build(:user, :with_phone) } subject(:otp_rate_limiter) do - OtpRateLimiter.new(phone: current_user.phone_configuration.phone, user: current_user) + OtpRateLimiter.new(phone: current_user.phone_configurations.first.phone, user: current_user) end - let(:phone_fingerprint) { Pii::Fingerprinter.fingerprint(current_user.phone_configuration.phone) } + let(:phone_fingerprint) do + Pii::Fingerprinter.fingerprint(current_user.phone_configurations.first.phone) + end let(:rate_limited_phone) { OtpRequestsTracker.find_by(phone_fingerprint: phone_fingerprint) } describe '#exceeded_otp_send_limit?' do @@ -27,7 +29,9 @@ describe '#increment' do it 'updates otp_last_sent_at' do - tracker = OtpRequestsTracker.find_or_create_with_phone(current_user.phone_configuration.phone) + tracker = OtpRequestsTracker.find_or_create_with_phone( + current_user.phone_configurations.first.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/pii/cacher_spec.rb b/spec/services/pii/cacher_spec.rb index bf5c62c1507..ec31714a24c 100644 --- a/spec/services/pii/cacher_spec.rb +++ b/spec/services/pii/cacher_spec.rb @@ -38,7 +38,7 @@ old_ssn_signature = profile.ssn_signature old_email_fingerprint = user.email_fingerprint old_encrypted_email = user.encrypted_email - old_encrypted_phone = user.encrypted_phone + old_encrypted_phone = user.phone_configurations.first.encrypted_phone old_encrypted_otp_secret_key = user.encrypted_otp_secret_key rotate_all_keys @@ -55,7 +55,7 @@ expect(user.email_fingerprint).to_not eq old_email_fingerprint expect(user.encrypted_email).to_not eq old_encrypted_email expect(profile.ssn_signature).to_not eq old_ssn_signature - expect(user.encrypted_phone).to_not eq old_encrypted_phone + expect(user.phone_configurations.first.encrypted_phone).to_not eq old_encrypted_phone expect(user.encrypted_otp_secret_key).to_not eq old_encrypted_otp_secret_key end diff --git a/spec/services/populate_phone_configurations_table_spec.rb b/spec/services/populate_phone_configurations_table_spec.rb deleted file mode 100644 index 19bd60bbffd..00000000000 --- a/spec/services/populate_phone_configurations_table_spec.rb +++ /dev/null @@ -1,48 +0,0 @@ -require 'rails_helper' - -describe PopulatePhoneConfigurationsTable do - let(:subject) { described_class.new } - - describe '#call' do - context 'a user with no phone' do - let!(:user) { create(:user) } - - it 'migrates nothing' do - subject.call - expect(user.reload.phone_configuration).to be_nil - end - end - - context 'a user with a phone' do - let!(:user) { create(:user, :with_phone) } - - context 'and no phone_configuration entry' do - before(:each) do - user.phone_configuration.delete - user.reload - end - - it 'migrates without decrypting and re-encrypting' do - expect(EncryptedAttribute).to_not receive(:new) - subject.call - end - - it 'migrates the phone' do - subject.call - configuration = user.reload.phone_configuration - expect(configuration.phone).to eq user.phone - expect(configuration.confirmed_at).to eq user.phone_confirmed_at - expect(configuration.delivery_preference).to eq user.otp_delivery_preference - end - end - - context 'and an existing phone_configuration entry' do - it 'adds no new rows' do - expect(PhoneConfiguration.where(user_id: user.id).count).to eq 1 - subject.call - expect(PhoneConfiguration.where(user_id: user.id).count).to eq 1 - end - end - end - end -end diff --git a/spec/services/remember_device_cookie_spec.rb b/spec/services/remember_device_cookie_spec.rb index 98bc69a4c02..d076b462fd7 100644 --- a/spec/services/remember_device_cookie_spec.rb +++ b/spec/services/remember_device_cookie_spec.rb @@ -2,7 +2,7 @@ describe RememberDeviceCookie do let(:phone_confirmed_at) { 90.days.ago } - let(:user) { create(:user, :with_phone, phone_confirmed_at: phone_confirmed_at) } + let(:user) { create(:user, :with_phone, with: { confirmed_at: phone_confirmed_at }) } let(:created_at) { Time.zone.now } subject { described_class.new(user_id: user.id, created_at: created_at) } @@ -74,7 +74,7 @@ context 'when the token does not refer to the current user' do it 'returns false' do - other_user = create(:user, phone_confirmed_at: 90.days.ago) + other_user = create(:user, :with_phone, with: { confirmed_at: 90.days.ago }) expect(subject.valid_for_user?(other_user)).to eq(false) end diff --git a/spec/services/twilio_service_spec.rb b/spec/services/twilio_service_spec.rb index 36a577875c0..d8cc7409821 100644 --- a/spec/services/twilio_service_spec.rb +++ b/spec/services/twilio_service_spec.rb @@ -40,10 +40,15 @@ TwilioService::Utils.telephony_service = Twilio::REST::Client end - it 'uses a real Twilio client' do + it 'uses a real Twilio client with timeout' do + allow(Figaro.env).to receive(:twilio_timeout).and_return('1') client = instance_double(Twilio::REST::Client) + twilio_http_client = instance_double(Twilio::HTTP::Client) + expect(Twilio::HTTP::Client).to receive(:new).with(timeout: 1).and_return(twilio_http_client) expect(Twilio::REST::Client). - to receive(:new).with(/sid(1|2)/, /token(1|2)/).and_return(client) + to receive(:new). + with(/sid(1|2)/, /token(1|2)/, nil, nil, twilio_http_client). + and_return(client) http_client = Struct.new(:adapter) expect(client).to receive(:http_client).and_return(http_client) expect(http_client).to receive(:adapter=).with(:typhoeus) @@ -95,6 +100,21 @@ expect { service.place_call(to: '+123456789012', url: 'https://twimlet.com') }. to raise_error(Twilio::REST::RestError, sanitized_message) end + + it 'rescues timeout errors and 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). + and_raise(Faraday::TimeoutError) + + expect { service.place_call(to: '+123456789012', url: 'https://twimlet.com') }. + to raise_error(Twilio::REST::RestError, message) + end end describe '#send_sms' do @@ -137,5 +157,20 @@ expect { service.send_sms(to: '+1 (888) 555-5555', body: 'test') }. to raise_error(Twilio::REST::RestError, sanitized_message) end + + it 'rescues timeout errors and 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). + and_raise(Faraday::TimeoutError) + + expect { service.send_sms(to: '+123456789012', body: 'test') }. + to raise_error(Twilio::REST::RestError, message) + end end end diff --git a/spec/services/update_user_spec.rb b/spec/services/update_user_spec.rb index 80153d22192..cd55785b766 100644 --- a/spec/services/update_user_spec.rb +++ b/spec/services/update_user_spec.rb @@ -25,17 +25,17 @@ } updater = UpdateUser.new(user: user, attributes: attributes) updater.call - phone_configuration = user.reload.phone_configuration + phone_configuration = user.phone_configurations.reload.first expect(phone_configuration.delivery_preference).to eq 'voice' expect(phone_configuration.confirmed_at).to eq confirmed_at expect(phone_configuration.phone).to eq '+1 222 333-4444' end - it 'deletes the phone configuration' do + it 'does not delete the phone configuration' do attributes = { phone: nil } updater = UpdateUser.new(user: user, attributes: attributes) updater.call - expect(user.reload.phone_configuration).to be_nil + expect(user.phone_configurations.reload).to_not be_empty end end @@ -50,7 +50,7 @@ } updater = UpdateUser.new(user: user, attributes: attributes) updater.call - phone_configuration = user.reload.phone_configuration + phone_configuration = user.phone_configurations.reload.first expect(phone_configuration.delivery_preference).to eq 'voice' expect(phone_configuration.confirmed_at).to eq confirmed_at expect(phone_configuration.phone).to eq '+1 222 333-4444' diff --git a/spec/support/fake_sms.rb b/spec/support/fake_sms.rb index 1e4e3c62862..f884cf2e64d 100644 --- a/spec/support/fake_sms.rb +++ b/spec/support/fake_sms.rb @@ -5,7 +5,7 @@ class FakeSms cattr_accessor :messages self.messages = [] - def initialize(_account_sid, _auth_token); end + def initialize(_username, _password, _account_sid, _region, _http_client); end def messages self diff --git a/spec/support/fake_voice_call.rb b/spec/support/fake_voice_call.rb index 4a5512a3e0b..f5afeb89093 100644 --- a/spec/support/fake_voice_call.rb +++ b/spec/support/fake_voice_call.rb @@ -4,7 +4,7 @@ class FakeVoiceCall cattr_accessor :calls self.calls = [] - def initialize(_account_sid, _auth_token); end + def initialize(_username, _password, _account_sid, _region, _http_client); end def calls self diff --git a/spec/support/features/idv_step_helper.rb b/spec/support/features/idv_step_helper.rb index be7ad433e6d..879fdd3b932 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_configuration.phone) + fill_out_phone_form_ok(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 6decb223a87..5d8f458b1a2 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_configuration.present? + if user.phone_configurations.any? Warden.on_next_request do |proxy| session = proxy.env['rack.session'] session['warden.user.user.session'] = {} @@ -110,12 +110,12 @@ def sign_in_and_2fa_user(user = user_with_2fa) end def user_with_2fa - create(:user, :signed_up, phone: '+1 202-555-1212', password: VALID_PASSWORD) + create(:user, :signed_up, with: { phone: '+1 202-555-1212' }, password: VALID_PASSWORD) end def user_with_piv_cac create(:user, :signed_up, :with_piv_or_cac, - phone: '+1 (703) 555-0000', + with: { phone: '+1 (703) 555-0000' }, password: VALID_PASSWORD) end @@ -394,7 +394,7 @@ def register_user(email = 'test@test.com') def confirm_email_and_password(email) allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) - click_link t('sign_up.registrations.create_account') + find_link(t('sign_up.registrations.create_account')).click submit_form_with_valid_email(email) click_confirmation_link_in_email(email) submit_form_with_valid_password diff --git a/spec/support/features/webauthn_helper.rb b/spec/support/features/webauthn_helper.rb new file mode 100644 index 00000000000..e079499f2b4 --- /dev/null +++ b/spec/support/features/webauthn_helper.rb @@ -0,0 +1,36 @@ +module WebauthnHelper + def protocol + 'http://' + end + + def attestation_object + <<~HEREDOC + o2NmbXRoZmlkby11MmZnYXR0U3RtdKJjc2lnWEcwRQIhALPWZKH5+O5MbcTX/si5CWbYExXTgRGmZ3BYDHEQ0zM2AiBLZ + rHCEXeifub4u0QT2CsIzNF0JfZ42BjI7SLzd33FXGN4NWOBWQLCMIICvjCCAaagAwIBAgIEdIb9wjANBgkqhkiG9w0BAQ + sFADAuMSwwKgYDVQQDEyNZdWJpY28gVTJGIFJvb3QgQ0EgU2VyaWFsIDQ1NzIwMDYzMTAgFw0xNDA4MDEwMDAwMDBaGA8 + yMDUwMDkwNDAwMDAwMFowbzELMAkGA1UEBhMCU0UxEjAQBgNVBAoMCVl1YmljbyBBQjEiMCAGA1UECwwZQXV0aGVudGlj + YXRvciBBdHRlc3RhdGlvbjEoMCYGA1UEAwwfWXViaWNvIFUyRiBFRSBTZXJpYWwgMTk1NTAwMzg0MjBZMBMGByqGSM49A + gEGCCqGSM49AwEHA0IABJVd8633JH0xde/9nMTzGk6HjrrhgQlWYVD7OIsuX2Unv1dAmqWBpQ0KxS8YRFwKE1SKE1PIpO + WacE5SO8BN6+2jbDBqMCIGCSsGAQQBgsQKAgQVMS4zLjYuMS40LjEuNDE0ODIuMS4xMBMGCysGAQQBguUcAgEBBAQDAgU + gMCEGCysGAQQBguUcAQEEBBIEEPigEfOMCk0VgAYXER+e3H0wDAYDVR0TAQH/BAIwADANBgkqhkiG9w0BAQsFAAOCAQEA + MVxIgOaaUn44Zom9af0KqG9J655OhUVBVW+q0As6AIod3AH5bHb2aDYakeIyyBCnnGMHTJtuekbrHbXYXERIn4aKdkPSK + lyGLsA/A+WEi+OAfXrNVfjhrh7iE6xzq0sg4/vVJoywe4eAJx0fS+Dl3axzTTpYl71Nc7p/NX6iCMmdik0pAuYJegBcTc + kE3AoYEg4K99AM/JaaKIblsbFh8+3LxnemeNf7UwOczaGGvjS6UzGVI0Odf9lKcPIwYhuTxM5CaNMXTZQ7xq4/yTfC3kP + WtE4hFT34UJJflZBiLrxG4OsYxkHw/n5vKgmpspB3GfYuYTWhkDKiE8CYtyg87mhhdXRoRGF0YVjESZYN5YgOjGh0NBcP + ZHZgW4/krrmihjLHmVzzuoMdl2NBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQKqDS1W7h4/KNbFPClTaqeglJdkHUe6OWQIZo + 5iJsTY+Aomll+hR+iMpbRxiKuuK3pYDcJ0dg3Gk2/zXB+4o+LalAQIDJiABIVggH/apoWRf+cr+ViGgqizMcQFz3WTsQA + Q+bgj5ZDl+d1giWCA+Q7Uff+TEiSLXuT/OtsPil4gRy1ITS4tv8m6n1JLYlw== + HEREDOC + end + + def client_data_json + <<~HEREDOC + eyJjaGFsbGVuZ2UiOiJncjEycndSVVVIWnFvNkZFSV9ZbEFnIiwib3JpZ2luIjoiaHR0cDovL2xvY2FsaG9zdDozMDAwI + iwidHlwZSI6IndlYmF1dGhuLmNyZWF0ZSJ9 + HEREDOC + end + + def challenge + [130, 189, 118, 175, 4, 84, 80, 118, 106, 163, 161, 68, 35, 246, 37, 2] + end +end diff --git a/spec/support/shared_examples/account_creation.rb b/spec/support/shared_examples/account_creation.rb index 141d16f4772..dde4eaf5b47 100644 --- a/spec/support/shared_examples/account_creation.rb +++ b/spec/support/shared_examples/account_creation.rb @@ -69,6 +69,39 @@ end end +shared_examples 'creating an LOA3 account using authenticator app for 2FA' do |sp| + it 'does not prompt for recovery code before IdV flow', email: true, idv_job: true do + visit_idp_from_sp_with_loa3(sp) + register_user_with_authenticator_app + 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 + shared_examples 'creating an account using PIV/CAC for 2FA' do |sp| it 'redirects to the SP', email: true do allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) diff --git a/spec/support/shared_examples/sign_in.rb b/spec/support/shared_examples/sign_in.rb index 9c0353c20af..564464611c5 100644 --- a/spec/support/shared_examples/sign_in.rb +++ b/spec/support/shared_examples/sign_in.rb @@ -174,7 +174,8 @@ it 'does not allow bypassing setting up backup phone' do stub_piv_cac_service - user = create(:user, :signed_up, :with_piv_or_cac, phone: nil) + user = create(:user, :signed_up, :with_piv_or_cac) + 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_phone_validation.rb b/spec/support/shared_examples_for_phone_validation.rb index cf2c732b093..2d8e33503b8 100644 --- a/spec/support/shared_examples_for_phone_validation.rb +++ b/spec/support/shared_examples_for_phone_validation.rb @@ -13,13 +13,13 @@ describe 'phone uniqueness' do context 'when phone is already taken' do it 'is valid' do - second_user = build_stubbed(:user, :signed_up, phone: '+1 (202) 555-1213') + 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: second_user.phone_configuration.phone + phone_configuration: { phone: second_user.phone_configurations.first.phone } ).and_return(true) - params[:phone] = second_user.phone_configuration.phone + params[:phone] = second_user.phone_configurations.first.phone result = subject.submit(params) expect(result).to be_kind_of(FormResponse) @@ -37,9 +37,8 @@ context 'when phone is same as current user' do it 'is valid' do - user.phone = '+1 (703) 500-5000' - user.phone_configuration.phone = '+1 (703) 500-5000' - params[:phone] = user.phone_configuration.phone + user.phone_configurations.first.phone = '+1 (703) 500-5000' + params[:phone] = 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 ed88e19f435..3036c9b524e 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_configuration.phone) + fill_out_phone_form_ok(user.phone_configurations.detect(&:mfa_enabled?).phone) click_idv_continue fill_in :user_password, with: user.password click_continue diff --git a/spec/views/account_reset/confirm_delete_account/show.html.slim_spec.rb b/spec/views/account_reset/confirm_delete_account/show.html.slim_spec.rb index 795f8092ccd..3118353bb4f 100644 --- a/spec/views/account_reset/confirm_delete_account/show.html.slim_spec.rb +++ b/spec/views/account_reset/confirm_delete_account/show.html.slim_spec.rb @@ -25,7 +25,7 @@ expect(rendered).to have_link( t('account_reset.confirm_delete_account.link_text', app: APP_NAME), - href: root_path + href: sign_up_email_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 30aa35e91b6..2f39b20e64e 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 @@ -7,7 +7,7 @@ phone_number: '***-***-1212', code_value: '12777', unconfirmed_user: false, - reenter_phone_number_path: idv_phone_path, + reenter_phone_number_path: manage_phone_path, } end @@ -255,7 +255,7 @@ render - expect(rendered).to have_link(t('forms.two_factor.try_again'), href: idv_phone_path) + expect(rendered).to have_link(t('forms.two_factor.try_again'), href: manage_phone_path) end end @@ -270,7 +270,7 @@ render - expect(rendered).to have_link(t('forms.two_factor.try_again'), href: idv_phone_path) + expect(rendered).to have_link(t('forms.two_factor.try_again'), href: manage_phone_path) end end end 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 04d24969a7d..18312877504 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_configuration&.mfa_enabled? + phone_enabled: user.phone_configurations.any?(&:mfa_enabled?) ) end