diff --git a/.circleci/config.yml b/.circleci/config.yml index 7cfe8417626..05fadbdae3d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -8,7 +8,7 @@ jobs: parallelism: 4 docker: # Specify the Ruby version you desire here - - image: circleci/ruby:2.3-node-browsers + - image: circleci/ruby:2.5.1-node-browsers environment: RAILS_ENV: test CC_TEST_REPORTER_ID: faecd27e9aed532634b3f4d3e251542d7de9457cfca96a94208a63270ef9b42e diff --git a/.reek b/.reek index 2eb24e1fbf1..f13c3d4d284 100644 --- a/.reek +++ b/.reek @@ -69,7 +69,6 @@ LongParameterList: - Idv::ProoferJob#perform - Idv::VendorResult#initialize - JWT - - Pii::Attributes#self.new_from_encrypted RepeatedConditional: exclude: - Users::ResetPasswordsController @@ -100,6 +99,7 @@ TooManyStatements: - Idv::Agent#proof - Idv::Proofer#configure_vendors - Idv::VendorResult#initialize + - SamlIdpController#auth - Upaya::QueueConfig#self.choose_queue_adapter - Upaya::RandomTools#self.random_weighted_sample - UserFlowFormatter#stop diff --git a/.rubocop.yml b/.rubocop.yml index 2b018c90d03..da4f8abaa80 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -10,17 +10,15 @@ AllCops: - '**/Rakefile' - '**/Capfile' Exclude: + - 'bin/**/*' + - 'db/migrate/*' - 'db/schema.rb' - - 'node_modules/**/*' - 'lib/rspec/user_flow_formatter.rb' + - 'lib/tasks/create_test_accounts.rb' - 'lib/user_flow_exporter.rb' - - 'scripts/load_testing/*' - - 'spec/**/*' + - 'node_modules/**/*' - 'tmp/**/*' - - 'bin/**/*' - - 'db/migrate/*' - - 'lib/tasks/create_test_accounts.rb' - TargetRubyVersion: 2.3 + TargetRubyVersion: 2.5 TargetRailsVersion: 5.1 UseCache: true @@ -65,6 +63,7 @@ Metrics/ClassLength: - app/controllers/users/confirmations_controller.rb - app/controllers/users/sessions_controller.rb - app/controllers/devise/two_factor_authentication_controller.rb + - app/decorators/service_provider_session_decorator.rb - app/decorators/user_decorator.rb - app/services/analytics.rb - app/services/idv/session.rb @@ -103,10 +102,13 @@ Metrics/ModuleLength: Metrics/ParameterLists: CountKeywordArgs: false -# This is a Rails 5 feature, so it should be disabled until we upgrade +Naming/VariableName: + Exclude: + - 'spec/services/pii/nist_encryption_spec.rb' + Rails/HttpPositionalArguments: Description: 'Use keyword arguments instead of positional arguments in http method calls.' - Enabled: false + Enabled: true Include: - 'spec/**/*' - 'test/**/*' diff --git a/.ruby-version b/.ruby-version index bb576dbde10..95e3ba81920 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.3 +2.5 diff --git a/Dockerfile b/Dockerfile index dfae4e98b1a..59a60b732b2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Use the official Ruby image because the Rails images have been deprecated -FROM ruby:2.3 +FROM ruby:2.5 # Install packages of https RUN apt-get update && apt-get install apt-transport-https @@ -15,13 +15,16 @@ RUN apt-get update \ RUN ln -s ../node/bin/node /usr/local/bin/ RUN ln -s ../node/bin/npm /usr/local/bin/ -RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \ - && echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list \ - && apt-get update && apt-get install yarn + +ADD https://dl.yarnpkg.com/debian/pubkey.gpg /tmp/yarn-pubkey.gpg +RUN apt-key add /tmp/yarn-pubkey.gpg && rm /tmp/yarn-pubkey.gpg +RUN echo 'deb http://dl.yarnpkg.com/debian/ stable main' > /etc/apt/sources.list.d/yarn.list +RUN apt-get update && apt-get install -y --no-install-recommends yarn WORKDIR /upaya COPY package.json /upaya +COPY yarn.lock /upaya COPY Gemfile /upaya COPY Gemfile.lock /upaya diff --git a/Gemfile b/Gemfile index 0443d2b3269..ba40671bc09 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,7 @@ source 'https://rubygems.org' git_source(:github) { |repo_name| "https://github.com/#{repo_name}.git" } -ruby '~> 2.3.7' +ruby '~> 2.5.1' gem 'rails', '~> 5.1.3' @@ -49,7 +49,7 @@ gem 'saml_idp', git: 'https://github.com/18F/saml_idp.git', tag: 'v0.7.0-18f' gem 'sass-rails', '~> 5.0' gem 'savon' gem 'scrypt' -gem 'secure_headers', '~> 3.0' +gem 'secure_headers', '~> 6.0' gem 'sidekiq' gem 'simple_form' gem 'sinatra', require: false @@ -114,7 +114,7 @@ group :test do end group :production do - gem 'aamva', git: 'git@github.com:18F/identity-aamva-api-client-gem', tag: 'v3.0.0' + gem 'aamva', git: 'git@github.com:18F/identity-aamva-api-client-gem', tag: 'v3.0.1' gem 'equifax', git: 'git@github.com:18F/identity-equifax-api-client-gem.git', tag: 'v1.1.0' gem 'lexisnexis', git: 'git@github.com:18F/identity-lexisnexis-api-client-gem', tag: 'v1.0.0' end diff --git a/Gemfile.lock b/Gemfile.lock index d7b1ca1e185..7bf3ea4dc70 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,9 +1,9 @@ GIT remote: git@github.com:18F/identity-aamva-api-client-gem - revision: 41cf170a0161883f3a4a34f5a5edbb186a36bc06 - tag: v3.0.0 + revision: 015186dd86691294404229ee051cfcf9e87fb6c7 + tag: v3.0.1 specs: - aamva (3.0.0) + aamva (3.0.1) dotenv hashie httpi @@ -272,7 +272,7 @@ GEM fasterer (0.4.1) colorize (~> 0.7) ruby_parser (~> 3.11.0) - ffi (1.9.23) + ffi (1.9.25) ffi-compiler (1.0.1) ffi (>= 1.0.0) rake @@ -415,7 +415,7 @@ GEM rack-headers_filter (0.0.1) rack-mini-profiler (1.0.0) rack (>= 1.2.0) - rack-protection (2.0.1) + rack-protection (2.0.2) rack rack-proxy (0.6.4) rack @@ -543,8 +543,7 @@ GEM wasabi (~> 3.4) scrypt (3.0.5) ffi-compiler (>= 1.0, < 2.0) - secure_headers (3.7.3) - useragent + secure_headers (6.0.0) selenium-webdriver (3.11.0) childprocess (~> 0.5) rubyzip (~> 1.2) @@ -565,10 +564,10 @@ GEM json (>= 1.8, < 3) simplecov-html (~> 0.10.0) simplecov-html (0.10.0) - sinatra (2.0.1) + sinatra (2.0.2) mustermann (~> 1.0) rack (~> 2.0) - rack-protection (= 2.0.1) + rack-protection (= 2.0.2) tilt (~> 2.0) slim (3.0.9) temple (>= 0.7.6, < 0.9) @@ -626,7 +625,6 @@ GEM unicode-display_width (1.3.0) uniform_notifier (1.11.0) user_agent_parser (2.4.1) - useragent (0.16.8) uuid (2.3.9) macaddr (~> 1.0) valid_email (0.1.0) @@ -754,7 +752,7 @@ DEPENDENCIES sass-rails (~> 5.0) savon scrypt - secure_headers (~> 3.0) + secure_headers (~> 6.0) shoulda-matchers (~> 3.0) sidekiq simple_form @@ -779,7 +777,7 @@ DEPENDENCIES zxcvbn-js RUBY VERSION - ruby 2.3.7p456 + ruby 2.5.1p57 BUNDLED WITH 1.16.1 diff --git a/app/assets/images/2FA-sms.svg b/app/assets/images/2FA-sms.svg new file mode 100644 index 00000000000..07817c9c190 --- /dev/null +++ b/app/assets/images/2FA-sms.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/2FA-voice.svg b/app/assets/images/2FA-voice.svg new file mode 100644 index 00000000000..951120fb53a --- /dev/null +++ b/app/assets/images/2FA-voice.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/alert/fail-x.svg b/app/assets/images/alert/fail-x.svg new file mode 100644 index 00000000000..c8028d21093 --- /dev/null +++ b/app/assets/images/alert/fail-x.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/alert/temp-lock.svg b/app/assets/images/alert/temp-lock.svg new file mode 100644 index 00000000000..4a6f7ac72d7 --- /dev/null +++ b/app/assets/images/alert/temp-lock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/sp-logos/doe.png b/app/assets/images/sp-logos/doe.png new file mode 100644 index 00000000000..cf097696f31 Binary files /dev/null and b/app/assets/images/sp-logos/doe.png differ diff --git a/app/assets/images/sp-logos/usaid.png b/app/assets/images/sp-logos/usaid.png new file mode 100644 index 00000000000..be27e210b75 Binary files /dev/null and b/app/assets/images/sp-logos/usaid.png differ diff --git a/app/controllers/account_recovery_setup_controller.rb b/app/controllers/account_recovery_setup_controller.rb new file mode 100644 index 00000000000..eb22586fee4 --- /dev/null +++ b/app/controllers/account_recovery_setup_controller.rb @@ -0,0 +1,18 @@ +class AccountRecoverySetupController < ApplicationController + include AccountRecoverable + include UserAuthenticator + + before_action :confirm_two_factor_authenticated + + def index + return redirect_to account_url unless piv_cac_enabled_but_not_phone_enabled? + @two_factor_options_form = TwoFactorOptionsForm.new(current_user) + @presenter = account_recovery_options_presenter + end + + private + + def account_recovery_options_presenter + AccountRecoveryOptionsPresenter.new + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 27a4cd2ea96..ab0d3cc5335 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -164,7 +164,7 @@ def confirm_two_factor_authenticated end def prompt_to_set_up_2fa - redirect_to phone_setup_url + redirect_to two_factor_options_url end def prompt_to_enter_otp @@ -184,12 +184,17 @@ def sp_session end def render_not_found - render template: 'pages/page_not_found', layout: false, status: 404, formats: :html + render template: 'pages/page_not_found', layout: false, status: :not_found, formats: :html end def render_timeout(exception) analytics.track_event(Analytics::RESPONSE_TIMED_OUT, analytics_exception_info(exception)) - render template: 'pages/page_took_too_long', layout: false, status: 503, formats: :html + render template: 'pages/page_took_too_long', + layout: false, status: :service_unavailable, formats: :html + end + + def render_full_width(template, **opts) + render template, **opts, layout: 'base' end def analytics_exception_info(exception) diff --git a/app/controllers/concerns/account_recoverable.rb b/app/controllers/concerns/account_recoverable.rb new file mode 100644 index 00000000000..5d80977bfff --- /dev/null +++ b/app/controllers/concerns/account_recoverable.rb @@ -0,0 +1,5 @@ +module AccountRecoverable + def piv_cac_enabled_but_not_phone_enabled? + current_user.piv_cac_enabled? && !current_user.phone_enabled? + end +end diff --git a/app/controllers/concerns/authorizable.rb b/app/controllers/concerns/authorizable.rb new file mode 100644 index 00000000000..524f3bff347 --- /dev/null +++ b/app/controllers/concerns/authorizable.rb @@ -0,0 +1,11 @@ +module Authorizable + def authorize_user + return unless current_user.phone_enabled? + + if user_fully_authenticated? + redirect_to account_url + elsif current_user.two_factor_enabled? + redirect_to user_two_factor_authentication_url + end + end +end diff --git a/app/controllers/concerns/two_factor_authenticatable.rb b/app/controllers/concerns/two_factor_authenticatable.rb index 7d2fdc3b948..a1b64bce45b 100644 --- a/app/controllers/concerns/two_factor_authenticatable.rb +++ b/app/controllers/concerns/two_factor_authenticatable.rb @@ -26,22 +26,21 @@ def authenticate_user def handle_second_factor_locked_user(type) analytics.track_event(Analytics::MULTI_FACTOR_AUTH_MAX_ATTEMPTS) - decorator = current_user.decorate - sign_out - render( - 'two_factor_authentication/shared/max_login_attempts_reached', - locals: { type: type, decorator: decorator } - ) + handle_max_attempts(type + '_login_attempts') end def handle_too_many_otp_sends analytics.track_event(Analytics::MULTI_FACTOR_AUTH_MAX_SENDS) - decorator = current_user.decorate - sign_out - render( - 'two_factor_authentication/shared/max_otp_requests_reached', - locals: { decorator: decorator } + handle_max_attempts('otp_requests') + 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 require_current_password @@ -254,6 +253,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_enabled?, }.merge(generic_data) end diff --git a/app/controllers/concerns/unconfirmed_user_concern.rb b/app/controllers/concerns/unconfirmed_user_concern.rb index 544dd5b4a47..432c1ee418e 100644 --- a/app/controllers/concerns/unconfirmed_user_concern.rb +++ b/app/controllers/concerns/unconfirmed_user_concern.rb @@ -43,7 +43,7 @@ def after_confirmation_url_for(user) elsif user.two_factor_enabled? account_url else - phone_setup_url + two_factor_options_url end end diff --git a/app/controllers/idv/jurisdiction_controller.rb b/app/controllers/idv/jurisdiction_controller.rb index a1b515f0128..5eb733b6fb9 100644 --- a/app/controllers/idv/jurisdiction_controller.rb +++ b/app/controllers/idv/jurisdiction_controller.rb @@ -27,8 +27,12 @@ def create end def show - @state = user_session[:idv_jurisdiction] - @reason = params[:reason] + presenter = JurisdictionFailurePresenter.new( + reason: params[:reason], + jurisdiction: user_session[:idv_jurisdiction], + view_context: view_context + ) + render_full_width('shared/_failure', locals: { presenter: presenter }) end def jurisdiction_params diff --git a/app/controllers/idv/sessions_controller.rb b/app/controllers/idv/sessions_controller.rb index b56cee9df6b..d434f610094 100644 --- a/app/controllers/idv/sessions_controller.rb +++ b/app/controllers/idv/sessions_controller.rb @@ -5,7 +5,7 @@ class SessionsController < ApplicationController include PersonalKeyConcern before_action :confirm_two_factor_authenticated, except: [:destroy] - before_action :confirm_idv_attempts_allowed + before_action :confirm_idv_attempts_allowed, except: %i[destroy success] before_action :confirm_idv_needed before_action :confirm_step_needed, except: %i[destroy success] before_action :initialize_idv_session, only: [:create] diff --git a/app/controllers/openid_connect/authorization_controller.rb b/app/controllers/openid_connect/authorization_controller.rb index fa63a51f879..3d499416659 100644 --- a/app/controllers/openid_connect/authorization_controller.rb +++ b/app/controllers/openid_connect/authorization_controller.rb @@ -1,5 +1,6 @@ module OpenidConnect class AuthorizationController < ApplicationController + include AccountRecoverable include FullyAuthenticatable include VerifyProfileConcern include VerifySPAttributesConcern @@ -13,7 +14,8 @@ class AuthorizationController < ApplicationController def index return confirm_two_factor_authenticated(request_id) unless user_fully_authenticated? - @authorize_form.link_identity_to_service_provider(current_user, session.id) + link_identity_to_service_provider + return redirect_to account_recovery_setup_url if piv_cac_enabled_but_not_phone_enabled? return redirect_to_account_or_verify_profile_url if profile_or_identity_needs_verification? return redirect_to(sign_up_completed_url) if needs_sp_attribute_verification? handle_successful_handoff @@ -21,6 +23,10 @@ def index private + def link_identity_to_service_provider + @authorize_form.link_identity_to_service_provider(current_user, session.id) + end + def handle_successful_handoff redirect_to @authorize_form.success_redirect_uri delete_branded_experience diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 25ef022a016..be64d6dd404 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -4,6 +4,6 @@ class PagesController < ApplicationController skip_before_action :disable_caching def page_not_found - render layout: false, status: 404, formats: :html + render layout: false, status: :not_found, formats: :html end end diff --git a/app/controllers/saml_idp_controller.rb b/app/controllers/saml_idp_controller.rb index 5e4d7ce475d..c6bc5ab98ab 100644 --- a/app/controllers/saml_idp_controller.rb +++ b/app/controllers/saml_idp_controller.rb @@ -6,6 +6,7 @@ class SamlIdpController < ApplicationController include SamlIdp::Controller include SamlIdpAuthConcern include SamlIdpLogoutConcern + include AccountRecoverable include FullyAuthenticatable include VerifyProfileConcern include VerifySPAttributesConcern @@ -17,6 +18,7 @@ def auth return confirm_two_factor_authenticated(request_id) unless user_fully_authenticated? link_identity_from_session_data capture_analytics + return redirect_to account_recovery_setup_url if piv_cac_enabled_but_not_phone_enabled? return redirect_to_account_or_verify_profile_url if profile_or_identity_needs_verification? return redirect_to(sign_up_completed_url) if needs_sp_attribute_verification? handle_successful_handoff diff --git a/app/controllers/test/piv_cac_authentication_test_subject_controller.rb b/app/controllers/test/piv_cac_authentication_test_subject_controller.rb index eeea57c35a0..60fa7523534 100644 --- a/app/controllers/test/piv_cac_authentication_test_subject_controller.rb +++ b/app/controllers/test/piv_cac_authentication_test_subject_controller.rb @@ -36,7 +36,8 @@ def must_be_in_development end def token_from_params - error, subject = params.slice(:error, :subject) + error = params[:error] + subject = params[:subject] if error.present? with_nonce(error: error).to_json diff --git a/app/controllers/two_factor_authentication/otp_verification_controller.rb b/app/controllers/two_factor_authentication/otp_verification_controller.rb index 71ae6bbf3fe..de65c743a6e 100644 --- a/app/controllers/two_factor_authentication/otp_verification_controller.rb +++ b/app/controllers/two_factor_authentication/otp_verification_controller.rb @@ -26,11 +26,19 @@ def create private def confirm_two_factor_enabled - return if confirmation_context? || current_user.two_factor_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 + end redirect_to phone_setup_url end + def phone_enabled? + current_user.phone_enabled? + end + def confirm_voice_capability return if two_factor_authentication_method == 'sms' 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 bf836a596f6..32ea41c4d2c 100644 --- a/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb +++ b/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb @@ -3,14 +3,13 @@ class PivCacVerificationController < ApplicationController include TwoFactorAuthenticatable include PivCacConcern - before_action :confirm_piv_cac_enabled + before_action :confirm_piv_cac_enabled, only: :show before_action :reset_attempt_count_if_user_no_longer_locked_out, only: :show def show if params[:token] process_token else - create_piv_cac_nonce @presenter = presenter_for_two_factor_authentication_method end end @@ -35,10 +34,16 @@ def handle_valid_piv_cac ) handle_valid_otp_for_authentication_context - redirect_to after_otp_verification_confirmation_url + redirect_to next_step reset_otp_session_data end + def next_step + return account_recovery_setup_url unless current_user.phone_enabled? + + after_otp_verification_confirmation_url + end + def handle_invalid_piv_cac clear_piv_cac_information # create new nonce for retry @@ -52,6 +57,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_enabled?, piv_cac_nonce: piv_cac_nonce, }.merge(generic_data) end diff --git a/app/controllers/users/personal_keys_controller.rb b/app/controllers/users/personal_keys_controller.rb index 49769184a37..0f903186138 100644 --- a/app/controllers/users/personal_keys_controller.rb +++ b/app/controllers/users/personal_keys_controller.rb @@ -26,6 +26,7 @@ def update def create user_session[:personal_key] = create_new_code analytics.track_event(Analytics::PROFILE_PERSONAL_KEY_CREATE) + Event.create(user_id: current_user.id, event_type: :new_personal_key) flash[:success] = t('notices.send_code.personal_key') if params[:resend].present? redirect_to manage_personal_key_url end diff --git a/app/controllers/users/phone_setup_controller.rb b/app/controllers/users/phone_setup_controller.rb new file mode 100644 index 00000000000..ead14b0dc7c --- /dev/null +++ b/app/controllers/users/phone_setup_controller.rb @@ -0,0 +1,43 @@ +module Users + class PhoneSetupController < ApplicationController + include UserAuthenticator + include PhoneConfirmation + include Authorizable + + before_action :authenticate_user + before_action :authorize_user + before_action :confirm_two_factor_authenticated, if: :two_factor_enabled? + + def index + @user_phone_form = UserPhoneForm.new(current_user) + @presenter = PhoneSetupPresenter.new(current_user.otp_delivery_preference) + analytics.track_event(Analytics::USER_REGISTRATION_PHONE_SETUP_VISIT) + end + + def create + @user_phone_form = UserPhoneForm.new(current_user) + @presenter = PhoneSetupPresenter.new(current_user.otp_delivery_preference) + result = @user_phone_form.submit(user_phone_form_params) + analytics.track_event(Analytics::MULTI_FACTOR_AUTH_PHONE_SETUP, result.to_h) + + if result.success? + prompt_to_confirm_phone(phone: @user_phone_form.phone) + else + render :index + end + end + + private + + def two_factor_enabled? + current_user.two_factor_enabled? + end + + def user_phone_form_params + params.require(:user_phone_form).permit( + :international_code, + :phone + ) + end + end +end diff --git a/app/controllers/users/phones_controller.rb b/app/controllers/users/phones_controller.rb index d6723c4a15d..32045dd9a28 100644 --- a/app/controllers/users/phones_controller.rb +++ b/app/controllers/users/phones_controller.rb @@ -6,11 +6,12 @@ class PhonesController < ReauthnRequiredController def edit @user_phone_form = UserPhoneForm.new(current_user) + @presenter = PhoneSetupPresenter.new(current_user.otp_delivery_preference) end def update @user_phone_form = UserPhoneForm.new(current_user) - + @presenter = PhoneSetupPresenter.new(current_user.otp_delivery_preference) if @user_phone_form.submit(user_params).success? process_updates bypass_sign_in current_user diff --git a/app/controllers/users/piv_cac_authentication_setup_controller.rb b/app/controllers/users/piv_cac_authentication_setup_controller.rb index 43c85bcd6ab..1d016e3fcbd 100644 --- a/app/controllers/users/piv_cac_authentication_setup_controller.rb +++ b/app/controllers/users/piv_cac_authentication_setup_controller.rb @@ -3,7 +3,10 @@ class PivCacAuthenticationSetupController < ApplicationController include UserAuthenticator include PivCacConcern - before_action :confirm_two_factor_authenticated + before_action :authenticate_user! + before_action :confirm_two_factor_authenticated, + if: :two_factor_enabled?, + except: :redirect_to_piv_cac_service before_action :authorize_piv_cac_setup, only: :new before_action :authorize_piv_cac_disable, only: :delete @@ -11,9 +14,7 @@ def new if params.key?(:token) process_piv_cac_setup else - # add a nonce that we track for the return analytics.track_event(Analytics::USER_REGISTRATION_PIV_CAC_SETUP_VISIT) - create_piv_cac_nonce @presenter = PivCacAuthenticationSetupPresenter.new(user_piv_cac_form) render :new end @@ -28,8 +29,17 @@ def delete redirect_to account_url end + def redirect_to_piv_cac_service + create_piv_cac_nonce + redirect_to PivCacService.piv_cac_service_link(piv_cac_nonce) + end + private + def two_factor_enabled? + current_user.two_factor_enabled? + end + def process_piv_cac_setup result = user_piv_cac_form.submit analytics.track_event(Analytics::USER_REGISTRATION_PIV_CAC_ENABLED, result.to_h) @@ -54,11 +64,15 @@ def process_valid_submission subject: user_piv_cac_form.x509_dn, presented: true ) - redirect_to account_url + redirect_to next_step + end + + def next_step + return account_url if current_user.phone_enabled? + account_recovery_setup_url end def process_invalid_submission - create_piv_cac_nonce @presenter = PivCacAuthenticationSetupErrorPresenter.new(user_piv_cac_form) clear_piv_cac_information render :error diff --git a/app/controllers/users/reset_passwords_controller.rb b/app/controllers/users/reset_passwords_controller.rb index 1a327f05a41..77777f833b1 100644 --- a/app/controllers/users/reset_passwords_controller.rb +++ b/app/controllers/users/reset_passwords_controller.rb @@ -1,6 +1,8 @@ +# rubocop:disable Metrics/ClassLength module Users class ResetPasswordsController < Devise::PasswordsController include RecaptchaConcern + before_action :prevent_token_leakage, only: %i[edit] def new @password_reset_email_form = PasswordResetEmailForm.new('') @@ -34,7 +36,7 @@ def edit # PUT /resource/password def update - self.resource = user_matching_token(user_params[:reset_password_token]) + self.resource = user_matching_token(session[:reset_password_token]) @reset_password_form = ResetPasswordForm.new(resource) @@ -94,7 +96,14 @@ def user_matching_token(token) end def token_user - @_token_user ||= User.with_reset_password_token(params[:reset_password_token]) + @_token_user ||= User.with_reset_password_token(session[:reset_password_token]) + end + + def validated_token_from_url + reset_password_token = params[:reset_password_token] + return if reset_password_token.blank? + user = User.with_reset_password_token(reset_password_token) + user ? reset_password_token : nil end def build_user @@ -102,20 +111,22 @@ def build_user end def handle_successful_password_reset + create_user_event(:password_changed, resource) update_user - mark_profile_inactive flash[:notice] = t('devise.passwords.updated_not_active') if is_flashing_format? redirect_to new_user_session_url EmailNotifier.new(resource).send_password_changed_email + session.delete(:reset_password_token) end def handle_unsuccessful_password_reset(result) if result.errors[:reset_password_token].present? flash[:error] = t('devise.passwords.token_expired') redirect_to new_user_password_url + session.delete(:reset_password_token) return end @@ -126,6 +137,8 @@ def update_user attributes = { password: user_params[:password] } attributes[:confirmed_at] = Time.zone.now unless resource.confirmed? UpdateUser.new(user: resource, attributes: attributes).call + + mark_profile_inactive end def mark_profile_inactive @@ -136,5 +149,20 @@ def user_params params.require(:reset_password_form). permit(:password, :reset_password_token) end + + def redirect_without_token_url(token) + session[:reset_password_token] = token + redirect_to url_for + end + + def prevent_token_leakage + token = validated_token_from_url + redirect_without_token_url(token) if token + end + + def assert_reset_token_passed + # remove devise's default behavior + end end end +# rubocop:enable Metrics/ClassLength diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index a3af30cf38f..31a53946858 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -69,12 +69,12 @@ def auth_params end def process_locked_out_user - decorator = current_user.decorate - sign_out - render( - 'two_factor_authentication/shared/max_login_attempts_reached', - locals: { type: 'generic', decorator: decorator } + presenter = TwoFactorAuthCode::MaxAttemptsReachedPresenter.new( + 'generic_login_attempts', + current_user.decorate ) + sign_out + render_full_width('shared/_failure', locals: { presenter: presenter }) end def handle_valid_authentication @@ -120,7 +120,7 @@ def cache_active_profile profile = current_user.decorate.active_or_pending_profile begin cacher.save(auth_params[:password], profile) - rescue Pii::EncryptionError => err + rescue Encryption::EncryptionError => err profile.deactivate(:encryption_error) analytics.track_event(Analytics::PROFILE_ENCRYPTION_INVALID, error: err.message) end diff --git a/app/controllers/users/two_factor_authentication_controller.rb b/app/controllers/users/two_factor_authentication_controller.rb index 10ca5e35c09..db1a17a40c7 100644 --- a/app/controllers/users/two_factor_authentication_controller.rb +++ b/app/controllers/users/two_factor_authentication_controller.rb @@ -7,10 +7,12 @@ class TwoFactorAuthenticationController < ApplicationController def show if current_user.totp_enabled? redirect_to login_two_factor_authenticator_url - elsif current_user.two_factor_enabled? + elsif current_user.phone_enabled? validate_otp_delivery_preference_and_send_code + elsif current_user.piv_cac_enabled? + redirect_to login_two_factor_piv_cac_url else - redirect_to phone_setup_url + redirect_to two_factor_options_url end end @@ -85,26 +87,25 @@ def reauthn_param def handle_valid_otp_delivery_preference(method) otp_rate_limiter.reset_count_and_otp_last_sent_at if decorated_user.no_longer_locked_out? - if otp_rate_limiter.exceeded_otp_send_limit? - otp_rate_limiter.lock_out_user - - return handle_too_many_otp_sends - end + return handle_too_many_otp_sends if exceeded_otp_send_limit? + otp_rate_limiter.increment + return handle_too_many_otp_sends if exceeded_otp_send_limit? send_user_otp(method) redirect_to login_two_factor_url(otp_delivery_preference: method, reauthn: reauthn?) end + def exceeded_otp_send_limit? + return otp_rate_limiter.lock_out_user if otp_rate_limiter.exceeded_otp_send_limit? + end + def send_user_otp(method) - otp_rate_limiter.increment current_user.create_direct_otp job = "#{method.capitalize}OtpSenderJob".constantize job_priority = confirmation_context? ? :perform_now : :perform_later - job.send(job_priority, - code: current_user.direct_otp, - phone: phone_to_deliver_to, - otp_created_at: current_user.direct_otp_sent_at.to_s) + job.send(job_priority, code: current_user.direct_otp, phone: phone_to_deliver_to, + otp_created_at: current_user.direct_otp_sent_at.to_s) end def user_selected_otp_delivery_preference diff --git a/app/controllers/users/two_factor_authentication_setup_controller.rb b/app/controllers/users/two_factor_authentication_setup_controller.rb index a6e1d294e3b..1ffec9ad70a 100644 --- a/app/controllers/users/two_factor_authentication_setup_controller.rb +++ b/app/controllers/users/two_factor_authentication_setup_controller.rb @@ -1,41 +1,49 @@ module Users class TwoFactorAuthenticationSetupController < ApplicationController include UserAuthenticator - include PhoneConfirmation + include Authorizable - before_action :authorize_otp_setup before_action :authenticate_user + before_action :authorize_user def index - @user_phone_form = UserPhoneForm.new(current_user) - analytics.track_event(Analytics::USER_REGISTRATION_PHONE_SETUP_VISIT) + @two_factor_options_form = TwoFactorOptionsForm.new(current_user) + @presenter = two_factor_options_presenter + analytics.track_event(Analytics::USER_REGISTRATION_2FA_SETUP_VISIT) end - def set - @user_phone_form = UserPhoneForm.new(current_user) - result = @user_phone_form.submit(params[:user_phone_form]) - - analytics.track_event(Analytics::MULTI_FACTOR_AUTH_PHONE_SETUP, result.to_h) + def create + @two_factor_options_form = TwoFactorOptionsForm.new(current_user) + result = @two_factor_options_form.submit(two_factor_options_form_params) + analytics.track_event(Analytics::USER_REGISTRATION_2FA_SETUP, result.to_h) if result.success? process_valid_form else + @presenter = two_factor_options_presenter render :index end end private - def authorize_otp_setup - if user_fully_authenticated? - redirect_to(request.referer || root_url) - elsif current_user&.two_factor_enabled? - redirect_to user_two_factor_authentication_url - end + def two_factor_options_presenter + TwoFactorOptionsPresenter.new(current_user, current_sp) end def process_valid_form - prompt_to_confirm_phone(phone: @user_phone_form.phone) + case @two_factor_options_form.selection + when 'sms', 'voice' + redirect_to phone_setup_url + when 'auth_app' + redirect_to authenticator_setup_url + when 'piv_cac' + redirect_to setup_piv_cac_url + end + end + + def two_factor_options_form_params + params.require(:two_factor_options_form).permit(:selection) end end end diff --git a/app/decorators/service_provider_session_decorator.rb b/app/decorators/service_provider_session_decorator.rb index 8ca638d4acf..3cb8bfff2bd 100644 --- a/app/decorators/service_provider_session_decorator.rb +++ b/app/decorators/service_provider_session_decorator.rb @@ -1,7 +1,4 @@ class ServiceProviderSessionDecorator - include Rails.application.routes.url_helpers - include LocaleHelper - DEFAULT_LOGO = 'generic.svg'.freeze SP_ALERTS = { @@ -40,6 +37,15 @@ def sp_logo sp.logo || DEFAULT_LOGO end + def sp_logo_url + logo = sp_logo + if RemoteSettingsService.remote?(logo) + logo + else + ActionController::Base.helpers.image_path("sp-logos/#{logo}") + end + end + def return_to_service_provider_partial if sp_return_url.present? 'devise/sessions/return_to_service_provider' @@ -97,7 +103,7 @@ def sp_return_url end def cancel_link_url - sign_up_start_url(request_id: sp_session[:request_id], locale: locale_url_param) + view_context.sign_up_start_url(request_id: sp_session[:request_id]) end def sp_alert? diff --git a/app/decorators/session_decorator.rb b/app/decorators/session_decorator.rb index c161d8ddf41..923990c5a0d 100644 --- a/app/decorators/session_decorator.rb +++ b/app/decorators/session_decorator.rb @@ -1,6 +1,7 @@ class SessionDecorator - include Rails.application.routes.url_helpers - include LocaleHelper + def initialize(view_context: nil) + @view_context = view_context + end def return_to_service_provider_partial 'shared/null' @@ -31,7 +32,7 @@ def idv_hardfail4_partial end def cancel_link_url - root_url(locale: locale_url_param) + view_context.root_url end def sp_name; end @@ -40,6 +41,8 @@ def sp_agency; end def sp_logo; end + def sp_logo_url; end + def sp_redirect_uris; end def sp_return_url; end @@ -51,4 +54,8 @@ def sp_alert?; end def sp_alert_name; end def sp_alert_learn_more; end + + private + + attr_reader :view_context end diff --git a/app/forms/otp_verification_form.rb b/app/forms/otp_verification_form.rb index 0c6e8ee98eb..51369840049 100644 --- a/app/forms/otp_verification_form.rb +++ b/app/forms/otp_verification_form.rb @@ -13,7 +13,7 @@ def submit attr_reader :code, :user def valid_direct_otp_code? - return false unless code =~ pattern_matching_otp_code_format + return false unless code.match? pattern_matching_otp_code_format user.authenticate_direct_otp(code) end diff --git a/app/forms/totp_verification_form.rb b/app/forms/totp_verification_form.rb index 7f4bf8053ea..faae780778f 100644 --- a/app/forms/totp_verification_form.rb +++ b/app/forms/totp_verification_form.rb @@ -13,7 +13,7 @@ def submit attr_reader :user, :code def valid_totp_code? - return false unless code =~ pattern_matching_totp_code_format + return false unless code.match? pattern_matching_totp_code_format user.authenticate_totp(code) end diff --git a/app/forms/two_factor_options_form.rb b/app/forms/two_factor_options_form.rb new file mode 100644 index 00000000000..6ca9a0b8bec --- /dev/null +++ b/app/forms/two_factor_options_form.rb @@ -0,0 +1,46 @@ +class TwoFactorOptionsForm + include ActiveModel::Model + + attr_reader :selection + + validates :selection, inclusion: { in: %w[voice sms auth_app piv_cac] } + + def initialize(user) + self.user = user + end + + def submit(params) + self.selection = params[:selection] + + success = valid? + + update_otp_delivery_preference_for_user if success && user_needs_updating? + + FormResponse.new(success: success, errors: errors.messages, extra: extra_analytics_attributes) + end + + def selected?(type) + type == (selection || 'sms') + end + + private + + attr_accessor :user + attr_writer :selection + + def extra_analytics_attributes + { + selection: selection, + } + end + + def user_needs_updating? + return false unless %w[voice sms].include?(selection) + selection != user.otp_delivery_preference + end + + def update_otp_delivery_preference_for_user + user_attributes = { otp_delivery_preference: selection } + UpdateUser.new(user: user, attributes: user_attributes).call + end +end diff --git a/app/forms/user_phone_form.rb b/app/forms/user_phone_form.rb index 8a3c0632fd3..fca174eebc3 100644 --- a/app/forms/user_phone_form.rb +++ b/app/forms/user_phone_form.rb @@ -3,6 +3,8 @@ class UserPhoneForm include FormPhoneValidator include OtpDeliveryPreferenceValidator + validates :otp_delivery_preference, inclusion: { in: %w[voice sms] } + attr_accessor :phone, :international_code, :otp_delivery_preference def initialize(user) @@ -16,9 +18,10 @@ def submit(params) ingest_submitted_params(params) success = valid? - self.phone = submitted_phone unless success - update_otp_delivery_preference_for_user if otp_delivery_preference_changed? && success + + update_otp_delivery_preference_for_user if + success && otp_delivery_preference.present? && otp_delivery_preference_changed? FormResponse.new(success: success, errors: errors.messages, extra: extra_analytics_attributes) end @@ -44,7 +47,10 @@ def ingest_submitted_params(params) submitted_phone, country_code: international_code ) - self.otp_delivery_preference = params[:otp_delivery_preference] + + tfa_prefs = params[:otp_delivery_preference] + + self.otp_delivery_preference = tfa_prefs if tfa_prefs end def otp_delivery_preference_changed? diff --git a/app/forms/verify_personal_key_form.rb b/app/forms/verify_personal_key_form.rb index c718ec1a732..5a96dce3009 100644 --- a/app/forms/verify_personal_key_form.rb +++ b/app/forms/verify_personal_key_form.rb @@ -51,7 +51,7 @@ def reset_sensitive_fields def personal_key_decrypts? decrypted_pii.present? - rescue Pii::EncryptionError => _err + rescue Encryption::EncryptionError => _err false end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index c931272ef9a..ec2df76c6fe 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -7,6 +7,10 @@ def card_cls(cls) content_for(:card_cls) { cls } end + def background_cls(cls) + content_for(:background_cls) { cls } + end + def step_class(step, active) if active > step 'complete' diff --git a/app/javascript/app/form-field-format.js b/app/javascript/app/form-field-format.js index 8f13ce3e031..b28edc7f89e 100644 --- a/app/javascript/app/form-field-format.js +++ b/app/javascript/app/form-field-format.js @@ -1,6 +1,5 @@ import { SocialSecurityNumberFormatter, TextField } from 'field-kit'; import DateFormatter from './modules/date-formatter'; -import InternationalPhoneFormatter from './modules/international-phone-formatter'; import NumericFormatter from './modules/numeric-formatter'; import PersonalKeyFormatter from './modules/personal-key-formatter'; import USPhoneFormatter from './modules/us-phone-formatter'; @@ -11,7 +10,6 @@ function formatForm() { const formats = [ ['.dob', new DateFormatter()], ['.mfa', new NumericFormatter()], - ['.phone', new InternationalPhoneFormatter()], ['.us-phone', new USPhoneFormatter()], ['.personal-key', new PersonalKeyFormatter()], ['.ssn', new SocialSecurityNumberFormatter()], diff --git a/app/javascript/app/form-validation.js b/app/javascript/app/form-validation.js index 628eb58a4bd..69896083384 100644 --- a/app/javascript/app/form-validation.js +++ b/app/javascript/app/form-validation.js @@ -23,7 +23,7 @@ document.addEventListener('DOMContentLoaded', () => { if (elements.length !== 0) { [].forEach.call(elements, function(input) { input.addEventListener('input', function () { - if (buttons.length !== 0 && input.valid) { + if (buttons.length !== 0 && input.checkValidity()) { [].forEach.call(buttons, function(button) { if (button.disabled) { button.disabled = false; diff --git a/app/javascript/app/modules/international-phone-formatter.js b/app/javascript/app/modules/international-phone-formatter.js deleted file mode 100644 index 6f4376a3a46..00000000000 --- a/app/javascript/app/modules/international-phone-formatter.js +++ /dev/null @@ -1,77 +0,0 @@ -import { Formatter } from 'field-kit'; -import { asYouType as AsYouType } from 'libphonenumber-js'; - -const INTERNATIONAL_CODE_REGEX = /^\+\d{1,3} /; - -const fixCountryCodeSpacing = (text, countryCode) => { - // If the text is `+123456`, make it `+123 456` - if (text[countryCode.length + 1] !== ' ') { - return text.replace(`+${countryCode}`, `+${countryCode} `); - } - return text; -}; - -const getFormattedTextData = (text) => { - if (text === '1') { - text = '+1'; - } - - const asYouType = new AsYouType('US'); - let formattedText = asYouType.input(text); - const countryCode = asYouType.country_phone_code; - - if (asYouType.country_phone_code) { - formattedText = fixCountryCodeSpacing(formattedText, countryCode); - } - - return { - text: formattedText, - template: asYouType.template, - countryCode, - }; -}; - -const changeRemovesInternationalCode = (current, previous) => { - if (previous.text.match(INTERNATIONAL_CODE_REGEX) && - !current.text.match(INTERNATIONAL_CODE_REGEX) - ) { - return true; - } - return false; -}; - -const cursorPosition = (formattedTextData) => { - // If the text is `(23 )` the cursor goes after the 3 - const match = formattedTextData.text.match(/\d[^\d]*$/); - if (match) { - return match.index + 1; - } - return formattedTextData.text.length + 1; -}; - -class InternationalPhoneFormatter extends Formatter { - format(text) { - const formattedTextData = getFormattedTextData(text); - return super.format(formattedTextData.text); - } - - // eslint-disable-next-line class-methods-use-this - parse(text) { - return text.replace(/[^\d+]/g, ''); - } - - isChangeValid(change, error) { - const formattedTextData = getFormattedTextData(change.proposed.text); - const previousFormattedTextData = getFormattedTextData(change.current.text); - - if (changeRemovesInternationalCode(formattedTextData, previousFormattedTextData)) { - return false; - } - - change.proposed.text = formattedTextData.text; - change.proposed.selectedRange.start = cursorPosition(formattedTextData); - return super.isChangeValid(change, error); - } -} - -export default InternationalPhoneFormatter; diff --git a/app/javascript/app/phone-internationalization.js b/app/javascript/app/phone-internationalization.js index 167b2c3c618..5aa75d8ab21 100644 --- a/app/javascript/app/phone-internationalization.js +++ b/app/javascript/app/phone-internationalization.js @@ -76,7 +76,7 @@ const updateOTPDeliveryMethods = () => { return; } - const phoneInput = document.querySelector('[data-international-phone-form] .phone') || document.querySelector('[data-international-phone-form] .new-phone'); + const phoneInput = document.querySelector('[data-international-phone-form] .phone'); const phoneLabel = phoneRadio.parentNode.parentNode; const deliveryMethodHint = document.querySelector('#otp_delivery_preference_instruction'); const optPhoneLabelInfo = document.querySelector('#otp_phone_label_info'); @@ -111,7 +111,7 @@ const updateInternationalCodeInPhone = (phone, newCode) => { }; const updateInternationalCodeInput = () => { - const phoneInput = document.querySelector('[data-international-phone-form] .phone') || document.querySelector('[data-international-phone-form] .new-phone'); + const phoneInput = document.querySelector('[data-international-phone-form] .phone'); const phone = phoneInput.value; const inputInternationalCode = internationalCodeFromPhone(phone); const selectedInternationalCode = selectedInternationCodeOption().dataset.countryCode; @@ -122,7 +122,7 @@ const updateInternationalCodeInput = () => { }; document.addEventListener('DOMContentLoaded', () => { - const phoneInput = document.querySelector('[data-international-phone-form] .phone') || document.querySelector('[data-international-phone-form] .new-phone'); + const phoneInput = document.querySelector('[data-international-phone-form] .phone'); const codeInput = document.querySelector('[data-international-phone-form] .international-code'); if (phoneInput) { phoneInput.addEventListener('countryChange', updateOTPDeliveryMethods); diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 1f41ae6dee3..330388e7f38 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -2,7 +2,8 @@ class UserMailer < ActionMailer::Base include Mailable include LocaleHelper before_action :attach_images - default from: email_with_name(Figaro.env.email_from, Figaro.env.email_from) + default from: email_with_name(Figaro.env.email_from, Figaro.env.email_from), + reply_to: email_with_name(Figaro.env.email_from, Figaro.env.email_from) def email_changed(old_email) mail(to: old_email, subject: t('mailer.email_change_notice.subject')) diff --git a/app/models/concerns/user_access_key_overrides.rb b/app/models/concerns/user_access_key_overrides.rb index af288ec784d..a014ca0d50b 100644 --- a/app/models/concerns/user_access_key_overrides.rb +++ b/app/models/concerns/user_access_key_overrides.rb @@ -5,69 +5,39 @@ module UserAccessKeyOverrides extend ActiveSupport::Concern - attr_accessor :user_access_key - def valid_password?(password) - return false if encrypted_password.blank? - begin - unlock_user_access_key(password) - rescue Pii::EncryptionError => err - log_error(err) - return false - end - Devise.secure_compare(encrypted_password, user_access_key.encrypted_password) - end - - def unlock_user_access_key(password) - self.user_access_key = build_user_access_key(password).unlock(encryption_key) + result = Encryption::PasswordVerifier.verify( + password: password, + digest: encrypted_password_digest + ) + log_password_verification_failure unless result + result end def password=(new_password) @password = new_password - encrypt_password(@password) if @password.present? - end - - def authenticatable_salt - password_salt + return if @password.blank? + digest = Encryption::PasswordVerifier.digest(@password) + self.encrypted_password_digest = digest.to_s + # Until we drop the old columns, still write to them so that we can rollback + write_legacy_password_attributes(digest) end private - def log_error(err) + def write_legacy_password_attributes(digest) + self.encrypted_password = digest.encrypted_password + self.encryption_key = digest.encryption_key + self.password_salt = digest.password_salt + self.password_cost = digest.password_cost + end + + def log_password_verification_failure metadata = { - event: 'Pii::EncryptionError when validating password', - error: err.to_s, + event: 'Failure to validate password', uuid: uuid, timestamp: Time.zone.now, } Rails.logger.info(metadata.to_json) end - - def encrypt_password(new_password) - self.password_salt = Devise.friendly_token[0, 20] - - user_access_key = build_user_access_key(new_password, cost: nil).build - - self.encryption_key = user_access_key.encryption_key - self.password_cost = user_access_key.cost - self.encrypted_password = user_access_key.encrypted_password - self.encrypted_password_digest = build_encrypted_password_digest - end - - def build_user_access_key(password, salt: authenticatable_salt, cost: password_cost) - Encryption::UserAccessKey.new( - password: password, - salt: salt, - cost: cost - ) - end - - def build_encrypted_password_digest - { - encryption_key: encryption_key, - encrypted_password: encrypted_password, - password_cost: password_cost, - password_salt: password_salt, - }.to_json - end end diff --git a/app/models/event.rb b/app/models/event.rb index 0b414c6af97..1d9bedc78c7 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -13,6 +13,7 @@ class Event < ApplicationRecord usps_mail_sent: 9, piv_cac_enabled: 10, piv_cac_disabled: 11, + new_personal_key: 12, } validates :event_type, presence: true diff --git a/app/models/otp_requests_tracker.rb b/app/models/otp_requests_tracker.rb index 2a9f263a218..166f67c5304 100644 --- a/app/models/otp_requests_tracker.rb +++ b/app/models/otp_requests_tracker.rb @@ -10,4 +10,15 @@ def self.find_or_create_with_phone(phone) retry unless (tries -= 1).zero? raise end + + def self.atomic_increment(id) + now = Time.zone.now + # The following sql offers superior db performance with one write and no locking overhead + query = sanitize_sql_array(['UPDATE otp_requests_trackers ' \ + 'SET otp_send_count = otp_send_count + 1,' \ + 'otp_last_sent_at = ?, updated_at = ? ' \ + 'WHERE id = ?', now, now, id]) + OtpRequestsTracker.connection.execute(query) + OtpRequestsTracker.find(id) + end end diff --git a/app/models/profile.rb b/app/models/profile.rb index 4dea5a1b66c..98aeff5be39 100644 --- a/app/models/profile.rb +++ b/app/models/profile.rb @@ -33,39 +33,27 @@ def deactivate(reason) def decrypt_pii(password) Pii::Attributes.new_from_encrypted( encrypted_pii, - password: password, - salt: user.password_salt, - cost: user.password_cost + password: password ) end def recover_pii(personal_key) Pii::Attributes.new_from_encrypted( encrypted_pii_recovery, - password: personal_key, - salt: user.recovery_salt, - cost: user.recovery_cost + password: personal_key ) end def encrypt_pii(pii, password) ssn = pii.ssn self.ssn_signature = Pii::Fingerprinter.fingerprint(ssn) if ssn - self.encrypted_pii = pii.encrypted( - password: password, - salt: user.password_salt, - cost: user.password_cost - ) + self.encrypted_pii = pii.encrypted(password) encrypt_recovery_pii(pii) end def encrypt_recovery_pii(pii) personal_key = personal_key_generator.create - self.encrypted_pii_recovery = pii.encrypted( - password: personal_key_generator.normalize(personal_key), - salt: user.recovery_salt, - cost: user.recovery_cost - ) + self.encrypted_pii_recovery = pii.encrypted(personal_key_generator.normalize(personal_key)) @personal_key = personal_key end diff --git a/app/models/remote_setting.rb b/app/models/remote_setting.rb new file mode 100644 index 00000000000..968b01960ee --- /dev/null +++ b/app/models/remote_setting.rb @@ -0,0 +1,6 @@ +class RemoteSetting < ApplicationRecord + validates :url, format: { + with: + %r{\A(https://raw.githubusercontent.com/18F/identity-idp/|https://login.gov).+\z}, + } +end diff --git a/app/models/service_provider.rb b/app/models/service_provider.rb index 2135fef4b11..c62d65aa661 100644 --- a/app/models/service_provider.rb +++ b/app/models/service_provider.rb @@ -17,12 +17,7 @@ def metadata def ssl_cert @ssl_cert ||= begin return if cert.blank? - - cert_file = Rails.root.join('certs', 'sp', "#{cert}.crt") - - return OpenSSL::X509::Certificate.new(cert) unless File.exist?(cert_file) - - OpenSSL::X509::Certificate.new(File.read(cert_file)) + OpenSSL::X509::Certificate.new(load_cert(cert)) end end @@ -47,8 +42,22 @@ def live? active? && approved? end + def piv_cac_available? + PivCacService.piv_cac_available_for_agency?(agency) + end + private + def load_cert(cert) + if RemoteSettingsService.remote?(cert) + RemoteSettingsService.load(cert) + else + cert_file = Rails.root.join('certs', 'sp', "#{cert}.crt") + return OpenSSL::X509::Certificate.new(cert) unless File.exist?(cert_file) + File.read(cert_file) + end + end + def redirect_uris_are_parsable return if redirect_uris.blank? diff --git a/app/models/user.rb b/app/models/user.rb index 467775908e8..5fec213a7a7 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -57,22 +57,23 @@ def confirm_piv_cac?(proposed_uuid) end def piv_cac_enabled? - x509_dn_uuid.present? + FeatureManagement.piv_cac_enabled? && x509_dn_uuid.present? end def piv_cac_available? - FeatureManagement.piv_cac_enabled? && ( - piv_cac_enabled? || - identities.any?(&:piv_cac_available?) - ) + piv_cac_enabled? || identities.any?(&:piv_cac_available?) end def need_two_factor_authentication?(_request) two_factor_enabled? end + def phone_enabled? + phone.present? + end + def two_factor_enabled? - phone.present? || totp_enabled? || piv_cac_enabled? + phone_enabled? || totp_enabled? || piv_cac_enabled? end def send_two_factor_authentication_code(_code) diff --git a/app/presenters/account_recovery_options_presenter.rb b/app/presenters/account_recovery_options_presenter.rb new file mode 100644 index 00000000000..7c0f450f2aa --- /dev/null +++ b/app/presenters/account_recovery_options_presenter.rb @@ -0,0 +1,32 @@ +class AccountRecoveryOptionsPresenter + include ActionView::Helpers::TranslationHelper + + AVAILABLE_2FA_TYPES = %w[sms voice].freeze + + def title + t('titles.account_recovery_setup') + end + + def heading + t('headings.account_recovery_setup.piv_cac_linked') + end + + def info + t('instructions.account_recovery_setup.piv_cac_next_step') + end + + def label + t('forms.account_recovery_setup.legend') + ':' + end + + def options + AVAILABLE_2FA_TYPES.map do |type| + OpenStruct.new( + type: type, + label: t("devise.two_factor_authentication.two_factor_choice_options.#{type}"), + info: t("devise.two_factor_authentication.two_factor_choice_options.#{type}_info"), + selected: type == :sms + ) + end + end +end diff --git a/app/presenters/failure_presenter.rb b/app/presenters/failure_presenter.rb new file mode 100644 index 00000000000..f87b28b42d5 --- /dev/null +++ b/app/presenters/failure_presenter.rb @@ -0,0 +1,52 @@ + +class FailurePresenter + attr_reader :state + + STATE_CONFIG = { + failure: { + icon: 'alert/fail-x.svg', + alt_text: 'failure', + color: 'red', + }, + locked: { + icon: 'alert/temp-lock.svg', + alt_text: 'locked', + color: 'red', + }, + warning: { + icon: 'alert/warning-lg.svg', + alt_text: 'warning', + color: 'yellow', + }, + }.freeze + + def initialize(state) + @state = state + end + + def state_icon + STATE_CONFIG.dig(state, :icon) + end + + def state_alt_text + STATE_CONFIG.dig(state, :alt_text) + end + + def state_color + STATE_CONFIG.dig(state, :color) + end + + def message; end + + def title; end + + def header; end + + def description; end + + def next_steps + [] + end + + def js; end +end diff --git a/app/presenters/idv/jurisdiction_failure_presenter.rb b/app/presenters/idv/jurisdiction_failure_presenter.rb new file mode 100644 index 00000000000..db81973c6da --- /dev/null +++ b/app/presenters/idv/jurisdiction_failure_presenter.rb @@ -0,0 +1,62 @@ +module Idv + class JurisdictionFailurePresenter < FailurePresenter + attr_reader :jurisdiction, :reason, :view_context + + delegate :account_path, + :decorated_session, + :idv_jurisdiction_path, + :link_to, + :state_name_for_abbrev, + :t, + to: :view_context + + def initialize(jurisdiction:, reason:, view_context:) + super(:failure) + @jurisdiction = jurisdiction + @reason = reason + @view_context = view_context + end + + def title + t("idv.titles.#{reason}", **i18n_args) + end + + def header + t("idv.titles.#{reason}", **i18n_args) + end + + def description + t("idv.messages.jurisdiction.#{reason}_failure", **i18n_args) + end + + def message + t('headings.lock_failure') + end + + def next_steps + [try_again_step, sp_step, profile_step].compact + end + + private + + def i18n_args + jurisdiction ? { state: state_name_for_abbrev(jurisdiction) } : {} + end + + def try_again_step + try_again_link = link_to(t('idv.messages.jurisdiction.try_again_link'), idv_jurisdiction_path) + t('idv.messages.jurisdiction.try_again', link: try_again_link) + end + + def sp_step + return unless (sp_name = decorated_session.sp_name) + support_link = link_to(sp_name, decorated_session.sp_alert_learn_more) + t('idv.messages.jurisdiction.sp_support', link: support_link) + end + + def profile_step + profile_link = link_to(t('idv.messages.jurisdiction.profile_link'), account_path) + t('idv.messages.jurisdiction.profile', link: profile_link) + end + end +end diff --git a/app/presenters/phone_setup_presenter.rb b/app/presenters/phone_setup_presenter.rb new file mode 100644 index 00000000000..f6b55f8db5b --- /dev/null +++ b/app/presenters/phone_setup_presenter.rb @@ -0,0 +1,25 @@ +class PhoneSetupPresenter + include ActionView::Helpers::TranslationHelper + + attr_reader :otp_delivery_preference + + def initialize(otp_delivery_preference) + @otp_delivery_preference = otp_delivery_preference + end + + def heading + t("titles.phone_setup.#{otp_delivery_preference}") + end + + def label + t("devise.two_factor_authentication.phone_#{otp_delivery_preference}_label") + end + + def info + t("devise.two_factor_authentication.phone_#{otp_delivery_preference}_info_html") + end + + def image + "2FA-#{otp_delivery_preference}.svg" + end +end diff --git a/app/presenters/piv_cac_authentication_setup_base_presenter.rb b/app/presenters/piv_cac_authentication_setup_base_presenter.rb index e51ec8a7387..56dc061ab57 100644 --- a/app/presenters/piv_cac_authentication_setup_base_presenter.rb +++ b/app/presenters/piv_cac_authentication_setup_base_presenter.rb @@ -8,15 +8,11 @@ def initialize(form) @form = form end - def piv_cac_nonce - @form.nonce - end - def piv_cac_capture_text t('forms.piv_cac_setup.submit') end def piv_cac_service_link - PivCacService.piv_cac_service_link(piv_cac_nonce) + redirect_to_piv_cac_service_url end end diff --git a/app/presenters/two_factor_auth_code/authenticator_delivery_presenter.rb b/app/presenters/two_factor_auth_code/authenticator_delivery_presenter.rb index 6bf128a47eb..ee9e22e0f5c 100644 --- a/app/presenters/two_factor_auth_code/authenticator_delivery_presenter.rb +++ b/app/presenters/two_factor_auth_code/authenticator_delivery_presenter.rb @@ -30,9 +30,10 @@ def cancel_link private - attr_reader :user_email, :two_factor_authentication_method + attr_reader :user_email, :two_factor_authentication_method, :phone_enabled def otp_fallback_options + return unless phone_enabled t( 'devise.two_factor_authentication.totp_fallback.text_html', sms_link: sms_link, diff --git a/app/presenters/two_factor_auth_code/max_attempts_reached_presenter.rb b/app/presenters/two_factor_auth_code/max_attempts_reached_presenter.rb new file mode 100644 index 00000000000..707295ac8dd --- /dev/null +++ b/app/presenters/two_factor_auth_code/max_attempts_reached_presenter.rb @@ -0,0 +1,63 @@ +module TwoFactorAuthCode + class MaxAttemptsReachedPresenter < FailurePresenter + include ActionView::Helpers::TranslationHelper + include ActionView::Helpers::UrlHelper + + attr_reader :type, :decorated_user + + COUNTDOWN_ID = 'countdown'.freeze + + T_SCOPE = 'devise.two_factor_authentication'.freeze + + def initialize(type, decorated_user) + super(:locked) + @type = type + @decorated_user = decorated_user + end + + def title + t('titles.account_locked') + end + + def header + t('titles.account_locked') + end + + def description + t("max_#{type}_reached", scope: T_SCOPE) + end + + def message + t('headings.lock_failure') + end + + def next_steps + [please_try_again, read_about_two_factor_authentication] + end + + def js + <<~JS + var test = #{decorated_user.lockout_time_remaining} * 1000; + window.LoginGov.countdownTimer(document.getElementById('#{COUNTDOWN_ID}'), test); + JS + end + + private + + def please_try_again + t(:please_try_again_html, + scope: T_SCOPE, id: COUNTDOWN_ID, + time_remaining: decorated_user.lockout_time_remaining_in_words) + end + + def read_about_two_factor_authentication + link = link_to( + t('read_about_two_factor_authentication.link', scope: T_SCOPE), + MarketingSite.help_url + ) + + t('read_about_two_factor_authentication.text_html', + scope: T_SCOPE, link: link) + end + end +end diff --git a/app/presenters/two_factor_auth_code/piv_cac_authentication_presenter.rb b/app/presenters/two_factor_auth_code/piv_cac_authentication_presenter.rb index 6a1d7017347..8efbed8b36f 100644 --- a/app/presenters/two_factor_auth_code/piv_cac_authentication_presenter.rb +++ b/app/presenters/two_factor_auth_code/piv_cac_authentication_presenter.rb @@ -35,14 +35,15 @@ def cancel_link end def piv_cac_service_link - PivCacService.piv_cac_service_link(piv_cac_nonce) + redirect_to_piv_cac_service_url end private - attr_reader :user_email, :two_factor_authentication_method, :totp_enabled, :piv_cac_nonce + attr_reader :user_email, :two_factor_authentication_method, :totp_enabled, :phone_enabled def otp_fallback_options + return unless phone_enabled t( 'devise.two_factor_authentication.totp_fallback.text_html', sms_link: sms_link, diff --git a/app/presenters/two_factor_options_presenter.rb b/app/presenters/two_factor_options_presenter.rb new file mode 100644 index 00000000000..5ef4a5e360a --- /dev/null +++ b/app/presenters/two_factor_options_presenter.rb @@ -0,0 +1,49 @@ +class TwoFactorOptionsPresenter + include ActionView::Helpers::TranslationHelper + + attr_reader :current_user, :service_provider + + def initialize(current_user, sp) + @current_user = current_user + @service_provider = sp + end + + def title + t('titles.two_factor_setup') + end + + def heading + t('devise.two_factor_authentication.two_factor_choice') + end + + def info + t('devise.two_factor_authentication.two_factor_choice_intro') + end + + def label + t('forms.two_factor_choice.legend') + ':' + end + + def options + available_2fa_types.map do |type| + OpenStruct.new( + type: type, + label: t("devise.two_factor_authentication.two_factor_choice_options.#{type}"), + info: t("devise.two_factor_authentication.two_factor_choice_options.#{type}_info"), + selected: type == :sms + ) + end + end + + private + + def available_2fa_types + %w[sms voice auth_app] + piv_cac_if_available + end + + def piv_cac_if_available + return [] if current_user.piv_cac_enabled? + return [] unless current_user.piv_cac_available? || service_provider&.piv_cac_available? + %w[piv_cac] + end +end diff --git a/app/services/agency_seeder.rb b/app/services/agency_seeder.rb index ac0e325a9a8..53ba4db3eea 100644 --- a/app/services/agency_seeder.rb +++ b/app/services/agency_seeder.rb @@ -21,7 +21,12 @@ def run attr_reader :rails_env, :deploy_env def agencies - content = ERB.new(Rails.root.join('config', 'agencies.yml').read).result + file = remote_setting || Rails.root.join('config', 'agencies.yml').read + content = ERB.new(file).result YAML.safe_load(content).fetch(rails_env, {}) end + + def remote_setting + RemoteSetting.find_by(name: 'agencies.yml')&.contents + end end diff --git a/app/services/analytics.rb b/app/services/analytics.rb index 273416409d4..4bbd34ba5fe 100644 --- a/app/services/analytics.rb +++ b/app/services/analytics.rb @@ -109,6 +109,8 @@ def browser USER_REGISTRATION_EMAIL_CONFIRMATION_RESEND = 'User Registration: Email Confirmation requested due to invalid token'.freeze USER_REGISTRATION_ENTER_EMAIL_VISIT = 'User Registration: enter email visited'.freeze USER_REGISTRATION_INTRO_VISIT = 'User Registration: intro visited'.freeze + USER_REGISTRATION_2FA_SETUP = 'User Registration: 2FA Setup'.freeze + USER_REGISTRATION_2FA_SETUP_VISIT = 'User Registration: 2FA Setup visited'.freeze USER_REGISTRATION_PHONE_SETUP_VISIT = 'User Registration: phone setup visited'.freeze USER_REGISTRATION_PERSONAL_KEY_VISIT = 'User Registration: personal key visited'.freeze USER_REGISTRATION_PIV_CAC_DISABLED = 'User Registration: piv cac disabled'.freeze diff --git a/app/services/decorated_session.rb b/app/services/decorated_session.rb index 5a51d68fee7..4d69b7295e5 100644 --- a/app/services/decorated_session.rb +++ b/app/services/decorated_session.rb @@ -15,7 +15,7 @@ def call service_provider_request: service_provider_request ) else - SessionDecorator.new + SessionDecorator.new(view_context: view_context) end end diff --git a/app/services/pii/cipher.rb b/app/services/encryption/aes_cipher.rb similarity index 75% rename from app/services/pii/cipher.rb rename to app/services/encryption/aes_cipher.rb index 0e54b93e1b8..9022acdfcfa 100644 --- a/app/services/pii/cipher.rb +++ b/app/services/encryption/aes_cipher.rb @@ -1,18 +1,22 @@ -module Pii - class Cipher +module Encryption + class AesCipher include Encodable def encrypt(plaintext, cek) self.cipher = OpenSSL::Cipher.new 'aes-256-gcm' cipher.encrypt - cipher.key = cek + # The key length for the AES-256-GCM cipher is fixed at 128 bits, or 32 + # characters. Starting with Ruby 2.4, an expection is thrown if you try to + # set a key longer than 32 characters, which is what we have been doing + # all along. In prior versions of Ruby, the key was silently truncated. + cipher.key = cek[0..31] encipher(plaintext) end def decrypt(payload, cek) self.cipher = OpenSSL::Cipher.new 'aes-256-gcm' cipher.decrypt - cipher.key = cek + cipher.key = cek[0..31] decipher(payload) end @@ -45,7 +49,7 @@ def try_decipher(unpacked_payload) def unpack_payload(payload) JSON.parse(payload, symbolize_names: true) rescue StandardError - raise Pii::EncryptionError, 'Unable to parse encrypted payload' + raise EncryptionError, 'Unable to parse encrypted payload' end def iv(unpacked_payload) diff --git a/app/services/pii/encodable.rb b/app/services/encryption/encodable.rb similarity index 94% rename from app/services/pii/encodable.rb rename to app/services/encryption/encodable.rb index 9fc1c4a5feb..1c726e60603 100644 --- a/app/services/pii/encodable.rb +++ b/app/services/encryption/encodable.rb @@ -1,4 +1,4 @@ -module Pii +module Encryption module Encodable extend ActiveSupport::Concern diff --git a/app/services/pii/encryption_error.rb b/app/services/encryption/encryption_error.rb similarity index 73% rename from app/services/pii/encryption_error.rb rename to app/services/encryption/encryption_error.rb index d117adf952b..95696c009e3 100644 --- a/app/services/pii/encryption_error.rb +++ b/app/services/encryption/encryption_error.rb @@ -1,4 +1,4 @@ -module Pii +module Encryption class EncryptionError < StandardError end end diff --git a/app/services/encryption/encryptors/aes_encryptor.rb b/app/services/encryption/encryptors/aes_encryptor.rb new file mode 100644 index 00000000000..62ebe2a125a --- /dev/null +++ b/app/services/encryption/encryptors/aes_encryptor.rb @@ -0,0 +1,61 @@ +module Encryption + module Encryptors + class AesEncryptor + include Encodable + + DELIMITER = '.'.freeze + + # "It is a riddle, wrapped in a mystery, inside an enigma; but perhaps there is a key." + # - Winston Churchill, https://en.wiktionary.org/wiki/a_riddle_wrapped_up_in_an_enigma + # + + def initialize + self.cipher = AesCipher.new + end + + def encrypt(plaintext, cek) + payload = fingerprint_and_concat(plaintext) + encode(cipher.encrypt(payload, cek)) + end + + def decrypt(ciphertext, cek) + raise EncryptionError, 'ciphertext is invalid' unless sane_payload?(ciphertext) + decrypt_and_test_payload(decode(ciphertext), cek) + end + + private + + attr_accessor :cipher + + def fingerprint_and_concat(plaintext) + fingerprint = Pii::Fingerprinter.fingerprint(plaintext) + join_segments(plaintext, fingerprint) + end + + def decrypt_and_test_payload(payload, cek) + begin + payload = cipher.decrypt(payload, cek) + rescue OpenSSL::Cipher::CipherError => err + raise EncryptionError, err.inspect + end + raise EncryptionError, 'payload is invalid' unless sane_payload?(payload) + plaintext, fingerprint = split_into_segments(payload) + return plaintext if Pii::Fingerprinter.verify(plaintext, fingerprint) + end + + def sane_payload?(payload) + payload.split(DELIMITER).each do |segment| + return false unless valid_base64_encoding?(segment) + end + end + + def join_segments(*segments) + segments.map { |segment| encode(segment) }.join(DELIMITER) + end + + def split_into_segments(string) + string.split(DELIMITER).map { |segment| decode(segment) } + end + end + end +end diff --git a/app/services/encryption/encryptors/attribute_encryptor.rb b/app/services/encryption/encryptors/attribute_encryptor.rb index 43e4d69810e..dd32f618288 100644 --- a/app/services/encryption/encryptors/attribute_encryptor.rb +++ b/app/services/encryption/encryptors/attribute_encryptor.rb @@ -15,7 +15,7 @@ def decrypt(ciphertext) result = try_decrypt(ciphertext, key: key, cost: cost) return result unless result.nil? end - raise Pii::EncryptionError, 'unable to decrypt attribute with any key' + raise EncryptionError, 'unable to decrypt attribute with any key' end def stale? @@ -41,7 +41,7 @@ def try_decrypt(ciphertext, key:, cost:) result = UserAccessKeyEncryptor.new(user_access_key).decrypt(ciphertext) self.stale = key != current_key result - rescue Pii::EncryptionError + rescue EncryptionError nil end end diff --git a/app/services/encryption/encryptors/pii_encryptor.rb b/app/services/encryption/encryptors/pii_encryptor.rb index d6cffd611d6..2cc61810111 100644 --- a/app/services/encryption/encryptors/pii_encryptor.rb +++ b/app/services/encryption/encryptors/pii_encryptor.rb @@ -1,39 +1,66 @@ module Encryption module Encryptors class PiiEncryptor - include Pii::Encodable + Ciphertext = Struct.new(:encrypted_data, :salt, :cost) do + include Encodable + class << self + include Encodable + end - def initialize(password:, salt:, cost: nil) - cost ||= Figaro.env.scrypt_cost - @aes_cipher = Pii::Cipher.new + def self.parse_from_string(ciphertext_string) + parsed_json = JSON.parse(ciphertext_string) + new(extract_encrypted_data(parsed_json), parsed_json['salt'], parsed_json['cost']) + rescue JSON::ParserError + raise EncryptionError, 'ciphertext is not valid JSON' + end + + def to_s + { + encrypted_data: encode(encrypted_data), + salt: salt, + cost: cost, + }.to_json + end + + def self.extract_encrypted_data(parsed_json) + encoded_encrypted_data = parsed_json['encrypted_data'] + raise EncryptionError, 'ciphertext invalid' unless valid_base64_encoding?( + encoded_encrypted_data + ) + decode(encoded_encrypted_data) + end + end + + def initialize(password) + @password = password + @aes_cipher = AesCipher.new @kms_client = KmsClient.new - @scrypt_password_digest = build_scrypt_password(password, salt, cost).digest end def encrypt(plaintext) + salt = Devise.friendly_token[0, 20] + cost = Figaro.env.scrypt_cost + aes_encryption_key = scrypt_password_digest(salt: salt, cost: cost) aes_encrypted_ciphertext = aes_cipher.encrypt(plaintext, aes_encryption_key) kms_encrypted_ciphertext = kms_client.encrypt(aes_encrypted_ciphertext) - encode(kms_encrypted_ciphertext) + Ciphertext.new(kms_encrypted_ciphertext, salt, cost).to_s end - def decrypt(ciphertext) - raise Pii::EncryptionError, 'ciphertext invalid' unless valid_base64_encoding?(ciphertext) - decoded_ciphertext = decode(ciphertext) - aes_encrypted_ciphertext = kms_client.decrypt(decoded_ciphertext) + def decrypt(ciphertext_string) + ciphertext = Ciphertext.parse_from_string(ciphertext_string) + aes_encrypted_ciphertext = kms_client.decrypt(ciphertext.encrypted_data) + aes_encryption_key = scrypt_password_digest(salt: ciphertext.salt, cost: ciphertext.cost) aes_cipher.decrypt(aes_encrypted_ciphertext, aes_encryption_key) end private - attr_reader :aes_cipher, :kms_client, :scrypt_password_digest + attr_reader :password, :aes_cipher, :kms_client - def build_scrypt_password(password, salt, cost) + def scrypt_password_digest(salt:, cost:) scrypt_salt = cost + OpenSSL::Digest::SHA256.hexdigest(salt) scrypted = SCrypt::Engine.hash_secret password, scrypt_salt, 32 - SCrypt::Password.new(scrypted) - end - - def aes_encryption_key + scrypt_password_digest = SCrypt::Password.new(scrypted).digest scrypt_password_digest[0...32] end end diff --git a/app/services/encryption/encryptors/user_access_key_encryptor.rb b/app/services/encryption/encryptors/user_access_key_encryptor.rb index 0975639ba74..0f5e505d7fc 100644 --- a/app/services/encryption/encryptors/user_access_key_encryptor.rb +++ b/app/services/encryption/encryptors/user_access_key_encryptor.rb @@ -1,13 +1,13 @@ module Encryption module Encryptors class UserAccessKeyEncryptor - include Pii::Encodable + include Encodable DELIMITER = '.'.freeze def initialize(user_access_key) @user_access_key = user_access_key - @encryptor = Pii::Encryptor.new + @encryptor = AesEncryptor.new end def encrypt(plaintext) @@ -36,7 +36,7 @@ def build_ciphertext(encryption_key, encrypted_contents) def encryption_key_from_ciphertext(ciphertext) encoded_encryption_key = ciphertext.split(DELIMITER).first - raise Pii::EncryptionError, 'ciphertext is invalid' unless valid_base64_encoding?( + raise EncryptionError, 'ciphertext is invalid' unless valid_base64_encoding?( encoded_encryption_key ) decode(encoded_encryption_key) @@ -44,7 +44,7 @@ def encryption_key_from_ciphertext(ciphertext) def encrypted_contents_from_ciphertext(ciphertext) contents = ciphertext.split(DELIMITER).second - raise Pii::EncryptionError, 'ciphertext is missing encrypted contents' if contents.nil? + raise EncryptionError, 'ciphertext is missing encrypted contents' if contents.nil? contents end diff --git a/app/services/encryption/kms_client.rb b/app/services/encryption/kms_client.rb index 2ee8808657c..56c0c77f8d2 100644 --- a/app/services/encryption/kms_client.rb +++ b/app/services/encryption/kms_client.rb @@ -1,6 +1,6 @@ module Encryption class KmsClient - include Pii::Encodable + include Encodable KEY_TYPE = { KMS: 'KMSx', @@ -30,7 +30,7 @@ def decrypt_kms(ciphertext) kms_input = ciphertext.sub(KEY_TYPE[:KMS], '') aws_client.decrypt(ciphertext_blob: kms_input).plaintext rescue Aws::KMS::Errors::InvalidCiphertextException - raise Pii::EncryptionError, 'Aws::KMS::Errors::InvalidCiphertextException' + raise EncryptionError, 'Aws::KMS::Errors::InvalidCiphertextException' end def encrypt_local(plaintext) @@ -53,7 +53,7 @@ def aws_client end def encryptor - @encryptor ||= Pii::Encryptor.new + @encryptor ||= Encryptors::AesEncryptor.new end end end diff --git a/app/services/encryption/password_verifier.rb b/app/services/encryption/password_verifier.rb new file mode 100644 index 00000000000..6e5196bf638 --- /dev/null +++ b/app/services/encryption/password_verifier.rb @@ -0,0 +1,56 @@ +module Encryption + class PasswordVerifier + PasswordDigest = Struct.new( + :encrypted_password, + :encryption_key, + :password_salt, + :password_cost + ) do + def self.parse_from_string(digest_string) + data = JSON.parse(digest_string, symbolize_names: true) + new( + data[:encrypted_password], + data[:encryption_key], + data[:password_salt], + data[:password_cost] + ) + rescue JSON::ParserError + raise EncryptionError, 'digest contains invalid json' + end + + def to_s + { + encrypted_password: encrypted_password, + encryption_key: encryption_key, + password_salt: password_salt, + password_cost: password_cost, + }.to_json + end + end + + def self.digest(password) + salt = Devise.friendly_token[0, 20] + uak = UserAccessKey.new(password: password, salt: salt) + uak.build + PasswordDigest.new( + uak.encrypted_password, + uak.encryption_key, + salt, + uak.cost + ) + end + + def self.verify(password:, digest:) + parsed_digest = PasswordDigest.parse_from_string(digest) + uak = UserAccessKey.new( + password: password, + salt: parsed_digest.password_salt, + cost: parsed_digest.password_cost + ) + uak.unlock(parsed_digest.encryption_key) + Devise.secure_compare(uak.encrypted_password, parsed_digest.encrypted_password) + rescue EncryptionError + false + end + end +end diff --git a/app/services/file_encryptor.rb b/app/services/file_encryptor.rb index 37eae7f632a..8544e982287 100644 --- a/app/services/file_encryptor.rb +++ b/app/services/file_encryptor.rb @@ -47,7 +47,7 @@ def gpg_encrypt_command(outfile) --pinentry-mode loopback \ --status-fd \ --with-colons \ - --no-tty \ + --no-tty \ -e \ -r #{Shellwords.shellescape(recipient_email)} \ --output #{Shellwords.shellescape(outfile)} diff --git a/app/services/openid_connect_attribute_scoper.rb b/app/services/openid_connect_attribute_scoper.rb index 97fe4dcc3aa..3bd63c68485 100644 --- a/app/services/openid_connect_attribute_scoper.rb +++ b/app/services/openid_connect_attribute_scoper.rb @@ -29,7 +29,7 @@ class OpenidConnectAttributeScoper SCOPE_ATTRIBUTE_MAP = {}.tap do |scope_attribute_map| ATTRIBUTE_SCOPES_MAP.each do |attribute, scopes| - next [] if attribute =~ /_verified$/ + next [] if attribute.match?(/_verified$/) scopes.each do |scope| scope_attribute_map[scope] ||= [] scope_attribute_map[scope] << attribute diff --git a/app/services/otp_rate_limiter.rb b/app/services/otp_rate_limiter.rb index b0287467a20..26e9b2dd9ae 100644 --- a/app/services/otp_rate_limiter.rb +++ b/app/services/otp_rate_limiter.rb @@ -16,7 +16,7 @@ def exceeded_otp_send_limit? end def max_requests_reached? - entry_for_current_phone.otp_send_count >= otp_maxretry_times + entry_for_current_phone.otp_send_count > otp_maxretry_times end def rate_limit_period_expired? @@ -32,9 +32,8 @@ def lock_out_user end def increment - entry_for_current_phone.otp_send_count += 1 - entry_for_current_phone.otp_last_sent_at = Time.zone.now - entry_for_current_phone.save! + # DO NOT MEMOIZE + @entry = OtpRequestsTracker.atomic_increment(entry_for_current_phone.id) end private diff --git a/app/services/personal_key_generator.rb b/app/services/personal_key_generator.rb index 81f9920f691..646a79f2433 100644 --- a/app/services/personal_key_generator.rb +++ b/app/services/personal_key_generator.rb @@ -9,20 +9,20 @@ def initialize(user, length: 4) end def create - user.recovery_salt = Devise.friendly_token[0, 20] - user.recovery_cost = Figaro.env.scrypt_cost - @user_access_key = make_user_access_key(raw_personal_key) - user.personal_key = hashed_code + create_recovery_code + create_encrypted_recovery_code_digest user.save! raw_personal_key.tr(' ', '-') end def verify(plaintext_code) @user_access_key = make_user_access_key(normalize(plaintext_code)) - encryption_key, encrypted_code = user.personal_key.split(Pii::Encryptor::DELIMITER) + encryption_key, encrypted_code = user.personal_key.split( + Encryption::Encryptors::AesEncryptor::DELIMITER + ) begin user_access_key.unlock(encryption_key) - rescue Pii::EncryptionError => _err + rescue Encryption::EncryptionError => _err return false end Devise.secure_compare(encrypted_code, user_access_key.encrypted_password) @@ -42,6 +42,22 @@ def normalize(plaintext_code) attr_reader :user + def create_recovery_code + user.recovery_salt = Devise.friendly_token[0, 20] + user.recovery_cost = Figaro.env.scrypt_cost + @user_access_key = make_user_access_key(raw_personal_key) + user.personal_key = hashed_code + end + + def create_encrypted_recovery_code_digest + user.encrypted_recovery_code_digest = { + encryption_key: user_access_key.encryption_key, + encrypted_password: user_access_key.encrypted_password, + password_cost: user.recovery_cost, + password_salt: user.recovery_salt, + }.to_json + end + def encode_code(code:, length:, split:) decoded = Base32::Crockford.decode(code) Base32::Crockford.encode(decoded, length: length, split: split).tr('-', ' ') @@ -60,7 +76,7 @@ def hashed_code [ user_access_key.encryption_key, user_access_key.encrypted_password, - ].join(Pii::Encryptor::DELIMITER) + ].join(Encryption::Encryptors::AesEncryptor::DELIMITER) end def raw_personal_key diff --git a/app/services/pii/attributes.rb b/app/services/pii/attributes.rb index 02bb5823631..0a4778b98b3 100644 --- a/app/services/pii/attributes.rb +++ b/app/services/pii/attributes.rb @@ -18,12 +18,8 @@ def self.new_from_hash(hash) attrs end - def self.new_from_encrypted(encrypted, password:, salt:, cost:) - encryptor = Encryption::Encryptors::PiiEncryptor.new( - password: password, - salt: salt, - cost: cost - ) + def self.new_from_encrypted(encrypted, password:) + encryptor = Encryption::Encryptors::PiiEncryptor.new(password) decrypted = encryptor.decrypt(encrypted) new_from_json(decrypted) end @@ -39,12 +35,8 @@ def initialize(*args) assign_all_members end - def encrypted(password:, salt:, cost:) - encryptor = Encryption::Encryptors::PiiEncryptor.new( - password: password, - salt: salt, - cost: cost - ) + def encrypted(password) + encryptor = Encryption::Encryptors::PiiEncryptor.new(password) encryptor.encrypt(to_json) end diff --git a/app/services/pii/encryptor.rb b/app/services/pii/encryptor.rb deleted file mode 100644 index c95806e012b..00000000000 --- a/app/services/pii/encryptor.rb +++ /dev/null @@ -1,59 +0,0 @@ -module Pii - class Encryptor - include Encodable - - DELIMITER = '.'.freeze - - # "It is a riddle, wrapped in a mystery, inside an enigma; but perhaps there is a key." - # - Winston Churchill, https://en.wiktionary.org/wiki/a_riddle_wrapped_up_in_an_enigma - # - - def initialize - self.cipher = Pii::Cipher.new - end - - def encrypt(plaintext, cek) - payload = fingerprint_and_concat(plaintext) - encode(cipher.encrypt(payload, cek)) - end - - def decrypt(ciphertext, cek) - raise EncryptionError, 'ciphertext is invalid' unless sane_payload?(ciphertext) - decrypt_and_test_payload(decode(ciphertext), cek) - end - - private - - attr_accessor :cipher - - def fingerprint_and_concat(plaintext) - fingerprint = Pii::Fingerprinter.fingerprint(plaintext) - join_segments(plaintext, fingerprint) - end - - def decrypt_and_test_payload(payload, cek) - begin - payload = cipher.decrypt(payload, cek) - rescue OpenSSL::Cipher::CipherError => err - raise EncryptionError, err.inspect - end - raise EncryptionError, 'payload is invalid' unless sane_payload?(payload) - plaintext, fingerprint = split_into_segments(payload) - return plaintext if Pii::Fingerprinter.verify(plaintext, fingerprint) - end - - def sane_payload?(payload) - payload.split(DELIMITER).each do |segment| - return false unless valid_base64_encoding?(segment) - end - end - - def join_segments(*segments) - segments.map { |segment| encode(segment) }.join(DELIMITER) - end - - def split_into_segments(string) - string.split(DELIMITER).map { |segment| decode(segment) } - end - end -end diff --git a/app/services/piv_cac_service.rb b/app/services/piv_cac_service.rb index 1acf6661666..facd772de2d 100644 --- a/app/services/piv_cac_service.rb +++ b/app/services/piv_cac_service.rb @@ -1,8 +1,11 @@ +require 'base64' require 'cgi' require 'net/https' module PivCacService class << self + RANDOM_HOSTNAME_BYTES = 2 + include Rails.application.routes.url_helpers def decode_token(token) @@ -14,7 +17,7 @@ def piv_cac_service_link(nonce) if FeatureManagement.development_and_piv_cac_entry_enabled? test_piv_cac_entry_url else - uri = URI(Figaro.env.piv_cac_service_url) + uri = URI(randomize_uri(Figaro.env.piv_cac_service_url)) # add the nonce uri.query = "nonce=#{CGI.escape(nonce)}" uri.to_s @@ -38,6 +41,11 @@ def piv_cac_available_for_agency?(agency) private + def randomize_uri(uri) + # we only support {random}, so we're going for performance here + uri.gsub('{random}') { |_| SecureRandom.hex(RANDOM_HOSTNAME_BYTES) } + end + # Only used in tests def reset_piv_cac_avaialable_agencies @piv_cac_agencies = nil @@ -54,14 +62,33 @@ def token_decoded(token) return { 'error' => 'service.disabled' } if FeatureManagement.identity_pki_disabled? uri = URI(piv_cac_verify_token_link) - res = Net::HTTP.post_form(uri, token: token) + res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http| + http.request(decode_request(uri, token)) + end decode_token_response(res) end + def decode_request(uri, token) + req = Net::HTTP::Post.new(uri, 'Authentication' => authenticate(token)) + req.form_data = { token: token } + req + end + + def authenticate(token) + # TODO: make this secret required once we have everything deployed and configured + # The piv/cac service side is pending, so this is not critical yet. + secret = Figaro.env.piv_cac_verify_token_secret + return '' if secret.blank? + nonce = SecureRandom.hex(10) + hmac = Base64.urlsafe_encode64( + OpenSSL::HMAC.digest('SHA256', secret, [token, nonce].join('+')) + ) + "hmac :#{nonce}:#{hmac}" + end + def decode_token_response(res) return { 'error' => 'token.bad' } unless res.code.to_i == 200 - result = res.body - JSON.parse(result) + JSON.parse(res.body) rescue JSON::JSONError { 'error' => 'token.bad' } end diff --git a/app/services/remote_settings_service.rb b/app/services/remote_settings_service.rb new file mode 100644 index 00000000000..bd6e48af0da --- /dev/null +++ b/app/services/remote_settings_service.rb @@ -0,0 +1,33 @@ +class RemoteSettingsService + def self.load_yml_erb(location) + result = ERB.new(load(location)).result + begin + YAML.safe_load(result.to_s) + rescue StandardError + raise "Error parsing yml file: #{location}" + end + result + end + + def self.load(location) + raise "Location must begin with 'https://': #{location}" unless remote?(location) + response = HTTParty.get( + location, headers: + { 'User-Agent' => 'Mozilla/5.0 (Windows NT 6.1; WOW64; rv:40.0) Gecko/20100101 Firefox/40.1' } + ) + raise "Error retrieving: #{location}" unless response.code == 200 + response.body + end + + def self.update_setting(name, url) + remote_setting = RemoteSetting.where(name: name).first_or_initialize + remote_setting.url = url + raise "url not whitelisted: #{url}" unless remote_setting.valid? + remote_setting.contents = RemoteSettingsService.load(remote_setting.url) + remote_setting.save + end + + def self.remote?(location) + location.to_s.starts_with?('https://') + end +end diff --git a/app/services/saml_cert_rotation_manager.rb b/app/services/saml_cert_rotation_manager.rb index 41b2cc29980..a2fc116564f 100644 --- a/app/services/saml_cert_rotation_manager.rb +++ b/app/services/saml_cert_rotation_manager.rb @@ -23,7 +23,7 @@ def self.rotation_path_suffix def self.use_new_secrets_for_request?(request) return false unless FeatureManagement.enable_saml_cert_rotation? - return false unless request.path =~ /#{rotation_path_suffix}$/ + return false unless request.path.match?(/#{rotation_path_suffix}$/) true end diff --git a/app/services/service_provider_seeder.rb b/app/services/service_provider_seeder.rb index 002263edc76..73f21bcb83f 100644 --- a/app/services/service_provider_seeder.rb +++ b/app/services/service_provider_seeder.rb @@ -22,10 +22,15 @@ def run attr_reader :rails_env, :deploy_env def service_providers - content = ERB.new(Rails.root.join('config', 'service_providers.yml').read).result + file = remote_setting || Rails.root.join('config', 'service_providers.yml').read + content = ERB.new(file).result YAML.safe_load(content).fetch(rails_env, {}) end + def remote_setting + RemoteSetting.find_by(name: 'service_providers.yml')&.contents + end + def write_service_provider?(config) return true if rails_env != 'production' diff --git a/app/validators/otp_delivery_preference_validator.rb b/app/validators/otp_delivery_preference_validator.rb index b6f8ae643e2..42a22dc404f 100644 --- a/app/validators/otp_delivery_preference_validator.rb +++ b/app/validators/otp_delivery_preference_validator.rb @@ -5,16 +5,26 @@ module OtpDeliveryPreferenceValidator validate :otp_delivery_preference_supported end + def otp_delivery_preference_supported? + return true unless otp_delivery_preference == 'voice' + !phone_number_capabilities.sms_only? + end + def otp_delivery_preference_supported - capabilities = PhoneNumberCapabilities.new(phone) - return unless otp_delivery_preference == 'voice' && capabilities.sms_only? + return if otp_delivery_preference_supported? errors.add( :phone, I18n.t( 'devise.two_factor_authentication.otp_delivery_preference.phone_unsupported', - location: capabilities.unsupported_location + location: phone_number_capabilities.unsupported_location ) ) end + + private + + def phone_number_capabilities + @phone_number_capabilities ||= PhoneNumberCapabilities.new(phone) + end end diff --git a/app/views/account_recovery_setup/index.html.slim b/app/views/account_recovery_setup/index.html.slim new file mode 100644 index 00000000000..d0dc36506b2 --- /dev/null +++ b/app/views/account_recovery_setup/index.html.slim @@ -0,0 +1,23 @@ +- title @presenter.title + +h1.h3.my0 = @presenter.heading +p.mt-tiny.mb3 = @presenter.info + += simple_form_for(@two_factor_options_form, + html: { autocomplete: 'off', role: 'form' }, + method: :patch, + url: two_factor_options_path) do |f| + .mb3 + fieldset.m0.p0.border-none. + legend.mb1.h4.serif.bold = @presenter.label + - @presenter.options.each do |option| + label.btn-border.col-12.mb1 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 + + = f.button :submit, t('forms.buttons.continue') diff --git a/app/views/idv/jurisdiction/show.html.slim b/app/views/idv/jurisdiction/show.html.slim deleted file mode 100644 index e884d0a42df..00000000000 --- a/app/views/idv/jurisdiction/show.html.slim +++ /dev/null @@ -1,22 +0,0 @@ -- i18n_args = @state ? { state: state_name_for_abbrev(@state) } : {} - -- title t("idv.titles.#{@reason}", **i18n_args) - -h1.h3.mb2.my0 = t("idv.titles.#{@reason}", **i18n_args) - -- if @state - p.mb1 = t("idv.messages.jurisdiction.#{@reason}", **i18n_args) - -.col-2 - hr.mt5.mb2.bw4.border-blue.rounded - -- if decorated_session.sp_name - - support_link = link_to(decorated_session.sp_name, decorated_session.sp_alert_learn_more) - p == t('idv.messages.jurisdiction.sp_support', link: support_link) - -- profile_link = link_to(t('idv.messages.jurisdiction.profile_link'), account_path) -p == t('idv.messages.jurisdiction.profile', link: profile_link) - -p.mt4 = link_to t('forms.buttons.back'), - decorated_session.cancel_link_url || account_path, - class: 'btn btn-primary btn-wide' diff --git a/app/views/layouts/application.html.slim b/app/views/layouts/application.html.slim index 9cd7ae6d5d9..17aaf2fe582 100644 --- a/app/views/layouts/application.html.slim +++ b/app/views/layouts/application.html.slim @@ -1,76 +1,3 @@ -doctype html -html lang="#{I18n.locale}" class='no-js' +- background_cls 'sm-bg-light-blue' - head - meta charset='utf-8' - meta name='description' content="#{content_for?(:description) ? yield(:description) : APP_NAME}" - meta http-equiv='X-UA-Compatible' content='IE=edge' - meta name='msapplication-config' content='none' - meta[name='viewport' content='width=device-width, initial-scale=1.0'] - meta name="format-detection" content="telephone=no" - - if content_for?(:meta_refresh) - meta http-equiv="refresh" content="#{yield(:meta_refresh)}" - - if session_with_trust? || FeatureManagement.disallow_all_web_crawlers? - meta name='robots' content='noindex,nofollow' - - title - = APP_NAME - - if content_for?(:title) - = ' - ' - = yield(:title) - - == stylesheet_link_tag 'application', media: 'all' - - == javascript_include_tag 'i18n-strings' - == javascript_pack_tag 'application' - == csrf_meta_tags - - link rel='apple-touch-icon' sizes='180x180' href='/apple-touch-icon.png' - link rel='icon' type='image/png' href='/favicon-32x32.png' sizes='32x32' - link rel='icon' type='image/png' href='/favicon-16x16.png' sizes='16x16' - link rel='manifest' href='/manifest.json' - link rel='mask-icon' href='/safari-pinned-tab.svg' color='#e21c3d' - meta name='theme-color' content='#ffffff' - - - - - - if Figaro.env.google_analytics_key.present? - = render 'shared/google_analytics/page_tracking' - - if Figaro.env.newrelic_browser_key.present? && Figaro.env.newrelic_browser_app_id.present? - = render 'shared/newrelic/browser_instrumentation' - - body class="#{Rails.env}-env site sm-bg-light-blue" - .site-wrap - = render 'shared/i18n_mode' if FeatureManagement.enable_i18n_mode? - = render 'shared/no_pii_banner' if FeatureManagement.no_pii_mode? - = render 'shared/usa_banner' - - if content_for?(:nav) - = yield(:nav) - - else - = render decorated_session.nav_partial - .container - div class="px2 py2 sm-py5 sm-px6 mx-auto sm-mb5 border-box card #{yield(:card_cls)}" - = render 'shared/flashes' - == yield - = render 'shared/footer_lite' - - #session-timeout-cntnr - - if current_user - = auto_session_timeout_js - - else - = auto_session_expired_js - - - if FeatureManagement.enable_i18n_mode? - == javascript_pack_tag 'i18n-mode' - - - if Figaro.env.participate_in_dap == 'true' && !session_with_trust? - = render 'shared/dap_analytics' += render template: 'layouts/base' diff --git a/app/views/layouts/base.html.slim b/app/views/layouts/base.html.slim new file mode 100644 index 00000000000..acfe5b7cca3 --- /dev/null +++ b/app/views/layouts/base.html.slim @@ -0,0 +1,76 @@ +doctype html +html lang="#{I18n.locale}" class='no-js' + + head + meta charset='utf-8' + meta name='description' content="#{content_for?(:description) ? yield(:description) : APP_NAME}" + meta http-equiv='X-UA-Compatible' content='IE=edge' + meta name='msapplication-config' content='none' + meta[name='viewport' content='width=device-width, initial-scale=1.0'] + meta name="format-detection" content="telephone=no" + - if content_for?(:meta_refresh) + meta http-equiv="refresh" content="#{yield(:meta_refresh)}" + - if session_with_trust? || FeatureManagement.disallow_all_web_crawlers? + meta name='robots' content='noindex,nofollow' + + title + = APP_NAME + - if content_for?(:title) + = ' - ' + = yield(:title) + + == stylesheet_link_tag 'application', media: 'all' + + == javascript_include_tag 'i18n-strings' + == javascript_pack_tag 'application' + == csrf_meta_tags + + link rel='apple-touch-icon' sizes='180x180' href='/apple-touch-icon.png' + link rel='icon' type='image/png' href='/favicon-32x32.png' sizes='32x32' + link rel='icon' type='image/png' href='/favicon-16x16.png' sizes='16x16' + link rel='manifest' href='/manifest.json' + link rel='mask-icon' href='/safari-pinned-tab.svg' color='#e21c3d' + meta name='theme-color' content='#ffffff' + + + + + - if Figaro.env.google_analytics_key.present? + = render 'shared/google_analytics/page_tracking' + - if Figaro.env.newrelic_browser_key.present? && Figaro.env.newrelic_browser_app_id.present? + = render 'shared/newrelic/browser_instrumentation' + + body class="#{Rails.env}-env site #{yield(:background_cls)}" + .site-wrap + = render 'shared/i18n_mode' if FeatureManagement.enable_i18n_mode? + = render 'shared/no_pii_banner' if FeatureManagement.no_pii_mode? + = render 'shared/usa_banner' + - if content_for?(:nav) + = yield(:nav) + - else + = render decorated_session.nav_partial + .container + div class="px2 py2 sm-py5 sm-px6 mx-auto sm-mb5 border-box card #{yield(:card_cls)}" + = render 'shared/flashes' + == yield + = render 'shared/footer_lite' + + #session-timeout-cntnr + - if current_user + = auto_session_timeout_js + - else + = auto_session_expired_js + + - if FeatureManagement.enable_i18n_mode? + == javascript_pack_tag 'i18n-mode' + + - if Figaro.env.participate_in_dap == 'true' && !session_with_trust? + = render 'shared/dap_analytics' diff --git a/app/views/layouts/card_wide.html.slim b/app/views/layouts/card_wide.html.slim index 000f2efd430..f19d98cf6b1 100644 --- a/app/views/layouts/card_wide.html.slim +++ b/app/views/layouts/card_wide.html.slim @@ -1,3 +1,4 @@ - card_cls 'card-wide' +- background_cls 'sm-bg-light-blue' -= render template: 'layouts/application' += render template: 'layouts/base' diff --git a/app/views/shared/_cancel_or_back_to_options.html.slim b/app/views/shared/_cancel_or_back_to_options.html.slim new file mode 100644 index 00000000000..82652316b46 --- /dev/null +++ b/app/views/shared/_cancel_or_back_to_options.html.slim @@ -0,0 +1,5 @@ +.mt2.pt1.border-top +- if user_fully_authenticated? + = link_to cancel_link_text, account_path, class: 'h5' +- else + = link_to t('devise.two_factor_authentication.two_factor_choice_cancel'), two_factor_options_path diff --git a/app/views/shared/_failure.html.slim b/app/views/shared/_failure.html.slim new file mode 100644 index 00000000000..5031207322d --- /dev/null +++ b/app/views/shared/_failure.html.slim @@ -0,0 +1,19 @@ +- title presenter.title + += image_tag(asset_url(presenter.state_icon), + alt: presenter.state_alt_text, width: 54) + +h1.h3.mb1.mt3.my0 = presenter.header + +p = presenter.description + +.col-2 + hr class="mt3 mb2 bw4 rounded border-#{presenter.state_color}" + +h2.h4.mb2.mt3.my0 = presenter.message + +- presenter.next_steps.each do |step| + p == step + +- if presenter.js + = nonced_javascript_tag presenter.js diff --git a/app/views/shared/_nav_branded.html.slim b/app/views/shared/_nav_branded.html.slim index 58f2a541a70..e1589e52ec3 100644 --- a/app/views/shared/_nav_branded.html.slim +++ b/app/views/shared/_nav_branded.html.slim @@ -3,5 +3,5 @@ nav.nav-branded.vertical-align.bg-light-blue.center.relative alt: APP_NAME, class: 'inline-block align-middle') .px-12p.inline-block span.absolute.top-0.bottom-0.border-right.my1.sm-my2 - = image_tag(asset_url("sp-logos/#{decorated_session.sp_logo}"), height: 40, + = image_tag(decorated_session.sp_logo_url, height: 40, alt: decorated_session.sp_name, class: 'inline-block align-middle') diff --git a/app/views/shared/_sp_alert.html.slim b/app/views/shared/_sp_alert.html.slim index 6eb76769a30..5d4b716aabd 100644 --- a/app/views/shared/_sp_alert.html.slim +++ b/app/views/shared/_sp_alert.html.slim @@ -5,6 +5,8 @@ p.mb1 - if current_page?(sign_up_start_path) = t("service_providers.#{sp_alert_name}.account_page.body") + - elsif current_page?(sign_up_email_path) + = t("service_providers.#{sp_alert_name}.create_account_page.body") - else - account_link = link_to t("service_providers.#{sp_alert_name}.create_account_link"), sign_up_email_url(request_id: params[:request_id]) diff --git a/app/views/sign_up/registrations/new.html.slim b/app/views/sign_up/registrations/new.html.slim index 8aa0d261bad..f46be28dff1 100644 --- a/app/views/sign_up/registrations/new.html.slim +++ b/app/views/sign_up/registrations/new.html.slim @@ -1,5 +1,7 @@ - title t('titles.registrations.new') += render 'shared/sp_alert' + h1.h3.my0 = t('headings.registrations.enter_email') = simple_form_for(@register_user_email_form, diff --git a/app/views/two_factor_authentication/shared/max_login_attempts_reached.html.erb b/app/views/two_factor_authentication/shared/max_login_attempts_reached.html.erb deleted file mode 100644 index bc4a3651a51..00000000000 --- a/app/views/two_factor_authentication/shared/max_login_attempts_reached.html.erb +++ /dev/null @@ -1,19 +0,0 @@ -<% lockout_time_in_words = decorator.lockout_time_remaining_in_words %> -<% lockout_time_remaining = decorator.lockout_time_remaining %> -<% title t('titles.account_locked') %> - -
-<%= t("devise.two_factor_authentication.max_#{type}_login_attempts_reached") %> -
-- <%= t('devise.two_factor_authentication.please_try_again_html', - time_remaining: content_tag(:span, lockout_time_in_words, id: 'countdown')) %> -
- -<%= nonced_javascript_tag do %> - var test = <%= lockout_time_remaining %> * 1000; - window.LoginGov.countdownTimer(document.getElementById('countdown'), test); -<% end %> diff --git a/app/views/two_factor_authentication/shared/max_otp_requests_reached.html.erb b/app/views/two_factor_authentication/shared/max_otp_requests_reached.html.erb deleted file mode 100644 index c5db7d9aabe..00000000000 --- a/app/views/two_factor_authentication/shared/max_otp_requests_reached.html.erb +++ /dev/null @@ -1,19 +0,0 @@ -<% lockout_time_in_words = decorator.lockout_time_remaining_in_words %> -<% lockout_time_remaining = decorator.lockout_time_remaining %> -<% title t('titles.account_locked') %> - --<%= t("devise.two_factor_authentication.max_otp_requests_reached") %> -
-- <%= t('devise.two_factor_authentication.please_try_again_html', - time_remaining: content_tag(:span, lockout_time_in_words, id: 'countdown')) %> -
- -<%= nonced_javascript_tag do %> - var test = <%= lockout_time_remaining %> * 1000; - window.LoginGov.countdownTimer(document.getElementById('countdown'), test); -<% end %> diff --git a/app/views/users/phone_setup/index.html.slim b/app/views/users/phone_setup/index.html.slim new file mode 100644 index 00000000000..7728c1797f2 --- /dev/null +++ b/app/views/users/phone_setup/index.html.slim @@ -0,0 +1,28 @@ +- title @presenter.heading += image_tag asset_url(@presenter.image), width: 200, class: 'mb2' + +h1.h3.my0 = @presenter.heading +p.mt-tiny.mb0 = @presenter.info += simple_form_for(@user_phone_form, + html: { autocomplete: 'off', role: 'form' }, + data: { unsupported_area_codes: unsupported_area_codes, + international_phone_form: true }, + method: :patch, + url: phone_setup_path) do |f| + .sm-col-8.js-intl-tel-code-select + = f.input :international_code, + collection: international_phone_codes, + include_blank: false, + input_html: { class: 'international-code' } + .sm-col-8.mb3 + = f.label :phone + strong.left = @presenter.label + = f.input :phone, as: :tel, label: false, required: true, + input_html: { class: 'phone col-8 mb4' } + = f.button :submit, t('forms.buttons.send_security_code') +.mt2.pt1.border-top + - path = current_user.piv_cac_enabled? ? account_recovery_setup_path : two_factor_options_path + = link_to t('devise.two_factor_authentication.two_factor_choice_cancel'), path + + = stylesheet_link_tag 'intl-tel-number/intlTelInput' + = javascript_pack_tag 'intl-tel-input' diff --git a/app/views/users/phones/edit.html.slim b/app/views/users/phones/edit.html.slim index 9099011ff55..bd922912786 100644 --- a/app/views/users/phones/edit.html.slim +++ b/app/views/users/phones/edit.html.slim @@ -6,6 +6,19 @@ h1.h3.my0 = t('headings.edit_info.phone') data: { unsupported_area_codes: unsupported_area_codes, international_phone_form: true }, url: manage_phone_path) do |f| - = render 'users/shared/phone_input', f: f + .sm-col-8.js-intl-tel-code-select + = f.input :international_code, + collection: international_phone_codes, + include_blank: false, + input_html: { class: 'international-code' } + .sm-col-8.mb3 + = f.label :phone + strong.left = @presenter.label + = f.input :phone, as: :tel, label: false, required: true, + input_html: { class: 'phone col-8 mb4' } + = render 'users/shared/otp_delivery_preference_selection' = f.button :submit, t('forms.buttons.submit.confirm_change') = render 'shared/cancel', link: account_path + += stylesheet_link_tag 'intl-tel-number/intlTelInput' += javascript_pack_tag 'intl-tel-input' diff --git a/app/views/users/piv_cac_authentication_setup/new.html.slim b/app/views/users/piv_cac_authentication_setup/new.html.slim index c62a521abdf..51f1aba2252 100644 --- a/app/views/users/piv_cac_authentication_setup/new.html.slim +++ b/app/views/users/piv_cac_authentication_setup/new.html.slim @@ -6,6 +6,6 @@ p.mt-tiny.mb3 = @presenter.description = link_to @presenter.piv_cac_capture_text, @presenter.piv_cac_service_link, class: 'btn btn-primary' -= render 'shared/cancel', link: account_path += render 'shared/cancel_or_back_to_options' == javascript_pack_tag 'clipboard' diff --git a/app/views/users/shared/_phone_input.html.slim b/app/views/users/shared/_phone_input.html.slim deleted file mode 100644 index 7e6cea23aa5..00000000000 --- a/app/views/users/shared/_phone_input.html.slim +++ /dev/null @@ -1,16 +0,0 @@ -.sm-col-8.js-intl-tel-code-select - = f.input :international_code, - collection: international_phone_codes, - include_blank: false, - input_html: { class: 'international-code' } -.sm-col-8.mb3 - = f.label :phone - strong.left = t('devise.two_factor_authentication.otp_phone_label') - span#otp_phone_label_info.ml1.italic - = t('devise.two_factor_authentication.otp_phone_label_info') - = f.input :phone, as: :tel, label: false, required: true, - input_html: { class: 'new-phone col-8 mb4' } -= render 'users/shared/otp_delivery_preference_selection' - -= stylesheet_link_tag 'intl-tel-number/intlTelInput' -= javascript_pack_tag 'intl-tel-input' diff --git a/app/views/users/totp_setup/new.html.slim b/app/views/users/totp_setup/new.html.slim index 0cf0000d630..b282b17db51 100644 --- a/app/views/users/totp_setup/new.html.slim +++ b/app/views/users/totp_setup/new.html.slim @@ -42,6 +42,7 @@ ul.list-reset .col.col-6.sm-col-5.px1 = submit_tag t('forms.buttons.submit.default'), class: 'col-12 btn btn-primary align-top' -= render 'shared/cancel', link: account_path + += render 'shared/cancel_or_back_to_options' == javascript_pack_tag 'clipboard' 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 fef9c4a4c6e..85d136b722f 100644 --- a/app/views/users/two_factor_authentication_setup/index.html.slim +++ b/app/views/users/two_factor_authentication_setup/index.html.slim @@ -1,14 +1,25 @@ -- title t('titles.two_factor_setup') - -h1.h3.my0 = t('devise.two_factor_authentication.two_factor_setup') -p.mt-tiny.mb0 - = t('devise.two_factor_authentication.otp_setup_html') -= simple_form_for(@user_phone_form, - html: { autocomplete: 'off', role: 'form' }, - data: { unsupported_area_codes: unsupported_area_codes, - international_phone_form: true }, - method: :patch, - url: phone_setup_path) do |f| - = render 'users/shared/phone_input', f: f - = f.button :submit, t('forms.buttons.send_security_code') +- title @presenter.title + +h1.h3.my0 = @presenter.heading +p.mt-tiny.mb3 = @presenter.info + += simple_form_for(@two_factor_options_form, + html: { autocomplete: 'off', role: 'form' }, + method: :patch, + url: two_factor_options_path) do |f| + .mb3 + fieldset.m0.p0.border-none. + legend.mb1.h4.serif.bold = @presenter.label + - @presenter.options.each do |option| + label.btn-border.col-12.mb1 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 + + = f.button :submit, t('forms.buttons.continue') + = render 'shared/cancel', link: destroy_user_session_path diff --git a/bin/generate-example-keys b/bin/generate-example-keys index e908b472483..12d9cf7ab95 100755 --- a/bin/generate-example-keys +++ b/bin/generate-example-keys @@ -26,7 +26,7 @@ def generate_equifax_gpg_private_key %commit %echo done ' - run "echo '#{parameters}' | gpg --batch --gen-key" + run "echo '#{parameters}' | gpg --batch --pinentry-mode loopback --gen-key" run 'gpg --export --output keys/equifax_gpg.pub.bin logs@login.gov' end diff --git a/bin/release b/bin/release index f186f313120..ab0ebc620ed 100755 --- a/bin/release +++ b/bin/release @@ -132,7 +132,7 @@ def deploy_to_prod end def open_pr_for_int - run "brew install hub" unless `brew list -1 | grep -Fqx hub` + run "brew install hub" if `brew list -1 | grep -Fqx hub`.empty? run "hub pull-request -m \"Deploy #{RC_BRANCH} to int\" -b stages/int" end diff --git a/bin/setup b/bin/setup index 56a94bb5f10..c659ff8f96d 100755 --- a/bin/setup +++ b/bin/setup @@ -71,7 +71,7 @@ Dir.chdir APP_ROOT do run "rm -rf tmp/cache" puts "\n== Adding git hooks via Overcommit ==" - run 'overcommit --install' + run 'bundle exec overcommit --install' puts "\n== Restarting application server ==" run "mkdir -p tmp" diff --git a/certs/sp/doe_prod.crt b/certs/sp/doe_prod.crt new file mode 100644 index 00000000000..ce7f45d0d92 --- /dev/null +++ b/certs/sp/doe_prod.crt @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFEDCCAvgCCQD0/Uy5AXKvizANBgkqhkiG9w0BAQUFADBKMQswCQYDVQQGEwJV +UzELMAkGA1UECBMCTUQxEzARBgNVBAcTCkdlcm1hbnRvd24xDDAKBgNVBAoTA0RP +RTELMAkGA1UECxMCRkUwHhcNMTgwNjE1MDIwMDIxWhcNMTkwNjE1MDIwMDIxWjBK +MQswCQYDVQQGEwJVUzELMAkGA1UECBMCTUQxEzARBgNVBAcTCkdlcm1hbnRvd24x +DDAKBgNVBAoTA0RPRTELMAkGA1UECxMCRkUwggIiMA0GCSqGSIb3DQEBAQUAA4IC +DwAwggIKAoICAQDGsNGjwEcsF1smQjVyPr7f24iQ9rmT1kg0ixQYbc1aYXtVmXwV +bSuZ2STfddflzqv5dRaGbkvwK7EnS9HRRtlxp7mlpXmrrk0uieaFvpFilRecGFwM +cFvI3O3h/xPm+X0dT5I2jS/HBwtANbwNolazSWbpN8/k7Olqn4sknakJ8b0IRYUf +xgpKNc9g76otkGH0dhZwX5px4W8zD9u7Pv7/abjcM46HxnDfF6/k/QmJzGtZAUGN +3D4YlB5hSzu+dSytcSClrnUUqsxciYg60fm5zaaI9p3SH2OrnMMKwT+EfWb17zys +QCPX2ocItosfJbn/6VD5GNVuEJJy3wAOTXJmVViM3s35Gv1HYF9dAeq0Xz1b8TkG +Ytxkl5LDTbFg6/c3QLOaYC3pOh19pVnqfeMtOMB0G1WmsG7CGGZNw8REJ811Qtvu +/ZxQ2xkEpC4rwlR11D3zlKdlpbXMxvSZJUAI58DpCIjGw9lU65/5rsr+b0tB7xGx +Y2tXImZVBn0hTlcXMR9WbnmYFU5VpLHmWcmuk7ASpkXJryUMpTURte81QeLm11U/ +t5Gb3uojic+RNy+FV0yrlaJFyqCLGX7fBNCYAf/vihQ4KBlAwFLrNRpimrapAtB7 +6Tavqe4pzotbJ8oZ2/32M6KJncMO6JdkcjOo940TeGpL8AJu+mT0rrLzewIDAQAB +MA0GCSqGSIb3DQEBBQUAA4ICAQBqFOIMWN4iXDHW11QFgP2yeD979UinhnBEFM5j +9i9TSCIJ0T/t2JXekxGlezFWXpsjWxGD6O+seEhGOfWjFiXVubi9t//vJv7bEgaE +2FliZnhSIzE+MoP2Go1iNVMc2IOWGiXcRGvOvk0r3GEIqzLDLBqHkZa5pKIhy5As +R8IXQQsWahb3qHp5VAW0bpV/zPhT4MfOesNvE7Ll++53FyJOgcMj1Nes3pXboo/U +LdwnJpTt9BwHzi0NBB0nAbopKLmjxGI/zINCzt59VVrXpORvWABbRm6X8OffnehV +Txc4FWe386DwZV/IGKTM4gKisPaZjRQIXPySgLMkbWb5gYFnzZhF9UYuEeYX0PsB +QG9mpm9g6GM9DHFk1NB76c0WjvacIIXvRKuaQxqKz8yZgyld753sg6kLhVCGPq0y +hh2zqTMPIy2XvSlry7htBDp2HEDrqlns4kiQJrxy2KPu8D3tsRQmeTWbADYgC2DF +OzF0U2CZLxavIoUgIvA6CS7vMUcHj9HK+e1qHooPQsz+3wuuSjogJ/+a9kWka3fy +pt00EnqFq7aOtFhzn1iMhth/Iz3mqn6T+KSXes1Uuno+4m3vlXo15rgUXkSr0sv5 +K9g+83aSJjCYJMySjXst0XXdV/dqam39RT1P16mHdYKWRbIMnMXVyerDy/fNzLYh +eCqk/A== +-----END CERTIFICATE----- diff --git a/certs/sp/usss_prod.crt b/certs/sp/usss_prod.crt index dfe346617e9..80b3181a517 100644 --- a/certs/sp/usss_prod.crt +++ b/certs/sp/usss_prod.crt @@ -1,22 +1,26 @@ -----BEGIN CERTIFICATE----- -MIIDnjCCAoagAwIBAgIQjLwoT+vtBa9Ktbn99+V7sjANBgkqhkiG9w0BAQwFADA9 -MQ0wCwYDVQQKEwRVU1NTMQwwCgYDVQQLEwNVQVQxHjAcBgNVBAMTFXBpeC5zZWNy -ZXRzZXJ2aWNlLmdvdjAeFw0xODA0MjMxODAxMjJaFw0yODEyMzEwNDAwMDBaMD0x -DTALBgNVBAoTBFVTU1MxDDAKBgNVBAsTA1VBVDEeMBwGA1UEAxMVcGl4LnNlY3Jl -dHNlcnZpY2UuZ292MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv/ET -i8nRC/v3lkRSrgst6b7NWDpZ7dezjKIv6tjXz96OsovtT49KgI4RSGqgVowLN1j8 -nkhfj8leSHju5P6HkME8//HgZB9LAPyokj7hbUwmOH1wHFVf+W7RvuWCd9dE+WdF -FoysRsuaJmtPbz/9e+37FE/gWpu5ZCLXqDuoskTw13F30DBQDBtckT3VAf5mO+IA -YIkUnj+0RsZtvrmuTyfSitHHHzAVPRcyAv18w84WEcb2Rhu5LQmL8jUmUpCMRw8T -nKJYNnRoLgPL/Rec9swB286WtbTHJ8CAPBhfcr2TBQLGgIAu+z1d+S4zRyW2Ud5e -OJ39RpxojddB6vXrKQIDAQABo4GZMIGWMA8GA1UdEwEB/wQFMAMCAQAwEwYDVR0l -BAwwCgYIKwYBBQUHAwEwbgYDVR0BBGcwZYAQwkVmKJAsLAg6HeZIoZZwM6E/MD0x -DTALBgNVBAoTBFVTU1MxDDAKBgNVBAsTA1VBVDEeMBwGA1UEAxMVcGl4LnNlY3Jl -dHNlcnZpY2UuZ292ghCMvChP6+0Fr0q1uf335XuyMA0GCSqGSIb3DQEBDAUAA4IB -AQBhfLsBJBlzc0G19SfAYd30QmkrHW8cGtGaYdHA5QLahxhXWLx2tCh/RmYRbiOM -FCV8fvutGqqS9xZk5hWrkXTpogHQgPQu2b/emv6bmxR+o2cfxmFkMqP4T/fTAcW3 -JWGX5DGliO7+lnK0lQA68mt7DTSsJC70C6YYJqNAfUwKWsm+t4zH/p/HgcbGQB5k -rhsEiTTXzzVqq5v0nVGkVqp9Ha1ptbC203Mz1t23LU5dlh6HpkeKkmQ2Zlqkx4MV -OKQzsDbN7LPm1WXfApYsNm8rIjCDOtinH437GTG+/531IfmpgOT8glK8s1hV165G -NwbrX52CYX4TR+/I7nVFynOG +MIIEUDCCA9egAwIBAgITawAErwFberaAczUHmgAAAASvATAKBggqhkjOPQQDAzBr +MRMwEQYKCZImiZPyLGQBGRYDR09WMRMwEQYKCZImiZPyLGQBGRYDREhTMRQwEgYK +CZImiZPyLGQBGRYEVVNTUzEVMBMGCgmSJomT8ixkARkWBVNTTkVUMRIwEAYDVQQD +EwlTU05FVC1DQTIwHhcNMTgwNTAyMTQ1MDQ5WhcNMjAwNTAxMTQ1MDQ5WjByMQsw +CQYDVQQGEwJVUzELMAkGA1UECBMCREMxEzARBgNVBAcTCldhc2hpbmd0b24xFjAU +BgNVBAoTDVVTIEdvdmVybm1lbnQxKTAnBgNVBAMTIFByb3RlY3RpdmUgSW50ZWxs +aWdlbmNlIEV4Y2hhbmdlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA +wyClYaClJIQMLvnTVzXHRBarJFl+majYhRO80Fhd/4AKoDOOTxN9TRNhLMwy5Icz +M0OD4EB/ldQKuKKm8YzTL8gpxeOUv/RCk6CFBAvuEvhNIlQ1RAMeD98U6pKbNI0o +hP5k6j6URh0EAUjQih7B59J2cNN5IoTdqMU8Mj7el5boabum3iIX9fB3WH83Q68H +rtaKadMiY4MgPgzv+VMxTeTUUiXeNUbvEbqRxBoQGNC9elIBY7Nz7XZcQ+A09Gr9 +ZRDdIZRMdgiGGeQMmflnN/Q5/2BHDOLCufTAiI968PBwnxiDObaUAji5izqQX7QL +SbuP6wM7ocn9CajClug2/QIDAQABo4IBhjCCAYIwPgYJKwYBBAGCNxUHBDEwLwYn +KwYBBAGCNxUIg5mdbIHhp0OE/ZsZgo+DX4Ozrx6BVoX3/zqEvL0/AgFkAgENMBMG +A1UdJQQMMAoGCCsGAQUFBwMBMA4GA1UdDwEB/wQEAwIFoDAbBgkrBgEEAYI3FQoE +DjAMMAoGCCsGAQUFBwMBMB0GA1UdDgQWBBQ6SpdMHI1fVx1qeRg4Dxe3G786pzAg +BgNVHREEGTAXghVwaXguc2VjcmV0c2VydmljZS5nb3YwHwYDVR0jBBgwFoAUQdrX +SUGY7WYr29K6AKWWlTXrZaUwUQYDVR0fBEowSDBGoESgQoZAaHR0cDovLzE1d2Fz +MDItc3NuZXQuU1NORVQuVVNTUy5ESFMuR09WL0NlcnRFbnJvbGwvU1NORVQtQ0Ey +LmNybDBJBggrBgEFBQcBAQQ9MDswOQYIKwYBBQUHMAGGLWh0dHBzOi8vMTV3YXMw +Mi1zc25ldC5zc25ldC51c3NzLmRocy5nb3Yvb2NzcDAKBggqhkjOPQQDAwNnADBk +AjAsgIw22KiDgW//2eHmWscNtM+fTh1bdbY7OvA6dgfqv0JjECM5tz3wun8FVHgp +b1UCME9nVmEDgwiALy0xau8n7/LkL8GaBp9q4qIylP1e2UID+swlChDI5L7LcQMC +VNrUjw== -----END CERTIFICATE----- diff --git a/config/agencies.yml b/config/agencies.yml index dd906400676..0957cf2dbe8 100644 --- a/config/agencies.yml +++ b/config/agencies.yml @@ -17,6 +17,8 @@ test: name: 'DOD' 9: name: 'GSA' + 10: + name: 'DOE' development: 1: @@ -37,6 +39,8 @@ development: name: 'DOD' 9: name: 'GSA' + 10: + name: 'DOE' production: 1: @@ -57,3 +61,5 @@ production: name: 'DOD' 9: name: 'GSA' + 10: + name: 'DOE' diff --git a/config/application.yml.example b/config/application.yml.example index f36a5947355..654764aa85b 100644 --- a/config/application.yml.example +++ b/config/application.yml.example @@ -142,9 +142,11 @@ development: otp_delivery_blocklist_findtime: '5' otp_delivery_blocklist_maxretry: '10' otp_valid_for: '10' - password_pepper: 'f22d4b2cafac9066fe2f4416f5b7a32c6942d82f7e00740c7594a095fa8de8db17c05314be7b18a5d6dd5683e73eadf6cc95aa633e5ad9a701edb95192a6a105' + password_pepper: 'f22d4b2cafac9066fe2f4416f5b7a32c' password_strength_enabled: 'true' + piv_cac_agencies: '["Test Government Agency"]' piv_cac_enabled: 'true' + piv_cac_verify_token_secret: 'ee7f20f44cdc2ba0c6830f70470d1d1d059e1279cdb58134db92b35947b1528ef5525ece5910cf4f2321ab989a618feea12ef95711dbc62b9601e8520a34ee12' piv_cac_service_url: 'https://localhost:8443/' piv_cac_verify_token_url: 'https://localhost:8443/' pkcs11_lib: '/opt/cloudhsm/lib/libcloudhsm_pkcs11.so' @@ -364,10 +366,12 @@ test: otp_delivery_blocklist_findtime: '1' otp_delivery_blocklist_maxretry: '2' otp_valid_for: '10' - password_pepper: 'f22d4b2cafac9066fe2f4416f5b7a32c6942d82f7e00740c7594a095fa8de8db17c05314be7b18a5d6dd5683e73eadf6cc95aa633e5ad9a701edb95192a6a105' + password_pepper: 'f22d4b2cafac9066fe2f4416f5b7a32c' password_strength_enabled: 'false' + piv_cac_agencies: '["Test Government Agency"]' piv_cac_enabled: 'true' piv_cac_service_url: 'https://localhost:8443/' + piv_cac_verify_token_secret: '3ac13bfa23e22adae321194c083e783faf89469f6f85dcc0802b27475c94b5c3891b5657bd87d0c1ad65de459166440512f2311018db90d57b15d8ab6660748f' piv_cac_verify_token_url: 'https://localhost:8443/' pkcs11_lib: '/opt/cloudhsm/lib/libcloudhsm_pkcs11.so' proofer_mock_fallback: 'true' diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index 23fdba5e82b..6b4300eea71 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -116,6 +116,7 @@ ignore_unused: - 'headings.piv_cac_setup.*' - 'titles.sign_up.*' - 'users.delete.bullet_2_loa*' + - 'idv.messages.jurisdiction.{no_id,unsupported_jurisdiction}_failure' # - 'simple_form.{yes,no}' # - 'simple_form.{placeholders,hints,labels}.*' # - 'simple_form.{error_notification,required}.:' diff --git a/config/initializers/devise_mailer_helpers.rb b/config/initializers/devise_mailer_helpers.rb deleted file mode 100644 index aa1cb85a475..00000000000 --- a/config/initializers/devise_mailer_helpers.rb +++ /dev/null @@ -1,27 +0,0 @@ -# This overrides the Devise mailer headers so that the recipient -# is determined based on whether or not an unconfirmed_email is present, -# as opposed to passing in the email as an argument to the job, which -# might expose it in some logs. -module Devise - module Mailers - module Helpers - def headers_for(action, opts) - headers = { - subject: subject_for(action), - to: recipient, - template_path: template_paths, - template_name: action, - }.merge(opts) - - @email = headers[:to] - headers - end - - private - - def recipient - resource.unconfirmed_email.presence || resource.email - end - end - end -end diff --git a/config/initializers/new_relic.rb b/config/initializers/new_relic.rb new file mode 100644 index 00000000000..cdbf7fa15c5 --- /dev/null +++ b/config/initializers/new_relic.rb @@ -0,0 +1,16 @@ +# monkeypatch to prevent new relic from truncating backtraces. +# stack length is not currently configurable in new relic. +# The MAX_BACKTRACE_FRAMES constant is commented out for reference + +module NewRelic + module Agent + class ErrorCollector + # Maximum number of frames in backtraces. May be made configurable + # in the future. + # MAX_BACKTRACE_FRAMES = 50 + def truncate_trace(trace, _keep_frames = nil) + trace + end + end + end +end diff --git a/config/initializers/new_relic_tracers.rb b/config/initializers/new_relic_tracers.rb index 2306435810f..7be3e33c66c 100644 --- a/config/initializers/new_relic_tracers.rb +++ b/config/initializers/new_relic_tracers.rb @@ -59,3 +59,15 @@ add_method_tracer :rs256_algorithm, "Custom/#{name}/rs256_algorithm" add_method_tracer :sign, "Custom/#{name}/sign" end + +Encryption::KmsClient.class_eval do + include ::NewRelic::Agent::MethodTracer + add_method_tracer :decrypt, "Custom/#{name}/decrypt" + add_method_tracer :encrypt, "Custom/#{name}/encrypt" +end + +TwilioService.class_eval do + include ::NewRelic::Agent::MethodTracer + add_method_tracer :place_call, "Custom/#{name}/place_call" + add_method_tracer :send_sms, "Custom/#{name}/send_sms" +end diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index a2a39ec9380..c5d8931e4d8 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -111,7 +111,7 @@ def headers # increments the count), so requests below the limit are not blocked until # they hit the limit. At that point, `filter` will return true and block. user = req.params.fetch('user', {}) - email = user['email'].to_s + email = user['email'].to_s.downcase.strip email_fingerprint = Pii::Fingerprinter.fingerprint(email) if email.present? email_and_ip = "#{email_fingerprint}-#{req.remote_ip}" maxretry = Figaro.env.logins_per_email_and_ip_limit.to_i diff --git a/config/initializers/secure_headers.rb b/config/initializers/secure_headers.rb index acb431f7d09..76c04b56f57 100644 --- a/config/initializers/secure_headers.rb +++ b/config/initializers/secure_headers.rb @@ -19,7 +19,7 @@ '*.google-analytics.com', ], font_src: ["'self'", 'data:'], - img_src: ["'self'", 'data:'], + img_src: ["'self'", 'data:', 'login.gov'], media_src: ["'self'"], object_src: ["'none'"], script_src: [ diff --git a/config/locales/devise/en.yml b/config/locales/devise/en.yml index 911015e3a45..85e3df9b9c8 100644 --- a/config/locales/devise/en.yml +++ b/config/locales/devise/en.yml @@ -103,13 +103,16 @@ en: request a new one-time security code. invalid_personal_key: That personal key is invalid. invalid_piv_cac: That PIV/CAC is incorrect. - max_generic_login_attempts_reached: Your account is temporarily locked. - max_otp_login_attempts_reached: Your account is temporarily locked because you - have entered the one-time security code incorrectly too many times. - max_otp_requests_reached: Your account is temporarily locked because you have - requested a security code too many times. - max_personal_key_login_attempts_reached: Your account is temporarily locked - because you have entered the personal key incorrectly too many times. + max_generic_login_attempts_reached: For your security, your account is temporarily + locked. + max_otp_login_attempts_reached: For your security, your account is temporarily + locked because you have entered the one-time security code incorrectly too + many times. + max_otp_requests_reached: For your security, your account is temporarily locked + because you have requested a security code too many times. + max_personal_key_login_attempts_reached: For your security, your account is + temporarily locked because you have entered the personal key incorrectly too + many times. otp_delivery_preference: instruction: You can change this selection the next time you log in. If you entered a landline, please select "Phone call" below. @@ -118,7 +121,6 @@ en: sms: Text message (SMS) title: How should we send you a code? voice: Phone call - otp_phone_label: Phone number otp_phone_label_info: Mobile phone or landline otp_phone_label_info_mobile_only: Mobile phone otp_setup_html: "Every time you log in, we will send you a @@ -131,13 +133,21 @@ en: personal_key_header_text: Enter your personal key personal_key_prompt: You can use this personal key once. If you still need a code after signing in, go to your account settings page to get a new one. + phone_sms_label: Mobile phone number + phone_sms_info_html: We'll text a security code each time you sign in. + phone_voice_label: Phone number + phone_voice_info_html: We'll call you with a security code each time + you sign in. piv_cac_fallback: link: Use your PIV/CAC instead text_html: Do you have your PIV/CAC? %{link} piv_cac_header_text: Present your PIV/CAC please_confirm: Your phone number has been set. Confirm it by entering the security code below. - please_try_again_html: Please try again in %{time_remaining}. + please_try_again_html: Please try again in %{time_remaining}. + read_about_two_factor_authentication: + link: read about two-factor authentication + text_html: You can %{link} and why we use it at our Help page. totp_fallback: sms_link_text: get a code via text message text_html: If you can’t use your authenticator app right now you can %{sms_link} @@ -145,6 +155,20 @@ en: voice_link_text: Receive a code via phone call totp_header_text: Enter your authentication app code totp_info: Use any authenticator app to scan the QR code below. + two_factor_choice: Secure your account + two_factor_choice_options: + voice: Phone call + voice_info: Get your security code via phone call. + sms: Text message / SMS + sms_info: Get your security code via text message / SMS. + auth_app: Authentication application + auth_app_info: Set up an authentication application to get your security code + without providing a phone number. + piv_cac: Government employees + piv_cac_info: Use your PIV/CAC card to secure your account. + two_factor_choice_intro: login.gov makes sure you can access your account by + adding a second layer of security. + two_factor_choice_cancel: "‹ Choose another option" two_factor_setup: Add a phone number user: new_otp_sent: We sent you a new one-time security code. diff --git a/config/locales/devise/es.yml b/config/locales/devise/es.yml index d282144cc3a..008ce5c4413 100644 --- a/config/locales/devise/es.yml +++ b/config/locales/devise/es.yml @@ -108,21 +108,22 @@ es: de nuevo o solicitar un nuevo código de seguridad de sólo un uso. invalid_personal_key: Esa clave personal no es válida. invalid_piv_cac: NOT TRANSLATED YET - max_generic_login_attempts_reached: Su cuenta está bloqueada temporalmente. - max_otp_login_attempts_reached: Su cuenta ha sido bloqueada temporalmente porque - ha ingresado incorrectamente el código de seguridad de sólo un uso demasiadas - veces. - max_otp_requests_reached: Su cuenta ha sido bloqueada temporalmente porque ha - solicitado un código de seguridad demasiadas veces más de lo permitido. - max_personal_key_login_attempts_reached: Su cuenta ha sido bloqueada temporalmente - porque ha ingresado incorrectamente la clave personal demasiadas veces. + max_generic_login_attempts_reached: Para su seguridad, su cuenta está bloqueada + temporalmente. + max_otp_login_attempts_reached: Para su seguridad, su cuenta ha sido bloqueada + temporalmente porque ha ingresado incorrectamente el código de seguridad de + sólo un uso demasiadas veces. + max_otp_requests_reached: Para su seguridad, su cuenta ha sido bloqueada temporalmente + porque ha solicitado un código de seguridad demasiadas veces más de lo permitido. + max_personal_key_login_attempts_reached: Para su seguridad, su cuenta ha sido + bloqueada temporalmente porque ha ingresado incorrectamente la clave personal + demasiadas veces. otp_delivery_preference: instruction: Puede cambiar esta selección la próxima vez que inicie sesión. phone_unsupported: NOT TRANSLATED YET sms: Mensaje de texto (SMS, sigla en inglés) title: "¿Cómo deberíamos enviarle un código?" voice: Llamada telefónica - otp_phone_label: Número de teléfono otp_phone_label_info: El móvil o teléfono fijo. Si tiene un teléfono fijo, seleccione "Llamada telefónica" en la siguiente pregunta. otp_phone_label_info_mobile_only: NOT TRANSLATED YET @@ -137,13 +138,22 @@ es: personal_key_prompt: Puede usar esta clave personal una vez. Si todavía necesita un código después de iniciar una sesión, vaya a la página de configuración de su cuenta para obtener una clave nueva. + phone_sms_label: Número de teléfono móvil + phone_sms_info_html: Le enviaremos un mensaje de texto con un código de seguridad + cada vez que inicie sesión. + phone_voice_label: Número de teléfono + phone_voice_info_html: Te llamaremos con un código de seguridad cada + vez que inicies sesión. piv_cac_fallback: link: Use su PIV/CAC en su lugar text_html: "¿Tiene usted PIV/CAC? %{link}" piv_cac_header_text: NOT TRANSLATED YET please_confirm: Su número de teléfono ha sido establecido. Confírmelo ingresando el código de seguridad a continuación. - please_try_again_html: Inténtelo de nuevo en %{time_remaining}. + please_try_again_html: Inténtelo de nuevo en %{time_remaining}. + read_about_two_factor_authentication: + link: leer acerca de la autenticación de dos factores + text_html: Puede %{link} y por qué la utilizamos en nuestra página de Ayuda. totp_fallback: sms_link_text: Recibir un código por mensaje de texto text_html: Si no puede usar su app de autenticación ahora, puede %{sms_link} @@ -152,6 +162,20 @@ es: totp_header_text: Ingrese su código de la app de autenticación totp_info: Use cualquier app de autenticación para escanear el código QR que aparece a continuación. + two_factor_choice: Asegure su cuenta + two_factor_choice_options: + voice: Llamada telefónica + voice_info: Obtenga su código de seguridad a través de una llamada telefónica. + sms: Mensaje de texto / SMS + sms_info: Obtenga su código de seguridad a través de mensajes de texto / SMS. + auth_app: Aplicación de autenticación + auth_app_info: Configure una aplicación de autenticación para obtener su código + de seguridad sin proporcionar un número de teléfono. + piv_cac: Empleados del Gobierno + piv_cac_info: Use su tarjeta PIV / CAC para asegurar su cuenta. + two_factor_choice_intro: login.gov se asegura de que pueda acceder a su cuenta + agregando una segunda capa de seguridad. + two_factor_choice_cancel: "‹ Elige otra opción" two_factor_setup: Añada un número de teléfono user: new_otp_sent: Le enviamos un nuevo código de sólo un uso diff --git a/config/locales/devise/fr.yml b/config/locales/devise/fr.yml index 36afb68776c..fefc7cd17b1 100644 --- a/config/locales/devise/fr.yml +++ b/config/locales/devise/fr.yml @@ -114,15 +114,16 @@ fr: de nouveau ou demander un nouveau code de sécurité à utilisation unique. invalid_personal_key: Cette clé personnelle est non valide. invalid_piv_cac: NOT TRANSLATED YET - max_generic_login_attempts_reached: Votre compte est temporairement verrouillé. - max_otp_login_attempts_reached: Votre compte est temporairement verrouillé, - car vous avez entré le code de sécurité à utilisation unique de façon erronée - à de trop nombreuses reprises. - max_otp_requests_reached: Votre compte est temporairement verrouillé car vous - avez demandé un code de sécurité à trop de reprises. - max_personal_key_login_attempts_reached: Votre compte est temporairement verrouillé, - car vous avez entré le code de sécurité à utilisation unique de façon erronée - à de trop nombreuses reprises. + max_generic_login_attempts_reached: Pour votre sécurité, votre compte est temporairement + verrouillé. + max_otp_login_attempts_reached: Pour votre sécurité, votre compte est temporairement + verrouillé, car vous avez entré le code de sécurité à utilisation unique de + façon erronée à de trop nombreuses reprises. + max_otp_requests_reached: Pour votre sécurité, votre compte est temporairement + verrouillé car vous avez demandé un code de sécurité à trop de reprises. + max_personal_key_login_attempts_reached: Pour votre sécurité, votre compte est + temporairement verrouillé, car vous avez entré le code de sécurité à utilisation + unique de façon erronée à de trop nombreuses reprises. otp_delivery_preference: instruction: Vous pouvez changer cette sélection la prochaine fois que vous vous connectez. @@ -130,7 +131,6 @@ fr: sms: Message texte (SMS) title: Comment devrions-nous vous envoyer un code? voice: Appel téléphonique - otp_phone_label: Numéro de téléphone otp_phone_label_info: Cellulaire ou ligne fixe. Si vous entrez une ligne fixe, veuillez choisir l'option "Appel téléphonique" ci-dessous. otp_phone_label_info_mobile_only: NOT TRANSLATED YET @@ -145,13 +145,23 @@ fr: personal_key_prompt: Vous pouvez utiliser cette clé personnelle une fois seulement. Si vous avez toujours besoin d'un code après votre connexion, allez à la page des réglages de votre compte pour en obtenir un nouveau. + phone_sms_label: Numéro de téléphone portable + phone_sms_info_html: Nous vous enverrons un code de sécurité chaque + fois que vous vous connectez. + phone_voice_label: Numéro de téléphone + phone_voice_info_html: Nous vous appellerons avec un code de sécurité chaque + fois que vous vous connectez. piv_cac_fallback: link: Utilisez plutôt votre PIV/CAC text_html: Avez-vous votre PIV/CAC? %{link} piv_cac_header_text: NOT TRANSLATED YET please_confirm: Votre numéro de téléphone a été entré. Confirmez-le en entrant le code de sécurité ci-dessous. - please_try_again_html: Veuillez essayer de nouveau dans %{time_remaining}. + please_try_again_html: Veuillez essayer de nouveau dans %{time_remaining}. + read_about_two_factor_authentication: + link: lire sur l'authentification à deux facteurs + text_html: Vous pouvez %{link} et pourquoi nous l'utilisons sur notre page + d'aide. totp_fallback: sms_link_text: Obtenir un code via message texte text_html: Si vous ne pouvez utiliser votre application d'authentification @@ -160,6 +170,20 @@ fr: totp_header_text: Entrez votre code d'application d'authentification totp_info: Utilisez n'importe quelle application d'authentification pour balayer le code QR ci-dessous. + two_factor_choice: Sécurise ton compte + two_factor_choice_options: + voice: Appel téléphonique + voice_info: Obtenez votre code de sécurité par appel téléphonique. + sms: SMS + sms_info: Obtenez votre code de sécurité par SMS + auth_app: Application d'authentification + auth_app_info: Configurez une application d'authentification pour obtenir + votre code de sécurité sans fournir de numéro de téléphone. + piv_cac: Employés du gouvernement + piv_cac_info: Utilisez votre carte PIV / CAC pour sécuriser votre compte. + two_factor_choice_intro: login.gov s'assure que vous pouvez accéder à votre + compte en ajoutant une deuxième couche de sécurité. + two_factor_choice_cancel: "‹ Choisissez une autre option" two_factor_setup: Ajoutez un numéro de téléphone user: new_otp_sent: Nous vous avons envoyé un code de sécurité à utilisation unique. diff --git a/config/locales/event_types/en.yml b/config/locales/event_types/en.yml index 471a56a9a39..f87f2ea5404 100644 --- a/config/locales/event_types/en.yml +++ b/config/locales/event_types/en.yml @@ -9,6 +9,7 @@ en: authenticator_enabled: Authenticator app enabled eastern_timestamp: "%{timestamp} (Eastern)" email_changed: Email address changed + new_personal_key: Personal key changed password_changed: Password changed phone_changed: Phone number changed phone_confirmed: Phone confirmed diff --git a/config/locales/event_types/es.yml b/config/locales/event_types/es.yml index 4048ec62ed9..735cf988a5f 100644 --- a/config/locales/event_types/es.yml +++ b/config/locales/event_types/es.yml @@ -9,6 +9,7 @@ es: authenticator_enabled: App de autenticación permitido eastern_timestamp: "%{timestamp} (hora del Este)" email_changed: Email cambiado + new_personal_key: Clave personal cambiado password_changed: Contraseña cambiada phone_changed: Número de teléfono cambiado phone_confirmed: Teléfono confirmado diff --git a/config/locales/event_types/fr.yml b/config/locales/event_types/fr.yml index 301c2d58503..d37b9f45093 100644 --- a/config/locales/event_types/fr.yml +++ b/config/locales/event_types/fr.yml @@ -9,6 +9,7 @@ fr: authenticator_enabled: Application d'authentification activée eastern_timestamp: "%{timestamp} (Eastern)" email_changed: Adresse courriel modifiée + new_personal_key: Clé personnelle modifié password_changed: Mot de passe modifié phone_changed: Numéro de téléphone modifié phone_confirmed: Numéro de téléphone confirmé diff --git a/config/locales/forms/en.yml b/config/locales/forms/en.yml index c633a271cef..b6b79abf966 100644 --- a/config/locales/forms/en.yml +++ b/config/locales/forms/en.yml @@ -1,6 +1,8 @@ --- en: forms: + account_recovery_setup: + legend: Select a secondary authentication option buttons: back: Back continue: Continue @@ -72,6 +74,8 @@ en: code: One-time security code personal_key: Personal key try_again: Use another phone number + two_factor_choice: + legend: Select an option to secure your account verify_profile: instructions: Enter the ten-character code in the letter we sent you. name: Confirmation code diff --git a/config/locales/forms/es.yml b/config/locales/forms/es.yml index 7eb41249384..2c4e0c85f20 100644 --- a/config/locales/forms/es.yml +++ b/config/locales/forms/es.yml @@ -1,6 +1,8 @@ --- es: forms: + account_recovery_setup: + legend: NOT TRANSLATED YET buttons: back: Atrás continue: Continuar @@ -72,6 +74,8 @@ es: code: Código de seguridad de sólo un uso personal_key: Clave personal try_again: Use otro número de teléfono. + two_factor_choice: + legend: Seleccione una opción para proteger su cuenta verify_profile: instructions: Ingrese el código de 10 caracteres que le enviamos en la carta. name: Código de confirmación diff --git a/config/locales/forms/fr.yml b/config/locales/forms/fr.yml index 16930194f9b..597437d0a31 100644 --- a/config/locales/forms/fr.yml +++ b/config/locales/forms/fr.yml @@ -1,6 +1,8 @@ --- fr: forms: + account_recovery_setup: + legend: NOT TRANSLATED YET buttons: back: Retour continue: Continuer @@ -75,6 +77,8 @@ fr: code: Code de sécurité personal_key: Clé personnelle try_again: Utilisez un autre numéro de téléphone + two_factor_choice: + legend: Sélectionnez une option pour sécuriser votre compte verify_profile: instructions: Entrez le code à dix caractères qui se trouve dans la lettre que nous vous avons envoyée. diff --git a/config/locales/headings/en.yml b/config/locales/headings/en.yml index 7ff6bfc5a76..12bde6b447a 100644 --- a/config/locales/headings/en.yml +++ b/config/locales/headings/en.yml @@ -9,6 +9,8 @@ en: reactivate: Reactivate your account two_factor: Two-factor authentication verified_account: Verified Account + account_recovery_setup: + piv_cac_linked: Your PIV/CAC card is linked to your account confirmations: new: Send another confirmation email create_account_with_sp: @@ -19,6 +21,7 @@ en: email: Change your email password: Change your password phone: Enter your new phone number + lock_failure: Here's what you can do passwords: change: Change your password confirm: Confirm your current password to continue diff --git a/config/locales/headings/es.yml b/config/locales/headings/es.yml index ac932c544c0..b64ac5c5cb6 100644 --- a/config/locales/headings/es.yml +++ b/config/locales/headings/es.yml @@ -9,6 +9,8 @@ es: reactivate: Reactive su cuenta two_factor: Autenticación de dos factores verified_account: Cuenta verificada + account_recovery_setup: + piv_cac_linked: NOT TRANSLATED YET confirmations: new: Enviar otro email de confirmación create_account_with_sp: @@ -19,6 +21,7 @@ es: email: Cambie su email password: Cambie su contraseña phone: Ingrese su nuevo número de teléfono + lock_failure: Esto es lo que puedes hacer passwords: change: Cambie su contraseña confirm: Confirme la contraseña actual para continuar diff --git a/config/locales/headings/fr.yml b/config/locales/headings/fr.yml index 44e1a928dd7..fea1dcc54ae 100644 --- a/config/locales/headings/fr.yml +++ b/config/locales/headings/fr.yml @@ -9,6 +9,8 @@ fr: reactivate: Réactivez votre compte two_factor: Authentification à deux facteurs verified_account: Compte vérifié + account_recovery_setup: + piv_cac_linked: NOT TRANSLATED YET confirmations: new: Envoyer un autre courriel de confirmation create_account_with_sp: @@ -19,6 +21,7 @@ fr: email: Changez votre courriel password: Changez votre mot de passe phone: Entrez votre nouveau numéro de téléphone + lock_failure: Voici ce que vous pouvez faire passwords: change: Changez votre mot de passe confirm: Confirmez votre mot de passe actuel pour continuer diff --git a/config/locales/idv/en.yml b/config/locales/idv/en.yml index 162fd34617c..95e23031361 100644 --- a/config/locales/idv/en.yml +++ b/config/locales/idv/en.yml @@ -81,15 +81,18 @@ en: help_center_html: Visit our Help Center to learn more about verifying your account. jurisdiction: - why: To verify your identity, you'll need information from your state-issued - ID. - where: Where was your driver's license, driver's permit, or state ID issued? no_id: I don't have a state-issued ID - unsupported_jurisdiction: We're working hard to add more states and hope to - support %{state} soon. - sp_support: Visit %{link} for more information. + no_id_failure: We're working hard to add more ways to verify your identity. profile: To access your account in the future, you can %{link}. profile_link: view your account here + sp_support: Visit %{link} for more information. + try_again: Make a mistake? You can %{link}. + try_again_link: try again + unsupported_jurisdiction_failure: We're working hard to add more states and + hope to support %{state} soon. + why: To verify your identity, you'll need information from your state-issued + ID. + where: Where was your driver's license, driver's permit, or state ID issued? loading: Verifying your identity mail_sent: Your letter is on its way otp_delivery_method: diff --git a/config/locales/idv/es.yml b/config/locales/idv/es.yml index d557def97bc..22aaa709e4a 100644 --- a/config/locales/idv/es.yml +++ b/config/locales/idv/es.yml @@ -79,16 +79,20 @@ es: help_center_html: Visite nuestro Centro de Ayuda para obtener más información sobre la verificación de su cuenta. jurisdiction: - why: Para verificar su identidad, necesitará información de su identificación - emitida por el estado. - where: "¿Dónde se emitió su licencia de conducir, permiso de conducir o identificación - del estado?" no_id: No tengo una identificación emitida por el estado + no_id_failure: Estamos trabajando arduamente para agregar más formas de verificar + su identidad. profile: Para acceder a su cuenta en el futuro, puede %{link}. profile_link: mira tu cuenta aquí sp_support: Visita %{link} para obtener más información. - unsupported_jurisdiction: Estamos trabajando duro para agregar más estados - y esperamos apoyar a %{state} pronto. + try_again: "¿Cometer un error? Puedes %{link}." + try_again_link: intentarlo de nuevo + unsupported_jurisdiction_failure: Estamos trabajando duro para agregar más + estados y esperamos apoyar a %{state} pronto. + why: Para verificar su identidad, necesitará información de su identificación + emitida por el estado. + where: "¿Dónde se emitió su licencia de conducir, permiso de conducir o identificación + del estado?" loading: NOT TRANSLATED YET mail_sent: Su carta está en camino otp_delivery_method: diff --git a/config/locales/idv/fr.yml b/config/locales/idv/fr.yml index 2a61050ccd0..7f31c4748cd 100644 --- a/config/locales/idv/fr.yml +++ b/config/locales/idv/fr.yml @@ -85,16 +85,20 @@ fr: help_center_html: Visitez notre Centre d'aide pour en apprendre davantage sur la façon dont nous vérifions votre compte. jurisdiction: - why: Pour vérifier votre identité, vous aurez besoin d'informations provenant - de votre carte d'identité officielle. - where: Où a été délivré votre permis de conduire, votre permis de conduire - ou votre carte d'identité? no_id: Je n'ai pas de carte d'identité officielle + no_id_failure: Nous travaillons dur pour ajouter plus de moyens de vérifier + votre identité. profile: Pour accéder à votre compte dans le futur, vous pouvez %{link}. profile_link: voir votre compte ici sp_support: Visitez %{link} pour plus d'informations. - unsupported_jurisdiction: Nous travaillons dur pour ajouter plus d'états et - espérons pouvoir bientôt prendre en charge %{state}. + try_again: Faire une erreur? Vous pouvez %{link}. + try_again_link: réessayer + unsupported_jurisdiction_failure: Nous travaillons dur pour ajouter plus d'états + et espérons pouvoir bientôt prendre en charge %{state}. + why: Pour vérifier votre identité, vous aurez besoin d'informations provenant + de votre carte d'identité officielle. + where: Où a été délivré votre permis de conduire, votre permis de conduire + ou votre carte d'identité? loading: NOT TRANSLATED YET mail_sent: Votre lettre est en route otp_delivery_method: diff --git a/config/locales/instructions/en.yml b/config/locales/instructions/en.yml index 6619b10f1ce..3b99851d550 100644 --- a/config/locales/instructions/en.yml +++ b/config/locales/instructions/en.yml @@ -13,6 +13,8 @@ en: identity again. heading: Don't have your personal key? with_key: Do you have your personal key? + account_recovery_setup: + piv_cac_next_step: Next we need to give you a way to recover your account. forgot_password: close_window: You can close this browser window once you have reset your password. go_back_to_mobile_app: To continue, please go back to the %{friendly_name} app diff --git a/config/locales/instructions/es.yml b/config/locales/instructions/es.yml index 2dd82925b89..5d6010562f2 100644 --- a/config/locales/instructions/es.yml +++ b/config/locales/instructions/es.yml @@ -13,6 +13,8 @@ es: copy: Si no tiene su clave personal, verifique su identidad nuevamente. heading: NOT TRANSLATED YET with_key: "¿Tiene su clave personal?" + account_recovery_setup: + piv_cac_next_step: NOT TRANSLATED YET forgot_password: close_window: Puede cerrar esta ventana del navegador después que haya restablecido su contraseña. diff --git a/config/locales/instructions/fr.yml b/config/locales/instructions/fr.yml index d7e6ee49cc0..96c2c7ad5eb 100644 --- a/config/locales/instructions/fr.yml +++ b/config/locales/instructions/fr.yml @@ -15,6 +15,8 @@ fr: identité de nouveau. heading: Vous n'avez pas votre clé personnelle? with_key: Vous n'avez pas votre clé personnelle? + account_recovery_setup: + piv_cac_next_step: NOT TRANSLATED YET forgot_password: close_window: Vous pourrez fermer cette fenêtre de navigateur lorsque vous aurez réinitialisé votre mot de passe. diff --git a/config/locales/service_providers/en.yml b/config/locales/service_providers/en.yml index 9338df8ac1e..9b9b3258266 100644 --- a/config/locales/service_providers/en.yml +++ b/config/locales/service_providers/en.yml @@ -8,6 +8,8 @@ en: account below. body_html: Your old GOES userID and password won't work. Please %{link} create_account_link: create a login.gov account. + create_account_page: + body: '' usa_jobs: header: First time here from USAJOBS? account_page: @@ -16,6 +18,8 @@ en: body_html: Your old USAJOBS username and password won’t work. Please %{link} using the same email address you use for USAJOBS. create_account_link: create a login.gov account + create_account_page: + body: '' learn_more: Learn more. sam: header: First time here from SAM? @@ -25,3 +29,6 @@ en: body_html: Your old SAM username and password won’t work. Please %{link} using the same email address you use for SAM. create_account_link: create a login.gov account + create_account_page: + body: Please create a login.gov account using the same email address you use + for SAM. diff --git a/config/locales/service_providers/es.yml b/config/locales/service_providers/es.yml index 19075d381e6..0aef3ba185a 100644 --- a/config/locales/service_providers/es.yml +++ b/config/locales/service_providers/es.yml @@ -9,6 +9,8 @@ es: body_html: Su antiguo ID de usuario y contraseña de GOES no funcionarán. Favor de %{link} create_account_link: crear un nueva cuenta de login.gov. + create_account_page: + body: '' usa_jobs: header: "¿Ha venido de USAJOBS?" account_page: @@ -18,13 +20,18 @@ es: body_html: Si tiene un perfil de USAJOBS existente, favor de usar la dirección de correo electrónico primaria o secundaria que usó para USAJOBS para %{link}. create_account_link: crear su nueva cuenta de login.gov + create_account_page: + body: '' learn_more: Obtenga más información. sam: header: "¿Ha venido de SAM?" account_page: - body: Si tiene un perfil de SAM existente, favor de usar la dirección de correo - electrónico primaria o secundaria que usó para SAM para crear su nueva cuenta - de login.gov. + body: Su antiguo nombre de usuario y contraseña SAM no funcionará. Por favor + crea un login.gov cuenta usando la misma dirección de correo electrónico + que utiliza para SAM. body_html: Si tiene un perfil de SAM existente, favor de usar la dirección de correo electrónico primaria o secundaria que usó para SAM para %{link}. create_account_link: crear su nueva cuenta de login.gov + create_account_page: + body: Por favor crea un login.gov cuenta usando la misma dirección de correo + electrónico que utiliza para SAM. diff --git a/config/locales/service_providers/fr.yml b/config/locales/service_providers/fr.yml index 43377646637..b17e132600c 100644 --- a/config/locales/service_providers/fr.yml +++ b/config/locales/service_providers/fr.yml @@ -9,6 +9,8 @@ fr: body_html: Votre ancien nom d'utilisateur et mot de passe GOES ne marchera pas. Veuillez %{link} create_account_link: créer un nouveau compte login.gov. + create_account_page: + body: '' usa_jobs: header: Êtes-vous venu(e) de USAJOBS? account_page: @@ -18,13 +20,18 @@ fr: body_html: Si vous avez déjà un profil USAJOBS, veuillez utiliser l'adresse e-mail principale ou secondaire que vous avez utilisée pour USAJOBS pour %{link}. create_account_link: créer votre nouveau compte login.gov + create_account_page: + body: '' learn_more: En savoir plus. sam: header: Êtes-vous venu(e) de SAM? account_page: - body: Si vous avez déjà un profil SAM, veuillez utiliser l'adresse e-mail - principale ou secondaire que vous avez utilisée pour SAM pour créer votre - nouveau compte login.gov. + body: Votre ancien nom d'utilisateur et mot de passe SAM ne marchera pas. + Veuillez créer un nouveau compte login.gov avec la même adresse e-mail que + vous avez utilisée pour SAM. body_html: Si vous avez déjà un profil SAM, veuillez utiliser l'adresse e-mail principale ou secondaire que vous avez utilisée pour SAM pour %{link}. create_account_link: créer votre nouveau compte login.gov + create_account_page: + body: Veuillez créer un compte login.gov avec la même adresse e-mail que vous + avez utilisée pour SAM. diff --git a/config/locales/titles/en.yml b/config/locales/titles/en.yml index cca277124b0..4e4d8ac16ab 100644 --- a/config/locales/titles/en.yml +++ b/config/locales/titles/en.yml @@ -2,7 +2,8 @@ en: titles: account: Account - account_locked: Account locked + account_locked: Account temporarily locked + account_recovery_setup: Account Recovery Setup confirmations: new: Resend confirmation instructions for your account show: Choose a password @@ -16,6 +17,9 @@ en: confirm: Confirm the password for your account forgot: Reset the password for your account personal_key: Just in case + phone_setup: + voice: Send your security code via phone call + sms: Send your security code via text message piv_cac_setup: new: Use your PIV/CAC card to secure your account certificate: diff --git a/config/locales/titles/es.yml b/config/locales/titles/es.yml index 619a8351add..2c4bc569bd4 100644 --- a/config/locales/titles/es.yml +++ b/config/locales/titles/es.yml @@ -2,7 +2,8 @@ es: titles: account: Cuenta - account_locked: Cuenta bloqueada + account_locked: Cuenta bloqueada temporalmente + account_recovery_setup: NOT TRANSLATED YET confirmations: new: Reenviar instrucciones de confirmación de su cuenta show: Elija una contraseña @@ -16,6 +17,9 @@ es: confirm: Confirme la contraseña de su cuenta forgot: Restablezca la contraseña de su cuenta personal_key: Por si acaso + phone_setup: + voice: Envíe su código de seguridad a través de una llamada telefónica + sms: Envíe su código de seguridad a través de un mensaje de texto piv_cac_setup: new: Use su tarjeta PIV/CAC para asegurar su cuenta certificate: diff --git a/config/locales/titles/fr.yml b/config/locales/titles/fr.yml index bc4e3dfaaeb..cc6b8a10858 100644 --- a/config/locales/titles/fr.yml +++ b/config/locales/titles/fr.yml @@ -2,7 +2,8 @@ fr: titles: account: Compte - account_locked: Compte verrouillé + account_locked: Compte temporairement verrouillé + account_recovery_setup: NOT TRANSLATED YET confirmations: new: Envoyer les instructions de confirmation pour votre compte show: Choisissez un mot de passe @@ -16,6 +17,9 @@ fr: confirm: Confirmez le mot de passe de votre compte forgot: Réinitialisez le mot de passe de votre compte personal_key: Juste au cas + phone_setup: + voice: Envoyez votre code de sécurité par appel téléphonique + sms: Envoyer votre code de sécurité par SMS piv_cac_setup: new: Utilisez votre carte PIV/CAC pour sécuriser votre compte certificate: diff --git a/config/routes.rb b/config/routes.rb index dc8e7c96299..5e2c3f26cdc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -99,10 +99,12 @@ as: :create_verify_personal_key get '/account/verify_phone' => 'users/verify_profile_phone#index', as: :verify_profile_phone post '/account/verify_phone' => 'users/verify_profile_phone#create' + get '/account_recovery_setup' => 'account_recovery_setup#index' if FeatureManagement.piv_cac_enabled? get '/piv_cac' => 'users/piv_cac_authentication_setup#new', as: :setup_piv_cac delete '/piv_cac' => 'users/piv_cac_authentication_setup#delete', as: :disable_piv_cac + get '/present_piv_cac' => 'users/piv_cac_authentication_setup#redirect_to_piv_cac_service', as: :redirect_to_piv_cac_service end delete '/authenticator_setup' => 'users/totp_setup#disable', as: :disable_totp @@ -123,8 +125,10 @@ post '/manage/personal_key' => 'users/personal_keys#update' get '/otp/send' => 'users/two_factor_authentication#send_code' - get '/phone_setup' => 'users/two_factor_authentication_setup#index' - patch '/phone_setup' => 'users/two_factor_authentication_setup#set' + get '/two_factor_options' => 'users/two_factor_authentication_setup#index' + patch '/two_factor_options' => 'users/two_factor_authentication_setup#create' + get '/phone_setup' => 'users/phone_setup#index' + patch '/phone_setup' => 'users/phone_setup#create' get '/users/two_factor_authentication' => 'users/two_factor_authentication#show', as: :user_two_factor_authentication # route name is used by two_factor_authentication gem diff --git a/config/service_providers.yml b/config/service_providers.yml index 2c48c8350fe..4d414089b72 100644 --- a/config/service_providers.yml +++ b/config/service_providers.yml @@ -671,4 +671,32 @@ production: return_to_sp_url: 'https://sam.gov/portal/SAM' redirect_uris: - 'https://sam.gov/portal/SAM' + - 'https://www.sam.gov/portal/SAM' + restrict_to_deploy_env: 'prod' + + # SAM – System for Award Management / testing prod from UAT + 'urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:sam_uat': + agency_id: 9 + friendly_name: 'SAM - UAT' + agency: 'GSA' + logo: 'sam.png' + cert: 'sam_prod' + return_to_sp_url: 'https://uat.sam.gov/portal/SAM' + redirect_uris: + - 'https://uat.sam.gov/portal/SAM' + restrict_to_deploy_env: 'prod' + + # DOE - Fossil Energy - Import/Export Authorization Portal for Natural Gas + 'urn:gov:gsa:openidconnect.profiles:sp:sso:doe:fergas': + agency_id: 10 + friendly_name: 'Import/Export Authorization Portal for Natural Gas' + agency: 'DOE' + logo: 'doe.png' + cert: 'doe_prod' + return_to_sp_url: 'https://fossil.energy.gov/fergas-fe/#/login' + redirect_uris: + - 'https://fossil.energy.gov/fergas-fe/#/certify_redirect' + - 'https://fossil.energy.gov/fergas-fe/#/login' + - 'https://fossil.energy.gov/fergas-fe/#/login_redirect' + - 'https://fossil.energy.gov/fergas-fe/#/notice/15' restrict_to_deploy_env: 'prod' diff --git a/db/migrate/20180607144007_create_remote_settings.rb b/db/migrate/20180607144007_create_remote_settings.rb new file mode 100644 index 00000000000..06ab5f57446 --- /dev/null +++ b/db/migrate/20180607144007_create_remote_settings.rb @@ -0,0 +1,11 @@ +class CreateRemoteSettings < ActiveRecord::Migration[5.1] + def change + create_table :remote_settings do |t| + t.string "name", null: false + t.string "url", null: false + t.text "contents", null: false + t.timestamps + end + add_index :remote_settings, ["name"], name: "index_remote_settings_on_name", unique: true, using: :btree + end +end diff --git a/db/schema.rb b/db/schema.rb index 36d2cd64559..62ed4fc141c 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: 20180601145643) do +ActiveRecord::Schema.define(version: 20180607144007) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -99,6 +99,15 @@ t.index ["user_id"], name: "index_profiles_on_user_id" end + create_table "remote_settings", force: :cascade do |t| + t.string "name", null: false + t.string "url", null: false + t.text "contents", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["name"], name: "index_remote_settings_on_name", unique: true + end + create_table "service_provider_requests", force: :cascade do |t| t.string "issuer", null: false t.string "loa", null: false diff --git a/lib/i18n_override.rb b/lib/i18n_override.rb index a702f0c54e3..365508683a8 100644 --- a/lib/i18n_override.rb +++ b/lib/i18n_override.rb @@ -5,7 +5,7 @@ class << self def translate_with_markup(*args) i18n_text = normal_translate(*args) return i18n_text unless FeatureManagement.enable_i18n_mode? && i18n_text.is_a?(String) - return i18n_text if caller(2..2).first =~ /flows_spec.rb|session_helper.rb/ + return i18n_text if caller(2..2).first.match?(/flows_spec.rb|session_helper.rb/) key = args.first.to_s rtn = i18n_text + i18n_mode_additional_markup(key) @@ -65,7 +65,7 @@ def find_line_number def match_line_in_file(file, match_arr) File.foreach(file).with_index do |line, index| - break index + 1 if line =~ /#{match_arr.join('|')}/ + break index + 1 if line.match?(/#{match_arr.join('|')}/) end end diff --git a/lib/proofer_mocks/resolution_mock.rb b/lib/proofer_mocks/resolution_mock.rb index ac907d9ee42..5e256e149b1 100644 --- a/lib/proofer_mocks/resolution_mock.rb +++ b/lib/proofer_mocks/resolution_mock.rb @@ -8,10 +8,10 @@ class ResolutionMock < Proofer::Base raise 'Failed to contact proofing vendor' if first_name =~ /Fail/i - if first_name =~ /Bad/i + if first_name.match?(/Bad/i) result.add_error(:first_name, 'Unverified first name.') - elsif applicant[:ssn] =~ /6666/ + elsif applicant[:ssn].match?(/6666/) result.add_error(:ssn, 'Unverified SSN.') elsif applicant[:zipcode] == '00000' diff --git a/lib/tasks/remote_settings.rake b/lib/tasks/remote_settings.rake new file mode 100644 index 00000000000..f43c6db5593 --- /dev/null +++ b/lib/tasks/remote_settings.rake @@ -0,0 +1,26 @@ +namespace :remote_settings do + task :update, [:name, :url] => [:environment] do |task, args| + RemoteSettingsService.update_setting(args[:name], args[:url]) + Kernel.puts "Update successful" + end + + task :view, [:name] => [:environment] do |task, args| + Kernel.puts RemoteSetting.find_by(name: args[:name])&.contents + end + + task list: :environment do + RemoteSetting.all.each do |rec| + Kernel.puts "name=#{rec.name} url=#{rec.url}" + end + end + + task :delete, [:name] => [:environment] do |task, args| + RemoteSetting.where(name: args[:name]).delete_all + Kernel.puts "Delete successful" + end +end + +# example invocations: +# rake "remote_settings:update[agencies.yml,https://raw.githubusercontent.com/18F/identity-idp/master/config/agencies.yml]" +# rake "remote_settings:update[agencies.yml,https://login.gov/assets/idp/config/agencies.yml" +# rake "remote_settings:update[service_providers.yml,https://raw.githubusercontent.com/18F/identity-idp/master/config/service_providers.yml]" diff --git a/spec/config/initializers/active_job_logger_patch_spec.rb b/spec/config/initializers/active_job_logger_patch_spec.rb new file mode 100644 index 00000000000..66e7e054eb3 --- /dev/null +++ b/spec/config/initializers/active_job_logger_patch_spec.rb @@ -0,0 +1,36 @@ +require 'rails_helper' + +# Covers config/initializers/active_job_logger_patch.rb, which overrides +# ActiveJob::Logging::LogSubscriber to standardize output and prevent sensitive +# user data from being logged. +describe ActiveJob::Logging::LogSubscriber do + it 'overrides the default job logger to output only specified parameters in JSON format' do + class FakeJob < ApplicationJob + def perform(sensitive_param:); end + end + + # This list corresponds to the initializer's output + permitted_attributes = %w[ + timestamp + event_type + job_class + job_queue + job_id + duration + ] + + # In this case, we need to assert before the action which logs, block-style to + # match the initializer + expect(Rails.logger).to receive(:info) do |&blk| + output = JSON.parse(blk.call) + + # [Sidenote: The nested assertions don't seem to be reflected in the spec + # count--perhaps because of the uncommon block format?--but reversing them + # will show them failing as expected.] + output.each_key { |k| expect(permitted_attributes).to include(k) } + expect(output.keys).to_not include('sensitive_param') + end + + FakeJob.perform_later(sensitive_param: '111-22-3333') + end +end diff --git a/spec/controllers/account_recovery_setup_controller_spec.rb b/spec/controllers/account_recovery_setup_controller_spec.rb new file mode 100644 index 00000000000..4c77947abf2 --- /dev/null +++ b/spec/controllers/account_recovery_setup_controller_spec.rb @@ -0,0 +1,35 @@ +require 'rails_helper' + +describe AccountRecoverySetupController do + context 'user is not piv_cac enabled' do + it 'redirects to account_url' do + stub_sign_in + + get :index + + expect(response).to redirect_to account_url + end + end + + context 'user is piv_cac enabled and phone enabled' do + it 'redirects to account_url' do + user = build(:user, :signed_up, :with_piv_or_cac) + stub_sign_in(user) + + get :index + + expect(response).to redirect_to account_url + end + end + + 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) + stub_sign_in(user) + + get :index + + expect(response).to render_template(:index) + end + end +end diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index f6284e64ebf..13f5d1437e7 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -113,7 +113,7 @@ def index get :index - expect(response).to redirect_to phone_setup_url + expect(response).to redirect_to two_factor_options_url end end diff --git a/spec/controllers/idv/confirmations_controller_spec.rb b/spec/controllers/idv/confirmations_controller_spec.rb index aeecb8a3034..dd77041d4f0 100644 --- a/spec/controllers/idv/confirmations_controller_spec.rb +++ b/spec/controllers/idv/confirmations_controller_spec.rb @@ -37,7 +37,7 @@ def stub_idv_session address2: 'Ste 456', city: 'Anywhere', state: 'KS', - zipcode: '66666' + zipcode: '66666', } end let(:profile) { subject.idv_session.profile } diff --git a/spec/controllers/idv/jurisdiction_controller_spec.rb b/spec/controllers/idv/jurisdiction_controller_spec.rb index fcde8267922..84f1406eecd 100644 --- a/spec/controllers/idv/jurisdiction_controller_spec.rb +++ b/spec/controllers/idv/jurisdiction_controller_spec.rb @@ -76,22 +76,10 @@ controller.user_session[:idv_jurisdiction] = supported_jurisdiction end - it 'renders the `show` template' do + it 'renders the `_failure` template' do get :show, params: { reason: reason } - expect(response).to render_template(:show) - end - - it 'puts the jurisdiction from the user_session into @state' do - get :show, params: { reason: reason } - - expect(assigns(:state)).to eq(supported_jurisdiction) - end - - it 'puts the reason from the params in @reason' do - get :show, params: { reason: reason } - - expect(assigns(:reason)).to eq(reason) + expect(response).to render_template('shared/_failure') end end end diff --git a/spec/controllers/saml_idp_controller_spec.rb b/spec/controllers/saml_idp_controller_spec.rb index f202715267e..e2ffb79274a 100644 --- a/spec/controllers/saml_idp_controller_spec.rb +++ b/spec/controllers/saml_idp_controller_spec.rb @@ -894,6 +894,7 @@ def stub_auth allow(controller).to receive(:validate_saml_request_and_authn_context).and_return(true) allow(controller).to receive(:user_fully_authenticated?).and_return(true) allow(controller).to receive(:link_identity_from_session_data).and_return(true) + allow(controller).to receive(:current_user).and_return(build(:user)) end context 'user requires ID verification' do diff --git a/spec/controllers/sign_up/passwords_controller_spec.rb b/spec/controllers/sign_up/passwords_controller_spec.rb index 78bc85cc484..f36d41c7944 100644 --- a/spec/controllers/sign_up/passwords_controller_spec.rb +++ b/spec/controllers/sign_up/passwords_controller_spec.rb @@ -53,7 +53,7 @@ render_views it 'instructs crawlers to not index this page' do token = 'foo token' - user = create(:user, :unconfirmed, confirmation_token: token, confirmation_sent_at: Time.zone.now) + create(:user, :unconfirmed, confirmation_token: token, confirmation_sent_at: Time.zone.now) get :new, params: { confirmation_token: token } expect(response.body).to match('') diff --git a/spec/controllers/test/piv_cac_authentication_test_subject_controller_spec.rb b/spec/controllers/test/piv_cac_authentication_test_subject_controller_spec.rb index 992e94aa506..c691bd87d70 100644 --- a/spec/controllers/test/piv_cac_authentication_test_subject_controller_spec.rb +++ b/spec/controllers/test/piv_cac_authentication_test_subject_controller_spec.rb @@ -68,7 +68,7 @@ uri.to_s end - let(:expected_token) { {'error' => 'certificate.none', 'nonce' => nonce }} + let(:expected_token) { { 'error' => 'certificate.none', 'nonce' => nonce } } let(:serialized_token) { expected_token.to_json } let(:nonce) { 'nonce' } 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 9eef2bf379a..b77584f0a17 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,8 +3,7 @@ describe TwoFactorAuthentication::PivCacVerificationController do let(:user) do create(:user, :signed_up, :with_piv_or_cac, - phone: '+1 (555) 555-0000' - ) + phone: '+1 (555) 555-0000') end let(:nonce) { 'once' } @@ -17,12 +16,12 @@ allow(PivCacService).to receive(:decode_token).with('good-token').and_return( 'uuid' => user.x509_dn_uuid, 'dn' => x509_subject, - 'nonce' => nonce, + 'nonce' => nonce ) allow(PivCacService).to receive(:decode_token).with('good-other-token').and_return( 'uuid' => user.x509_dn_uuid + 'X', 'dn' => x509_subject + 'X', - 'nonce' => nonce, + 'nonce' => nonce ) allow(PivCacService).to receive(:decode_token).with('bad-token').and_return( 'uuid' => 'bad-uuid', @@ -58,7 +57,7 @@ expect(subject.current_user).to receive(:confirm_piv_cac?).and_return(true) expect(subject.current_user.reload.second_factor_attempts_count).to eq 0 - get :show, params: { token: 'good-token' } + get :show, params: { token: 'good-token' } expect(response).to redirect_to account_path expect(subject.user_session[:decrypted_x509]).to eq({ @@ -73,7 +72,7 @@ attributes: { second_factor_attempts_count: 1 } ).call - get :show, params: { token: 'good-token' } + get :show, params: { token: 'good-token' } expect(subject.current_user.reload.second_factor_attempts_count).to eq 0 end @@ -88,7 +87,7 @@ } expect(@analytics).to receive(:track_event).with(Analytics::MULTI_FACTOR_AUTH, attributes) - get :show, params: { token: 'good-token' } + get :show, params: { token: 'good-token' } end end @@ -170,9 +169,8 @@ let(:user) do create(:user, :signed_up, :with_piv_or_cac, - second_factor_locked_at: Time.zone.now - lockout_period - 1.second, - second_factor_attempts_count: 3 - ) + second_factor_locked_at: Time.zone.now - lockout_period - 1.second, + second_factor_attempts_count: 3) end describe 'when user submits an incorrect piv/cac' do diff --git a/spec/controllers/users/personal_keys_controller_spec.rb b/spec/controllers/users/personal_keys_controller_spec.rb index 4850462943b..2eaab123ecd 100644 --- a/spec/controllers/users/personal_keys_controller_spec.rb +++ b/spec/controllers/users/personal_keys_controller_spec.rb @@ -121,6 +121,8 @@ expect(generator).to receive(:create) expect(@analytics).to receive(:track_event).with(Analytics::PROFILE_PERSONAL_KEY_CREATE) + expect(Event).to receive(:create). + with(user_id: subject.current_user.id, event_type: :new_personal_key) post :create diff --git a/spec/controllers/users/phone_setup_controller_spec.rb b/spec/controllers/users/phone_setup_controller_spec.rb new file mode 100644 index 00000000000..d96c2364cd6 --- /dev/null +++ b/spec/controllers/users/phone_setup_controller_spec.rb @@ -0,0 +1,192 @@ +require 'rails_helper' + +describe Users::PhoneSetupController do + describe 'GET index' do + context 'when signed out' do + it 'redirects to sign in page' do + expect(PhoneSetupPresenter).to_not receive(:new) + + get :index + + expect(response).to redirect_to(new_user_session_url) + end + end + + context 'when signed in' do + it 'renders the index view' do + stub_analytics + user = build(:user, otp_delivery_preference: 'voice') + stub_sign_in_before_2fa(user) + + expect(@analytics).to receive(:track_event). + with(Analytics::USER_REGISTRATION_PHONE_SETUP_VISIT) + expect(PhoneSetupPresenter).to receive(:new).with(user.otp_delivery_preference) + expect(UserPhoneForm).to receive(:new).with(user) + + get :index + + expect(response).to render_template(:index) + end + end + end + + describe 'PATCH create' do + let(:user) { create(:user) } + + it 'tracks an event when the number is invalid' do + sign_in(user) + + stub_analytics + result = { + success: false, + errors: { phone: [t('errors.messages.improbable_phone')] }, + otp_delivery_preference: 'sms', + } + + expect(@analytics).to receive(:track_event). + with(Analytics::MULTI_FACTOR_AUTH_PHONE_SETUP, result) + + patch :create, params: { + user_phone_form: { + phone: '703-555-010', + international_code: 'US', + }, + } + + expect(response).to render_template(:index) + end + + context 'with voice' do + let(:user) { create(:user, otp_delivery_preference: 'voice') } + + it 'prompts to confirm the number' do + sign_in(user) + + stub_analytics + result = { + success: true, + errors: {}, + otp_delivery_preference: 'voice', + } + + expect(@analytics).to receive(:track_event). + with(Analytics::MULTI_FACTOR_AUTH_PHONE_SETUP, result) + + patch( + :create, + params: { + user_phone_form: { phone: '703-555-0100', + international_code: 'US' }, + } + ) + + expect(response).to redirect_to( + otp_send_path( + otp_delivery_selection_form: { otp_delivery_preference: 'voice' } + ) + ) + + expect(subject.user_session[:context]).to eq 'confirmation' + end + end + + context 'with SMS' do + it 'prompts to confirm the number' do + sign_in(user) + + stub_analytics + + result = { + success: true, + errors: {}, + otp_delivery_preference: 'sms', + } + + expect(@analytics).to receive(:track_event). + with(Analytics::MULTI_FACTOR_AUTH_PHONE_SETUP, result) + + patch( + :create, + params: { + user_phone_form: { phone: '703-555-0100', + international_code: 'US' }, + } + ) + + expect(response).to redirect_to( + otp_send_path( + otp_delivery_selection_form: { otp_delivery_preference: 'sms' } + ) + ) + + expect(subject.user_session[:context]).to eq 'confirmation' + end + end + + context 'without selection' do + it 'prompts to confirm via SMS by default' do + sign_in(user) + + stub_analytics + result = { + success: true, + errors: {}, + otp_delivery_preference: 'sms', + } + + expect(@analytics).to receive(:track_event). + with(Analytics::MULTI_FACTOR_AUTH_PHONE_SETUP, result) + + patch( + :create, + params: { + user_phone_form: { phone: '703-555-0100', + international_code: 'US' }, + } + ) + + expect(response).to redirect_to( + otp_send_path( + otp_delivery_selection_form: { otp_delivery_preference: 'sms' } + ) + ) + + expect(subject.user_session[:context]).to eq 'confirmation' + end + end + end + + describe 'before_actions' do + it 'includes the appropriate before_actions' do + expect(subject).to have_actions( + :before, + :authenticate_user, + :authorize_user + ) + end + end + + describe '#authorize_user' do + context 'when the user is fully authenticated and phone enabled' do + it 'redirects to account url' do + user = build_stubbed(:user, :with_phone) + stub_sign_in(user) + + get :index + + expect(response).to redirect_to(account_url) + end + end + + context 'when the user is two_factor_enabled but not fully authenticated' do + it 'prompts to enter OTP' do + user = build(:user, :signed_up) + stub_sign_in_before_2fa(user) + + get :index + + expect(response).to redirect_to(user_two_factor_authentication_url) + end + end + end +end 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 6db6814a1e5..bc5c746dba8 100644 --- a/spec/controllers/users/piv_cac_authentication_setup_controller_spec.rb +++ b/spec/controllers/users/piv_cac_authentication_setup_controller_spec.rb @@ -1,7 +1,6 @@ require 'rails_helper' describe Users::PivCacAuthenticationSetupController do - describe 'when not signed in' do describe 'GET index' do it 'redirects to root url' do @@ -33,9 +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 (555) 555-0000' - ) + create(:user, :signed_up, :with_piv_or_cac, phone: '+1 (555) 555-0000') end describe 'GET index' do @@ -58,9 +55,7 @@ context 'without associated piv/cac' do let(:user) do - create(:user, :signed_up, - phone: '+1 (555) 555-0000' - ) + create(:user, :signed_up, phone: '+1 (555) 555-0000') end before(:each) do @@ -83,7 +78,7 @@ let(:bad_token) { 'bad-token' } let(:bad_token_response) do { - 'error' => 'certificate.bad' , + 'error' => 'certificate.bad', 'nonce' => nonce, } end @@ -98,22 +93,24 @@ context 'when redirected with a good token' do it 'redirects to account page' do - get :new, params: {token: good_token} + get :new, params: { token: good_token } expect(response).to redirect_to(account_url) end it 'sets the piv/cac session information' do - get :new, params: {token: good_token} - expect(subject.user_session[:decrypted_x509]).to eq ({ + get :new, params: { token: good_token } + json = { 'subject' => 'some dn', - 'presented' => true - }.to_json) + 'presented' => true, + }.to_json + + expect(subject.user_session[:decrypted_x509]).to eq json end end context 'when redirected with an error token' do it 'renders the error template' do - get :new, params: {token: bad_token} + get :new, params: { token: bad_token } expect(response).to render_template(:error) end diff --git a/spec/controllers/users/reset_passwords_controller_spec.rb b/spec/controllers/users/reset_passwords_controller_spec.rb index b12736df20c..4cbcbda5b0a 100644 --- a/spec/controllers/users/reset_passwords_controller_spec.rb +++ b/spec/controllers/users/reset_passwords_controller_spec.rb @@ -33,6 +33,7 @@ allow(user).to receive(:reset_password_period_valid?).and_return(false) get :edit, params: { reset_password_token: 'foo' } + get :edit analytics_hash = { success: false, @@ -65,6 +66,9 @@ get :edit, params: { reset_password_token: 'foo' } + expect(response).to redirect_to edit_user_password_url + + get :edit expect(response).to render_template :edit expect(flash.keys).to be_empty expect(response.body).to match('') @@ -87,8 +91,9 @@ reset_password_token: db_confirmation_token ) - params = { password: 'short', reset_password_token: raw_reset_token } + params = { password: 'short' } + get :edit, params: { reset_password_token: raw_reset_token } put :update, params: { reset_password_form: params } analytics_hash = { @@ -122,7 +127,7 @@ reset_password_token: db_confirmation_token, reset_password_sent_at: Time.zone.now ) - form_params = { password: 'short', reset_password_token: raw_reset_token } + form_params = { password: 'short' } analytics_hash = { success: false, errors: { password: ['is too short (minimum is 9 characters)'] }, @@ -134,6 +139,7 @@ expect(@analytics).to receive(:track_event). with(Analytics::PASSWORD_RESET_PASSWORD, analytics_hash) + get :edit, params: { reset_password_token: raw_reset_token } put :update, params: { reset_password_form: form_params } expect(response).to render_template(:edit) @@ -161,8 +167,9 @@ stub_email_notifier(user) password = 'a really long passw0rd' - params = { password: password, reset_password_token: raw_reset_token } + params = { password: password } + get :edit, params: { reset_password_token: raw_reset_token } put :update, params: { reset_password_form: params } analytics_hash = { @@ -176,6 +183,8 @@ expect(@analytics).to have_received(:track_event). with(Analytics::PASSWORD_RESET_PASSWORD, analytics_hash) + expect(user.events.password_changed.size).to be 1 + expect(response).to redirect_to new_user_session_path expect(flash[:notice]).to eq t('devise.passwords.updated_not_active') expect(user.reload.confirmed_at).to eq old_confirmed_at @@ -199,8 +208,9 @@ stub_email_notifier(user) + get :edit, params: { reset_password_token: raw_reset_token } password = 'a really long passw0rd' - params = { password: password, reset_password_token: raw_reset_token } + params = { password: password } put :update, params: { reset_password_form: params } @@ -239,8 +249,9 @@ stub_email_notifier(user) password = 'a really long passw0rd' - params = { password: password, reset_password_token: raw_reset_token } + params = { password: password } + get :edit, params: { reset_password_token: raw_reset_token } put :update, params: { reset_password_form: params } analytics_hash = { diff --git a/spec/controllers/users/sessions_controller_spec.rb b/spec/controllers/users/sessions_controller_spec.rb index 84bf4734687..e7c3161b716 100644 --- a/spec/controllers/users/sessions_controller_spec.rb +++ b/spec/controllers/users/sessions_controller_spec.rb @@ -271,7 +271,9 @@ it 'deactivates profile if not de-cryptable' do user = create(:user, :signed_up) profile = create(:profile, :active, :verified, user: user, pii: { ssn: '1234' }) - profile.update!(encrypted_pii: Base64.strict_encode64('nonsense')) + profile.update!( + encrypted_pii: { encrypted_data: Base64.strict_encode64('nonsense') }.to_json + ) stub_analytics analytics_hash = { @@ -318,13 +320,13 @@ expect(response).to render_template(:new) end - it 'logs Pii::EncryptionError' do + it 'logs Encryption::EncryptionError' do user = create(:user, :signed_up) - allow(user).to receive(:unlock_user_access_key).and_raise Pii::EncryptionError, 'foo' + allow(user).to receive(:unlock_user_access_key).and_raise Encryption::EncryptionError, 'foo' expect(Rails.logger).to receive(:info) do |attributes| attributes = JSON.parse(attributes) - expect(attributes['event']).to eq 'Pii::EncryptionError when validating password' + expect(attributes['event']).to eq 'Encryption::EncryptionError when validating password' expect(attributes['error']).to eq 'foo' expect(attributes['uuid']).to eq user.uuid expect(attributes).to have_key('timestamp') diff --git a/spec/controllers/users/totp_setup_controller_spec.rb b/spec/controllers/users/totp_setup_controller_spec.rb index e7139ca04b2..d564f335671 100644 --- a/spec/controllers/users/totp_setup_controller_spec.rb +++ b/spec/controllers/users/totp_setup_controller_spec.rb @@ -6,7 +6,7 @@ expect(subject).to have_actions( :before, :authenticate_user!, - [:confirm_two_factor_authenticated, if: :two_factor_enabled?], + [:confirm_two_factor_authenticated, if: :two_factor_enabled?] ) end end diff --git a/spec/controllers/users/two_factor_authentication_controller_spec.rb b/spec/controllers/users/two_factor_authentication_controller_spec.rb index a190a83877c..66f716b905c 100644 --- a/spec/controllers/users/two_factor_authentication_controller_spec.rb +++ b/spec/controllers/users/two_factor_authentication_controller_spec.rb @@ -69,6 +69,16 @@ def index end describe '#show' do + context 'when user is piv/cac enabled' do + it 'renders the piv/cac entry screen' do + stub_sign_in_before_2fa(build(:user)) + allow(subject.current_user).to receive(:piv_cac_enabled?).and_return(true) + get :show + + expect(response).to redirect_to login_two_factor_piv_cac_path + end + end + context 'when user is TOTP enabled' do it 'renders the :confirm_totp view' do stub_sign_in_before_2fa(build(:user)) @@ -105,7 +115,7 @@ def index stub_sign_in_before_2fa(build(:user)) get :show - expect(response).to redirect_to phone_setup_url + expect(response).to redirect_to two_factor_options_url end end end @@ -158,7 +168,7 @@ def index allow(OtpRateLimiter).to receive(:new).with(phone: @user.phone, user: @user). and_return(otp_rate_limiter) - expect(otp_rate_limiter).to receive(:exceeded_otp_send_limit?) + expect(otp_rate_limiter).to receive(:exceeded_otp_send_limit?).twice expect(otp_rate_limiter).to receive(:increment) get :send_code, params: { otp_delivery_selection_form: { otp_delivery_preference: 'sms' } } diff --git a/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb b/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb index 3ac54eedb50..e5af8d4b2e5 100644 --- a/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb +++ b/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb @@ -2,6 +2,16 @@ describe Users::TwoFactorAuthenticationSetupController do describe 'GET index' do + it 'tracks the visit in analytics' do + stub_sign_in_before_2fa + stub_analytics + + expect(@analytics).to receive(:track_event). + with(Analytics::USER_REGISTRATION_2FA_SETUP_VISIT) + + get :index + end + context 'when signed out' do it 'redirects to sign in page' do get :index @@ -9,165 +19,150 @@ expect(response).to redirect_to(new_user_session_url) end end + + context 'when fully authenticated and phone enabled' do + it 'redirects to account page' do + user = build(:user, :signed_up) + stub_sign_in(user) + + get :index + + expect(response).to redirect_to(account_url) + end + end + + context 'when fully authenticated but not phone enabled' do + it 'allows access' do + stub_sign_in + + get :index + + expect(response).to render_template(:index) + end + end + + context 'already two factor enabled but not fully authenticated' do + it 'prompts for 2FA' do + user = build(:user, :signed_up) + stub_sign_in_before_2fa(user) + + get :index + + expect(response).to redirect_to(user_two_factor_authentication_url) + end + end end - describe 'PATCH set' do - let(:user) { create(:user) } + describe 'PATCH create' do + it 'submits the TwoFactorOptionsForm' do + user = build(:user) + stub_sign_in_before_2fa(user) + + voice_params = { + two_factor_options_form: { + selection: 'voice', + }, + } + params = ActionController::Parameters.new(voice_params) + response = FormResponse.new(success: true, errors: {}, extra: { selection: 'voice' }) - it 'tracks an event when the number is invalid' do - sign_in(user) + form = instance_double(TwoFactorOptionsForm) + allow(TwoFactorOptionsForm).to receive(:new).with(user).and_return(form) + expect(form).to receive(:submit). + with(params.require(:two_factor_options_form).permit(:selection)). + and_return(response) + expect(form).to receive(:selection).and_return('voice') + patch :create, params: voice_params + end + + it 'tracks analytics event' do + stub_sign_in_before_2fa stub_analytics + result = { - success: false, - errors: { phone: [t('errors.messages.improbable_phone')] }, - otp_delivery_preference: 'sms', + selection: 'voice', + success: true, + errors: {}, } expect(@analytics).to receive(:track_event). - with(Analytics::MULTI_FACTOR_AUTH_PHONE_SETUP, result) + with(Analytics::USER_REGISTRATION_2FA_SETUP, result) - patch :set, params: { - user_phone_form: { - phone: '703-555-010', - otp_delivery_preference: :sms, - international_code: 'US', + patch :create, params: { + two_factor_options_form: { + selection: 'voice', }, } - - expect(response).to render_template(:index) end - context 'with voice' do - it 'prompts to confirm the number' do - sign_in(user) + context 'when the selection is sms' do + it 'redirects to phone setup page' do + stub_sign_in_before_2fa - stub_analytics - result = { - success: true, - errors: {}, - otp_delivery_preference: 'voice', + patch :create, params: { + two_factor_options_form: { + selection: 'sms', + }, } - expect(@analytics).to receive(:track_event). - with(Analytics::MULTI_FACTOR_AUTH_PHONE_SETUP, result) - - patch( - :set, - params: { - user_phone_form: { phone: '703-555-0100', - otp_delivery_preference: 'voice', - international_code: 'US' }, - } - ) - - expect(response).to redirect_to( - otp_send_path( - otp_delivery_selection_form: { otp_delivery_preference: 'voice' } - ) - ) - - expect(subject.user_session[:context]).to eq 'confirmation' + expect(response).to redirect_to phone_setup_url end end - context 'with SMS' do - it 'prompts to confirm the number' do - sign_in(user) - - stub_analytics + context 'when the selection is voice' do + it 'redirects to phone setup page' do + stub_sign_in_before_2fa - result = { - success: true, - errors: {}, - otp_delivery_preference: 'sms', + patch :create, params: { + two_factor_options_form: { + selection: 'voice', + }, } - expect(@analytics).to receive(:track_event). - with(Analytics::MULTI_FACTOR_AUTH_PHONE_SETUP, result) - - patch( - :set, - params: { - user_phone_form: { phone: '703-555-0100', - otp_delivery_preference: :sms, - international_code: 'US' }, - } - ) - - expect(response).to redirect_to( - otp_send_path( - otp_delivery_selection_form: { otp_delivery_preference: 'sms' } - ) - ) - - expect(subject.user_session[:context]).to eq 'confirmation' + expect(response).to redirect_to phone_setup_url end end - context 'without selection' do - it 'prompts to confirm via SMS by default' do - sign_in(user) + context 'when the selection is auth_app' do + it 'redirects to authentication app setup page' do + stub_sign_in_before_2fa - stub_analytics - result = { - success: true, - errors: {}, - otp_delivery_preference: 'sms', + patch :create, params: { + two_factor_options_form: { + selection: 'auth_app', + }, } - expect(@analytics).to receive(:track_event). - with(Analytics::MULTI_FACTOR_AUTH_PHONE_SETUP, result) - - patch( - :set, - params: { - user_phone_form: { phone: '703-555-0100', - otp_delivery_preference: :sms, - international_code: 'US' }, - } - ) - - expect(response).to redirect_to( - otp_send_path( - otp_delivery_selection_form: { otp_delivery_preference: 'sms' } - ) - ) - - expect(subject.user_session[:context]).to eq 'confirmation' + expect(response).to redirect_to authenticator_setup_url end end - end - - describe 'before_actions' do - it 'includes the appropriate before_actions' do - expect(subject).to have_actions( - :before, - :authenticate_user, - :authorize_otp_setup - ) - end - end - describe '#authorize_otp_setup' do - context 'when the user is fully authenticated' do - it 'redirects to root url' do - user = create(:user, :signed_up) - sign_in(user) + context 'when the selection is piv_cac' do + it 'redirects to piv/cac setup page' do + stub_sign_in_before_2fa - get :index + patch :create, params: { + two_factor_options_form: { + selection: 'piv_cac', + }, + } - expect(response).to redirect_to(root_url) + expect(response).to redirect_to setup_piv_cac_url end end - context 'when the user is two_factor_enabled but not fully authenticated' do - it 'prompts to enter OTP' do - sign_in_before_2fa + context 'when the selection is not valid' do + it 'renders index page' do + stub_sign_in_before_2fa - get :index + patch :create, params: { + two_factor_options_form: { + selection: 'foo', + }, + } - expect(response).to redirect_to(user_two_factor_authentication_path) + expect(response).to render_template(:index) end end end diff --git a/spec/decorators/service_provider_session_decorator_spec.rb b/spec/decorators/service_provider_session_decorator_spec.rb index 083c52d1f81..47f943b48d8 100644 --- a/spec/decorators/service_provider_session_decorator_spec.rb +++ b/spec/decorators/service_provider_session_decorator_spec.rb @@ -126,6 +126,55 @@ end end + describe '#sp_logo_url' do + context 'service provider has a logo' do + it 'returns the logo' do + sp_logo = 'real_logo.svg' + sp = build_stubbed(:service_provider, logo: sp_logo) + + subject = ServiceProviderSessionDecorator.new( + sp: sp, + view_context: view_context, + sp_session: {}, + service_provider_request: ServiceProviderRequest.new + ) + + expect(subject.sp_logo_url).to end_with("/sp-logos/#{sp_logo}") + end + end + + context 'service provider does not have a logo' do + it 'returns the default logo' do + sp = build_stubbed(:service_provider, logo: nil) + + subject = ServiceProviderSessionDecorator.new( + sp: sp, + view_context: view_context, + sp_session: {}, + service_provider_request: ServiceProviderRequest.new + ) + + expect(subject.sp_logo_url).to match(%r{/sp-logos/generic-.+\.svg}) + end + end + + context 'service provider has a remote logo' do + it 'returns the remote logo' do + logo = 'https://raw.githubusercontent.com/18F/identity-idp/master/app/assets/images/sp-logos/generic.svg' + sp = build_stubbed(:service_provider, logo: logo) + + subject = ServiceProviderSessionDecorator.new( + sp: sp, + view_context: view_context, + sp_session: {}, + service_provider_request: ServiceProviderRequest.new + ) + + expect(subject.sp_logo_url).to eq(logo) + end + end + end + describe '#cancel_link_url' do subject(:decorator) do ServiceProviderSessionDecorator.new( @@ -136,18 +185,14 @@ ) end - it 'returns sign_up_start_url with the request_id as a param' do - expect(decorator.cancel_link_url). - to eq 'http://www.example.com/sign_up/start?request_id=foo' + before do + allow(view_context).to receive(:sign_up_start_url). + and_return('https://www.example.com/sign_up/start') end - context 'in another language' do - before { I18n.locale = :fr } - - it 'keeps the language' do - expect(decorator.cancel_link_url). - to eq 'http://www.example.com/fr/sign_up/start?request_id=foo' - end + it 'returns view_context.sign_up_start_url' do + expect(decorator.cancel_link_url). + to eq 'https://www.example.com/sign_up/start' end end end diff --git a/spec/decorators/session_decorator_spec.rb b/spec/decorators/session_decorator_spec.rb index c032abe3c71..34d145ca63f 100644 --- a/spec/decorators/session_decorator_spec.rb +++ b/spec/decorators/session_decorator_spec.rb @@ -62,8 +62,12 @@ end describe '#cancel_link_url' do - it 'returns root url' do - expect(subject.cancel_link_url).to eq 'http://www.example.com/' + it 'returns view_context.root url' do + view_context = ActionController::Base.new.view_context + allow(view_context).to receive(:root_url).and_return('http://www.example.com') + decorator = SessionDecorator.new(view_context: view_context) + + expect(decorator.cancel_link_url).to eq 'http://www.example.com' end end end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index c69990d0a18..6410f18f62e 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -15,6 +15,17 @@ x509_dn_uuid { SecureRandom.uuid } end + trait :with_personal_key do + after :build do |user| + user.personal_key = PersonalKeyGenerator.new(user).create + end + end + + trait :with_authentication_app do + with_personal_key + otp_secret_key 'abc123' + end + trait :admin do role :admin end @@ -25,9 +36,7 @@ trait :signed_up do with_phone - after :build do |user| - user.personal_key = PersonalKeyGenerator.new(user).create - end + with_personal_key end trait :unconfirmed do diff --git a/spec/features/accessibility/idv_pages_spec.rb b/spec/features/accessibility/idv_pages_spec.rb index 015fc33a270..e3e1377736d 100644 --- a/spec/features/accessibility/idv_pages_spec.rb +++ b/spec/features/accessibility/idv_pages_spec.rb @@ -43,7 +43,7 @@ end scenario 'review page' do - user = sign_in_and_2fa_user + sign_in_and_2fa_user visit idv_session_path fill_out_idv_form_ok click_idv_continue @@ -55,7 +55,7 @@ end scenario 'personal key / confirmation page' do - user = sign_in_and_2fa_user + sign_in_and_2fa_user visit idv_session_path fill_out_idv_form_ok click_idv_continue diff --git a/spec/features/accessibility/user_pages_spec.rb b/spec/features/accessibility/user_pages_spec.rb index 38367466aff..9db6fc614b6 100644 --- a/spec/features/accessibility/user_pages_spec.rb +++ b/spec/features/accessibility/user_pages_spec.rb @@ -28,8 +28,16 @@ end describe '2FA pages' do + scenario 'two factor options page' do + sign_up_and_set_password + + expect(current_path).to eq(two_factor_options_path) + expect(page).to be_accessible + end + scenario 'phone setup page' do sign_up_and_set_password + click_button t('forms.buttons.continue') expect(current_path).to eq(phone_setup_path) expect(page).to be_accessible diff --git a/spec/features/account_history_spec.rb b/spec/features/account_history_spec.rb index 0d36e2ea4c9..80959fc4ba1 100644 --- a/spec/features/account_history_spec.rb +++ b/spec/features/account_history_spec.rb @@ -32,6 +32,14 @@ let(:identity_with_link_timestamp) { identity_with_link.decorate.happened_at_in_words } let(:usps_mail_sent_again_timestamp) { usps_mail_sent_again_event.decorate.happened_at_in_words } let(:identity_without_link_timestamp) { identity_without_link.decorate.happened_at_in_words } + let(:new_personal_key_event) do + create(:event, event_type: :new_personal_key, + user: user, created_at: Time.zone.now - 40.days) + end + let(:password_changed_event) do + create(:event, event_type: :password_changed, + user: user, created_at: Time.zone.now - 30.days) + end before do sign_in_and_2fa_user(user) @@ -40,7 +48,14 @@ end scenario 'viewing account history' do - [account_created_event, usps_mail_sent_event, usps_mail_sent_again_event].each do |event| + events = [ + account_created_event, + usps_mail_sent_event, + usps_mail_sent_again_event, + new_personal_key_event, + password_changed_event, + ] + events.each do |event| decorated_event = event.decorate expect(page).to have_content(decorated_event.event_type) expect(page).to have_content(decorated_event.happened_at_in_words) @@ -70,5 +85,7 @@ def build_account_history usps_mail_sent_again_event identity_with_link identity_without_link + new_personal_key_event + password_changed_event end end diff --git a/spec/features/idv/steps/jurisdiction_step_spec.rb b/spec/features/idv/steps/jurisdiction_step_spec.rb index af54d9a3451..fae498190c5 100644 --- a/spec/features/idv/steps/jurisdiction_step_spec.rb +++ b/spec/features/idv/steps/jurisdiction_step_spec.rb @@ -29,7 +29,8 @@ select 'Alabama', from: 'jurisdiction_state' click_idv_continue - expect(page).to have_current_path(idv_jurisdiction_fail_path(reason: :unsupported_jurisdiction)) + expect(page). + to have_current_path(idv_jurisdiction_fail_path(reason: :unsupported_jurisdiction)) expect(page).to have_content(t('idv.titles.unsupported_jurisdiction', state: 'Alabama')) end end diff --git a/spec/features/openid_connect/openid_connect_spec.rb b/spec/features/openid_connect/openid_connect_spec.rb index d70992dfa04..498f6e57f2d 100644 --- a/spec/features/openid_connect/openid_connect_spec.rb +++ b/spec/features/openid_connect/openid_connect_spec.rb @@ -574,12 +574,15 @@ def enable_cloudhsm(is_enabled) allow(Figaro.env).to receive(:cloudhsm_enabled).and_return('true') SamlIdp.configure { |config| SamlIdpEncryptionConfigurator.configure(config, true) } allow(PKCS11).to receive(:open).and_return('true') - allow_any_instance_of(SamlIdp::Configurator).to receive_message_chain(:pkcs11, :active_slots, :first, :open).and_yield(MockSession) + allow_any_instance_of(SamlIdp::Configurator). + to receive_message_chain(:pkcs11, :active_slots, :first, :open).and_yield(MockSession) allow(MockSession).to receive(:login).and_return(true) allow(MockSession).to receive(:logout).and_return(true) allow(MockSession).to receive_message_chain(:find_objects, :first).and_return(true) - allow(MockSession).to receive(:sign) do |algorithm, key, input| - JWT::Algos::Rsa.sign(JWT::Signature::ToSign.new('RS256', input, RequestKeyManager.private_key)) + allow(MockSession).to receive(:sign) do |_algorithm, _key, input| + JWT::Algos::Rsa.sign( + JWT::Signature::ToSign.new('RS256', input, RequestKeyManager.private_key) + ) end end end diff --git a/spec/features/saml/loa1_sso_spec.rb b/spec/features/saml/loa1_sso_spec.rb index dc593198337..4e0b80685e7 100644 --- a/spec/features/saml/loa1_sso_spec.rb +++ b/spec/features/saml/loa1_sso_spec.rb @@ -182,7 +182,7 @@ saml_authn_request = auth_request.create(saml_settings) visit saml_authn_request - expect(current_path).to eq phone_setup_path + expect(current_path).to eq two_factor_options_path end end diff --git a/spec/features/saml/saml_spec.rb b/spec/features/saml/saml_spec.rb index 08df6bab357..8fd216d08b5 100644 --- a/spec/features/saml/saml_spec.rb +++ b/spec/features/saml/saml_spec.rb @@ -35,11 +35,12 @@ class MockSession; end end it 'prompts the user to set up 2FA' do - expect(current_path).to eq phone_setup_path + expect(current_path).to eq two_factor_options_path end it 'prompts the user to confirm phone after setting up 2FA' do - fill_in 'Phone', with: '202-555-1212' + select_2fa_option('sms') + fill_in 'user_phone_form_phone', with: '202-555-1212' click_send_security_code expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'sms') @@ -282,7 +283,8 @@ def enable_cloudhsm(is_enabled) allow(Figaro.env).to receive(:cloudhsm_enabled).and_return('true') SamlIdp.configure { |config| SamlIdpEncryptionConfigurator.configure(config, true) } allow(PKCS11).to receive(:open).and_return('true') - allow_any_instance_of(SamlIdp::Configurator).to receive_message_chain(:pkcs11, :active_slots, :first, :open).and_yield(MockSession) + allow_any_instance_of(SamlIdp::Configurator). + to receive_message_chain(:pkcs11, :active_slots, :first, :open).and_yield(MockSession) allow(MockSession).to receive(:login).and_return(true) allow(MockSession).to receive(:logout).and_return(true) allow(MockSession).to receive_message_chain(:find_objects, :first).and_return(true) diff --git a/spec/features/session/decryption_spec.rb b/spec/features/session/decryption_spec.rb index 872add2dd25..13db76bc3a4 100644 --- a/spec/features/session/decryption_spec.rb +++ b/spec/features/session/decryption_spec.rb @@ -6,9 +6,9 @@ sign_in_and_2fa_user session_encryptor = Rails.application.config.session_options[:serializer] - allow(session_encryptor).to receive(:load).and_raise(Pii::EncryptionError) + allow(session_encryptor).to receive(:load).and_raise(Encryption::EncryptionError) - expect { visit account_path }.to raise_error(Pii::EncryptionError) + expect { visit account_path }.to raise_error(Encryption::EncryptionError) allow(session_encryptor).to receive(:load).and_call_original visit account_path diff --git a/spec/features/two_factor_authentication/remember_device_spec.rb b/spec/features/two_factor_authentication/remember_device_spec.rb index 92f5812d935..0e965c28f49 100644 --- a/spec/features/two_factor_authentication/remember_device_spec.rb +++ b/spec/features/two_factor_authentication/remember_device_spec.rb @@ -28,6 +28,7 @@ def remember_device_and_sign_out_user def remember_device_and_sign_out_user user = sign_up_and_set_password user.password = Features::SessionHelper::VALID_PASSWORD + select_2fa_option('sms') fill_in :user_phone_form_phone, with: '5551231234' click_send_security_code check :remember_device diff --git a/spec/features/two_factor_authentication/sign_in_spec.rb b/spec/features/two_factor_authentication/sign_in_spec.rb index 062d8ad7e0a..b4b2edf38f0 100644 --- a/spec/features/two_factor_authentication/sign_in_spec.rb +++ b/spec/features/two_factor_authentication/sign_in_spec.rb @@ -9,9 +9,14 @@ attempt_to_bypass_2fa_setup - expect(current_path).to eq phone_setup_path + expect(current_path).to eq two_factor_options_path + + select_2fa_option('sms') + + click_continue + expect(page). - to have_content t('devise.two_factor_authentication.two_factor_setup') + to have_content t('titles.phone_setup.sms') send_security_code_without_entering_phone_number @@ -25,19 +30,20 @@ expect(page).to have_content invalid_phone_message - submit_2fa_setup_form_with_valid_phone_and_choose_phone_call_delivery + submit_2fa_setup_form_with_valid_phone expect(page).to_not have_content invalid_phone_message - expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'voice') + expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'sms') expect(user.reload.phone).to_not eq '+1 (555) 555-1212' - expect(user.voice?).to eq true + expect(user.sms?).to eq true end context 'user enters OTP incorrectly 3 times' do it 'locks the user out' do sign_in_before_2fa - submit_2fa_setup_form_with_valid_phone_and_choose_phone_call_delivery + select_2fa_option('sms') + submit_2fa_setup_form_with_valid_phone 3.times do fill_in('code', with: 'bad-code') click_button t('forms.buttons.submit.default') @@ -47,89 +53,51 @@ end end - context 'with U.S. phone that does not support phone delivery method' do + context 'with U.S. phone that does not support voice delivery method' do let(:unsupported_phone) { '242-555-5555' } - scenario 'renders an error if a user submits with phone selected' do + scenario 'renders an error if a user submits with voice selected' do sign_in_before_2fa + select_2fa_option('voice') fill_in 'Phone', with: unsupported_phone - choose 'Phone call' click_send_security_code - expect(current_path).to eq(phone_setup_path) - expect(page).to have_content t( - 'devise.two_factor_authentication.otp_delivery_preference.phone_unsupported', - location: 'Bahamas' - ) - end - - scenario 'disables the phone option and displays a warning with js', :js do - sign_in_before_2fa - - select_country_and_type_phone_number(country: 'bs', number: '7035551212') - phone_radio_button = page.find( - '#user_phone_form_otp_delivery_preference_voice', - visible: :all - ) + expect(current_path).to eq phone_setup_path expect(page).to have_content t( 'devise.two_factor_authentication.otp_delivery_preference.phone_unsupported', location: 'Bahamas' ) - expect(phone_radio_button).to be_disabled - select_country_and_type_phone_number(country: 'us', number: '7035551212') - - expect(page).not_to have_content t( - 'devise.two_factor_authentication.otp_delivery_preference.phone_unsupported', - location: 'Bahamas' - ) - expect(phone_radio_button).to_not be_disabled + click_on t('devise.two_factor_authentication.two_factor_choice_cancel') + + expect(current_path).to eq two_factor_options_path end end - context 'with international phone that does not support phone delivery' do - scenario 'renders an error if a user submits with phone selected' do + context 'with international phone that does not support voice delivery' do + scenario 'updates international code as user types', :js do sign_in_before_2fa + select_2fa_option('voice') + fill_in 'Phone', with: '+81 54 354 3643' - select 'Turkey +90', from: 'International code' - fill_in 'Phone', with: '555-555-5000' - choose 'Phone call' - click_send_security_code - - expect(current_path).to eq(phone_setup_path) - expect(page).to have_content t( - 'devise.two_factor_authentication.otp_delivery_preference.phone_unsupported', - location: 'Turkey' - ) - end - - scenario 'disables the phone option and displays a warning with js', :js do - sign_in_before_2fa - select_country_and_type_phone_number(country: 'tr', number: '3122132965') + expect(page.find('#user_phone_form_international_code', visible: false).value).to eq 'JP' - phone_radio_button = page.find( - '#user_phone_form_otp_delivery_preference_voice', - visible: :all - ) + fill_in 'Phone', with: '' + fill_in 'Phone', with: '+212 5376' - expect(page).to have_content t( - 'devise.two_factor_authentication.otp_delivery_preference.phone_unsupported', - location: 'Turkey' - ) - expect(phone_radio_button).to be_disabled + expect(page.find('#user_phone_form_international_code', visible: false).value).to eq 'MA' - select_country_and_type_phone_number(country: 'ca', number: '3122132965') + fill_in 'Phone', with: '' + fill_in 'Phone', with: '+81 54354' - expect(page).not_to have_content t( - 'devise.two_factor_authentication.otp_delivery_preference.phone_unsupported', - location: 'Turkey' - ) - expect(phone_radio_button).to_not be_disabled + expect(page.find('#user_phone_form_international_code', visible: false).value).to eq 'JP' end scenario 'allows a user to continue typing even if a number is invalid', :js do sign_in_before_2fa + select_2fa_option('voice') + select_country_and_type_phone_number(country: 'us', number: '12345678901234567890') expect(phone_field.value).to eq('12345678901234567890') @@ -142,7 +110,7 @@ def phone_field end def select_country_and_type_phone_number(country:, number:) - find(".selected-flag").click + find('.selected-flag').click find(".country[data-country-code='#{country}']:not(.preferred)").click phone_field.send_keys(number) end @@ -156,18 +124,17 @@ def send_security_code_without_entering_phone_number end def submit_2fa_setup_form_with_empty_string_phone - fill_in 'Phone', with: '' + fill_in 'user_phone_form_phone', with: '' click_send_security_code end def submit_2fa_setup_form_with_invalid_phone - fill_in 'Phone', with: 'five one zero five five five four three two one' + fill_in 'user_phone_form_phone', with: 'five one zero five five five four three two one' click_send_security_code end - def submit_2fa_setup_form_with_valid_phone_and_choose_phone_call_delivery - fill_in 'Phone', with: '555-555-1212' - choose 'Phone call' + def submit_2fa_setup_form_with_valid_phone + fill_in 'user_phone_form_phone', with: '555-555-1212' click_send_security_code end @@ -387,7 +354,7 @@ def submit_prefilled_otp_code rate_limited_phone = OtpRequestsTracker.find_by(phone_fingerprint: phone_fingerprint) expect(current_path).to eq otp_send_path - expect(rate_limited_phone.otp_send_count).to eq max_attempts + expect(rate_limited_phone.otp_send_count).to eq max_attempts + 1 visit account_path @@ -406,15 +373,16 @@ 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: '+1 202-555-1212') + second_user = create(:user, :signed_up, phone: '202-555-1212') sign_in_before_2fa max_attempts = Figaro.env.otp_delivery_blocklist_maxretry.to_i - submit_2fa_setup_form_with_valid_phone_and_choose_phone_call_delivery + select_2fa_option('sms') + submit_2fa_setup_form_with_valid_phone max_attempts.times do - click_link t('links.two_factor_authentication.resend_code.voice') + click_link t('links.two_factor_authentication.resend_code.sms') end expect(page).to have_content t('titles.account_locked') @@ -525,11 +493,10 @@ def submit_prefilled_otp_code nonce = visit_login_two_factor_piv_cac_and_get_nonce - visit_piv_cac_service(login_two_factor_piv_cac_path, { - uuid: user.x509_dn_uuid, - dn: "C=US, O=U.S. Government, OU=DoD, OU=PKI, CN=DOE.JOHN.1234", - nonce: nonce - }) + visit_piv_cac_service(login_two_factor_piv_cac_path, + uuid: user.x509_dn_uuid, + dn: 'C=US, O=U.S. Government, OU=DoD, OU=PKI, CN=DOE.JOHN.1234', + nonce: nonce) expect(current_path).to eq account_path end @@ -541,13 +508,12 @@ def submit_prefilled_otp_code nonce = visit_login_two_factor_piv_cac_and_get_nonce - visit_piv_cac_service(login_two_factor_piv_cac_path, { - uuid: user.x509_dn_uuid + 'X', - dn: "C=US, O=U.S. Government, OU=DoD, OU=PKI, CN=DOE.JOHN.12345", - nonce: nonce - }) + visit_piv_cac_service(login_two_factor_piv_cac_path, + uuid: user.x509_dn_uuid + 'X', + dn: 'C=US, O=U.S. Government, OU=DoD, OU=PKI, CN=DOE.JOHN.12345', + nonce: nonce) expect(current_path).to eq login_two_factor_piv_cac_path - expect(page).to have_content(t("devise.two_factor_authentication.invalid_piv_cac")) + expect(page).to have_content(t('devise.two_factor_authentication.invalid_piv_cac')) end end @@ -560,9 +526,9 @@ def submit_prefilled_otp_code end end - describe 'when the user is TOTP enabled' do + describe 'when the user is TOTP enabled and phone enabled' do it 'allows SMS and Voice fallbacks' do - user = create(:user, :signed_up, otp_secret_key: 'foo') + user = create(:user, :with_authentication_app, :with_phone) sign_in_before_2fa(user) click_link t('devise.two_factor_authentication.totp_fallback.sms_link_text') diff --git a/spec/features/users/piv_cac_management_spec.rb b/spec/features/users/piv_cac_management_spec.rb index 00c7f9f77b2..9a033a90408 100644 --- a/spec/features/users/piv_cac_management_spec.rb +++ b/spec/features/users/piv_cac_management_spec.rb @@ -1,7 +1,6 @@ require 'rails_helper' feature 'PIV/CAC Management' do - def find_form(page, attributes) page.all('form').detect do |form| attributes.all? { |key, value| form[key] == value } @@ -21,7 +20,7 @@ def find_form(page, attributes) Identity.create( user_id: user.id, service_provider: 'http://localhost:3000', - last_authenticated_at: Time.now, + last_authenticated_at: Time.zone.now ) end @@ -38,17 +37,16 @@ def find_form(page, attributes) sign_in_and_2fa_user(user) visit account_path - expect(page).to have_link(t('forms.buttons.enable'), href: setup_piv_cac_url) + click_link t('forms.buttons.enable'), href: setup_piv_cac_url - visit setup_piv_cac_url expect(page).to have_link(t('forms.piv_cac_setup.submit')) + nonce = get_piv_cac_nonce_from_link(find_link(t('forms.piv_cac_setup.submit'))) - visit_piv_cac_service(setup_piv_cac_url, { - nonce: nonce, - uuid: uuid, - subject: 'SomeIgnoredSubject' - }) + visit_piv_cac_service(setup_piv_cac_url, + nonce: nonce, + uuid: uuid, + subject: 'SomeIgnoredSubject') expect(current_path).to eq account_path @@ -68,6 +66,32 @@ def find_form(page, attributes) form = find_form(page, action: disable_piv_cac_url) expect(form).to be_nil end + + context 'when the user does not have a phone number yet' do + it 'prompts to set one up after configuring PIV/CAC' do + allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) + stub_piv_cac_service + + user.update(phone: nil, otp_secret_key: 'secret') + sign_in_and_2fa_user(user) + visit account_path + click_link t('forms.buttons.enable'), href: setup_piv_cac_url + + expect(page).to have_current_path(setup_piv_cac_path) + + nonce = get_piv_cac_nonce_from_link(find_link(t('forms.piv_cac_setup.submit'))) + visit_piv_cac_service(setup_piv_cac_url, + nonce: nonce, + uuid: SecureRandom.uuid, + subject: 'SomeIgnoredSubject') + + expect(page).to have_current_path(account_recovery_setup_path) + + configure_backup_phone + + expect(page).to have_current_path account_path + end + end end context 'with a service provider not allowed to use piv/cac' do @@ -75,7 +99,7 @@ def find_form(page, attributes) Identity.create( user_id: user.id, service_provider: 'http://localhost:3000', - last_authenticated_at: Time.now, + last_authenticated_at: Time.zone.now ) end diff --git a/spec/features/users/regenerate_personal_key_spec.rb b/spec/features/users/regenerate_personal_key_spec.rb index 0503a0367a1..cc66086bda4 100644 --- a/spec/features/users/regenerate_personal_key_spec.rb +++ b/spec/features/users/regenerate_personal_key_spec.rb @@ -145,7 +145,8 @@ def sign_up_and_view_personal_key allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) sign_up_and_set_password - fill_in 'Phone', with: '202-555-1212' + select_2fa_option('sms') + fill_in 'user_phone_form_phone', with: '202-555-1212' click_send_security_code click_submit_default end diff --git a/spec/features/users/sign_in_spec.rb b/spec/features/users/sign_in_spec.rb index 860fb3210e0..f954c9ccfae 100644 --- a/spec/features/users/sign_in_spec.rb +++ b/spec/features/users/sign_in_spec.rb @@ -251,7 +251,7 @@ rotate_attribute_encryption_key_with_invalid_queue expect { signin(email, password) }. - to raise_error Pii::EncryptionError, 'unable to decrypt attribute with any key' + to raise_error Encryption::EncryptionError, 'unable to decrypt attribute with any key' user = User.find_with_email(email) expect(user.encrypted_email).to eq encrypted_email @@ -403,6 +403,8 @@ it_behaves_like 'signing in as LOA3 with personal key', :oidc it_behaves_like 'signing in with wrong credentials', :saml it_behaves_like 'signing in with wrong credentials', :oidc + it_behaves_like 'signing with while PIV/CAC enabled but not phone enabled', :saml + it_behaves_like 'signing with while PIV/CAC enabled but not phone enabled', :oidc context 'user signs in with personal key, visits account page before viewing new key' do # this can happen if you submit the personal key form multiple times quickly @@ -429,4 +431,34 @@ expect(page.response_headers['Content-Security-Policy']). to(include('style-src \'self\'')) end + + context 'user is totp_enabled but not phone_enabled' do + before do + user = create(:user, :with_authentication_app) + signin(user.email, user.password) + end + + it 'requires 2FA before allowing access to phone setup form' do + visit phone_setup_path + + expect(page).to have_current_path login_two_factor_authenticator_path + end + + it 'does not redirect to phone setup form when visiting /login/two_factor/sms' do + visit login_two_factor_path(otp_delivery_preference: 'sms') + + expect(page).to have_current_path login_two_factor_authenticator_path + end + + it 'does not redirect to phone setup form when visiting /login/two_factor/voice' do + visit login_two_factor_path(otp_delivery_preference: 'voice') + + expect(page).to have_current_path login_two_factor_authenticator_path + end + + it 'does not display OTP Fallback text and links' do + expect(page). + to_not have_content t('devise.two_factor_authentication.totp_fallback.sms_link_text') + end + end end diff --git a/spec/features/users/sign_up_spec.rb b/spec/features/users/sign_up_spec.rb index 0103e135e9a..4d2ae4ccb76 100644 --- a/spec/features/users/sign_up_spec.rb +++ b/spec/features/users/sign_up_spec.rb @@ -55,7 +55,8 @@ allow(SmsOtpSenderJob).to receive(:perform_now).and_raise(twilio_error) sign_up_and_set_password - fill_in 'Phone', with: '202-555-1212' + select_2fa_option('sms') + fill_in 'user_phone_form_phone', with: '202-555-1212' click_send_security_code expect(current_path).to eq(phone_setup_path) @@ -81,7 +82,7 @@ click_on t('links.cancel') click_on t('sign_up.buttons.cancel') - expect(page).to have_current_path(sign_up_start_path) + expect(page).to have_current_path(sign_up_start_path(request_id: '123')) expect { User.find(user.id) }.to raise_error ActiveRecord::RecordNotFound end end @@ -154,25 +155,39 @@ it_behaves_like 'creating an account using authenticator app for 2FA', :saml it_behaves_like 'creating an account using authenticator app for 2FA', :oidc + it_behaves_like 'creating an account using PIV/CAC for 2FA', :saml + it_behaves_like 'creating an account using PIV/CAC for 2FA', :oidc + it 'allows a user to choose TOTP as 2FA method during sign up' do - user = create(:user) - sign_in_user(user) + sign_in_user set_up_2fa_with_authenticator_app click_acknowledge_personal_key expect(page).to have_current_path account_path end + it 'does not allow a user to choose piv/cac as 2FA method during sign up' do + allow(PivCacService).to receive(:piv_cac_available_for_agency?).and_return(false) + allow(FeatureManagement).to receive(:piv_cac_enabled?).and_return(true) + begin_sign_up_with_sp_and_loa(loa3: false) + + expect(page).to have_current_path two_factor_options_path + expect(page).not_to have_content( + t('devise.two_factor_authentication.two_factor_choice_options.piv_cac') + ) + end + it 'does not bypass 2FA when accessing authenticator_setup_path if the user is 2FA enabled' do user = create(:user, :signed_up) sign_in_user(user) visit authenticator_setup_path - expect(page).to have_current_path login_two_factor_path(otp_delivery_preference: 'sms', reauthn: false) + expect(page). + to have_current_path login_two_factor_path(otp_delivery_preference: 'sms', reauthn: false) end it 'prompts to sign in when accessing authenticator_setup_path before signing in' do - user = create(:user, :signed_up) + create(:user, :signed_up) visit authenticator_setup_path expect(page).to have_current_path root_path @@ -197,4 +212,34 @@ to(include('style-src \'self\' \'unsafe-inline\'')) end end + + describe 'user is partially authenticated and phone 2fa is not configured' do + context 'with piv/cac enabled' do + let(:user) do + create(:user, :with_piv_or_cac) + end + + before(:each) do + sign_in_user(user) + end + + scenario 'can not access phone_setup' do + expect(page).to have_current_path login_two_factor_piv_cac_path + visit phone_setup_path + expect(page).to have_current_path login_two_factor_piv_cac_path + end + + scenario 'can not access phone_setup via login/two_factor/sms' do + expect(page).to have_current_path login_two_factor_piv_cac_path + visit login_two_factor_path(otp_delivery_preference: :sms) + expect(page).to have_current_path login_two_factor_piv_cac_path + end + + scenario 'can not access phone_setup via login/two_factor/voice' do + expect(page).to have_current_path login_two_factor_piv_cac_path + visit login_two_factor_path(otp_delivery_preference: :voice) + expect(page).to have_current_path login_two_factor_piv_cac_path + end + end + end end diff --git a/spec/features/users/user_edit_spec.rb b/spec/features/users/user_edit_spec.rb index cc162c14cd1..86c012d5814 100644 --- a/spec/features/users/user_edit_spec.rb +++ b/spec/features/users/user_edit_spec.rb @@ -4,6 +4,8 @@ let(:user) { create(:user, :signed_up) } context 'editing email' do + let(:new_email) { 'new_email@test.com' } + before do sign_in_and_2fa_user(user) visit manage_email_path @@ -15,6 +17,16 @@ expect(page).to have_current_path manage_email_path end + + scenario 'user receives confirmation message at new address' do + fill_in 'Email', with: new_email + click_button 'Update' + + open_last_email + click_email_link_matching(/confirmation_token/) + + expect(page).to have_content(new_email) + end end context 'editing 2FA phone number' do @@ -31,7 +43,7 @@ end scenario 'user is able to submit with a Puerto Rico phone number as a US number', js: true do - fill_in 'Phone', with: '787 555-1234' + fill_in 'user_phone_form_phone', with: '787 555-1234' expect(page.find('#user_phone_form_international_code', visible: false).value).to eq 'PR' expect(page).to have_button(t('forms.buttons.submit.confirm_change'), disabled: false) @@ -41,7 +53,7 @@ allow(SmsOtpSenderJob).to receive(:perform_later) allow(VoiceOtpSenderJob).to receive(:perform_now) - fill_in 'Phone', with: '555-555-5000' + fill_in 'user_phone_form_phone', with: '555-555-5000' choose 'Phone call' click_button t('forms.buttons.submit.confirm_change') diff --git a/spec/features/visitors/email_confirmation_spec.rb b/spec/features/visitors/email_confirmation_spec.rb index add20a4df54..5084aabc1a4 100644 --- a/spec/features/visitors/email_confirmation_spec.rb +++ b/spec/features/visitors/email_confirmation_spec.rb @@ -17,7 +17,7 @@ fill_in 'password_form_password', with: Features::SessionHelper::VALID_PASSWORD click_button t('forms.buttons.continue') - expect(current_url).to eq phone_setup_url + expect(current_url).to eq two_factor_options_url expect(page).to_not have_content t('devise.confirmations.confirmed_but_must_set_password') end diff --git a/spec/features/visitors/password_recovery_spec.rb b/spec/features/visitors/password_recovery_spec.rb index 4a0db3975b1..3c4f9425cc0 100644 --- a/spec/features/visitors/password_recovery_spec.rb +++ b/spec/features/visitors/password_recovery_spec.rb @@ -50,7 +50,7 @@ fill_in_credentials_and_submit(user.email, 'NewVal!dPassw0rd') - expect(current_path).to eq phone_setup_path + expect(current_path).to eq two_factor_options_path end end @@ -87,7 +87,7 @@ it 'prompts user to set up their 2FA options after signing back in' do reset_password_and_sign_back_in(@user) - expect(current_path).to eq phone_setup_path + expect(current_path).to eq two_factor_options_path end end @@ -253,4 +253,29 @@ to(include('style-src \'self\' \'unsafe-inline\'')) end end + + context 'user resets password with unconfirmed email address edit' do + let(:original_email) { 'original_email@test.com' } + let(:new_email) { 'new_email@test.com' } + let(:user) { create(:user, :signed_up, email: original_email) } + + before do + sign_in_and_2fa_user(user) + visit manage_email_path + end + + it 'receives password reset message at original address' do + fill_in 'Email', with: new_email + click_button 'Update' + + expect(page).to have_content t('devise.registrations.email_update_needs_confirmation') + + visit sign_out_url + click_link t('links.passwords.forgot') + fill_in 'password_reset_email_form_email', with: original_email + click_button t('forms.buttons.continue') + + expect(open_last_email).to be_delivered_to(original_email) + end + end end diff --git a/spec/features/visitors/phone_confirmation_spec.rb b/spec/features/visitors/phone_confirmation_spec.rb index 6be62f7c9cd..c9e67734309 100644 --- a/spec/features/visitors/phone_confirmation_spec.rb +++ b/spec/features/visitors/phone_confirmation_spec.rb @@ -6,7 +6,8 @@ allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) allow(SmsOtpSenderJob).to receive(:perform_now) @user = sign_in_before_2fa - fill_in 'Phone', with: '555-555-5555' + select_2fa_option('sms') + fill_in 'user_phone_form_phone', with: '555-555-5555' click_send_security_code expect(SmsOtpSenderJob).to have_received(:perform_now).with( @@ -58,7 +59,8 @@ before do @existing_user = create(:user, :signed_up) @user = sign_in_before_2fa - fill_in 'Phone', with: @existing_user.phone + select_2fa_option('sms') + fill_in 'user_phone_form_phone', with: @existing_user.phone click_send_security_code end diff --git a/spec/forms/idv/jurisdiction_form_spec.rb b/spec/forms/idv/jurisdiction_form_spec.rb index 0b13d29868f..836758edd8e 100644 --- a/spec/forms/idv/jurisdiction_form_spec.rb +++ b/spec/forms/idv/jurisdiction_form_spec.rb @@ -9,7 +9,7 @@ describe '#submit' do context 'when the form is valid' do it 'returns a successful form response' do - result = subject.submit({ state: supported_jurisdiction }) + result = subject.submit(state: supported_jurisdiction) expect(result).to be_kind_of(FormResponse) expect(result.success?).to eq(true) @@ -19,7 +19,7 @@ context 'when the form is invalid' do it 'returns an unsuccessful form response' do - result = subject.submit({ state: unsupported_jurisdiction }) + result = subject.submit(state: unsupported_jurisdiction) expect(result).to be_kind_of(FormResponse) expect(result.success?).to eq(false) @@ -30,7 +30,7 @@ describe 'presence validations' do it 'is invalid when required attribute is not present' do - subject.submit({ state: nil }) + subject.submit(state: nil) expect(subject).to_not be_valid end @@ -38,7 +38,7 @@ describe 'jurisdiction validity' do it 'populates error for unsupported jurisdiction ' do - subject.submit({ state: unsupported_jurisdiction }) + subject.submit(state: unsupported_jurisdiction) expect(subject.valid?).to eq false expect(subject.errors[:state]).to eq [I18n.t('idv.errors.unsupported_jurisdiction')] end diff --git a/spec/forms/two_factor_options_form_spec.rb b/spec/forms/two_factor_options_form_spec.rb new file mode 100644 index 00000000000..44bba21f229 --- /dev/null +++ b/spec/forms/two_factor_options_form_spec.rb @@ -0,0 +1,55 @@ +require 'rails_helper' + +describe TwoFactorOptionsForm do + let(:user) { build(:user) } + subject { described_class.new(user) } + + describe '#submit' do + it 'is successful if the selection is valid' do + %w[voice sms auth_app piv_cac].each do |selection| + result = subject.submit(selection: selection) + + expect(result.success?).to eq true + end + end + + it 'is unsuccessful if the selection is invalid' do + result = subject.submit(selection: '!!!!') + + expect(result.success?).to eq false + expect(result.errors).to include :selection + end + + context "when the selection is different from the user's otp_delivery_preference" do + it "updates the user's otp_delivery_preference" do + user_updater = instance_double(UpdateUser) + allow(UpdateUser). + to receive(:new). + with( + user: user, + attributes: { otp_delivery_preference: 'voice' } + ). + and_return(user_updater) + expect(user_updater).to receive(:call) + + subject.submit(selection: 'voice') + end + end + + context "when the selection is the same as the user's otp_delivery_preference" do + it "does not update the user's otp_delivery_preference" do + expect(UpdateUser).to_not receive(:new) + + subject.submit(selection: 'sms') + end + end + + context 'when the selection is not voice or sms' do + it "does not update the user's otp_delivery_preference" do + expect(UpdateUser).to_not receive(:new) + + subject.submit(selection: 'auth_app') + end + end + end +end diff --git a/spec/forms/user_phone_form_spec.rb b/spec/forms/user_phone_form_spec.rb index 25139596550..67a72c864e2 100644 --- a/spec/forms/user_phone_form_spec.rb +++ b/spec/forms/user_phone_form_spec.rb @@ -43,7 +43,7 @@ expect(result.errors).to be_empty end - it 'include otp preference in the form response extra' do + it 'includes otp preference in the form response extra' do result = subject.submit(params) expect(result.extra).to eq( @@ -90,6 +90,87 @@ end end + context 'when otp_delivery_preference is not voice or sms' do + let(:params) do + { + phone: '703-555-1212', + international_code: 'US', + otp_delivery_preference: 'foo', + } + end + + it 'is invalid' do + result = subject.submit(params) + + expect(result.success?).to eq(false) + expect(result.errors[:otp_delivery_preference].first). + to eq 'is not included in the list' + end + end + + context 'when otp_delivery_preference is empty' do + let(:params) do + { + phone: '703-555-1212', + international_code: 'US', + otp_delivery_preference: '', + } + end + + it 'is invalid' do + result = subject.submit(params) + + expect(result.success?).to eq(false) + expect(result.errors[:otp_delivery_preference].first). + to eq 'is not included in the list' + end + end + + context 'when otp_delivery_preference param is not present' do + let(:params) do + { + phone: '703-555-1212', + international_code: 'US', + } + end + + it 'is valid' do + result = subject.submit(params) + + expect(result.success?).to eq(true) + end + end + + context "when the submitted otp_delivery_preference is different from the user's" do + it "updates the user's otp_delivery_preference" do + user_updater = instance_double(UpdateUser) + allow(UpdateUser). + to receive(:new). + with( + user: user, + attributes: { otp_delivery_preference: 'voice' } + ). + and_return(user_updater) + expect(user_updater).to receive(:call) + + params = { + phone: '555-555-5000', + international_code: 'US', + otp_delivery_preference: 'voice', + } + + subject.submit(params) + end + end + + context "when the submitted otp_delivery_preference is the same as the user's" do + it "does not update the user's otp_delivery_preference" do + expect(UpdateUser).to_not receive(:new) + + subject.submit(params) + end + end + it 'does not raise inclusion errors for Norwegian phone numbers' do # ref: https://github.com/18F/identity-private/issues/2392 params[:phone] = '21 11 11 11' diff --git a/spec/forms/user_piv_cac_setup_form_spec.rb b/spec/forms/user_piv_cac_setup_form_spec.rb index 1b6b8aea083..aa36dd2f055 100644 --- a/spec/forms/user_piv_cac_setup_form_spec.rb +++ b/spec/forms/user_piv_cac_setup_form_spec.rb @@ -19,7 +19,7 @@ { 'uuid' => x509_dn_uuid, 'subject' => 'x509-subject', - 'nonce' => nonce + 'nonce' => nonce, } end @@ -122,7 +122,7 @@ end context 'when token is missing' do - let(:token) { } + let(:token) {} it 'returns FormResponse with success: false' do result = instance_double(FormResponse) diff --git a/spec/forms/user_piv_cac_verification_form_spec.rb b/spec/forms/user_piv_cac_verification_form_spec.rb index 61ef993bc2d..684b4874f98 100644 --- a/spec/forms/user_piv_cac_verification_form_spec.rb +++ b/spec/forms/user_piv_cac_verification_form_spec.rb @@ -18,7 +18,7 @@ { 'uuid' => x509_dn_uuid, 'subject' => 'x509-subject', - 'nonce' => nonce + 'nonce' => nonce, } end @@ -96,7 +96,7 @@ end context 'when token is missing' do - let(:token) { } + let(:token) {} it 'returns FormResponse with success: false' do result = instance_double(FormResponse) diff --git a/spec/lib/cloudhsm_jwt_spec.rb b/spec/lib/cloudhsm_jwt_spec.rb index aa65f39c326..c271847e3b7 100644 --- a/spec/lib/cloudhsm_jwt_spec.rb +++ b/spec/lib/cloudhsm_jwt_spec.rb @@ -54,10 +54,14 @@ def mock_cloudhsm allow(MockSession).to receive(:login).and_return(true) allow(MockSession).to receive(:logout).and_return(true) allow(MockSession).to receive_message_chain(:find_objects, :first).and_return(true) - allow(MockSession).to receive(:sign) do |algorithm, key, input| - JWT::Algos::Rsa.sign(JWT::Signature::ToSign.new('RS256', input, RequestKeyManager.private_key)) + allow(MockSession).to receive(:sign) do |_algorithm, _key, input| + JWT::Algos::Rsa.sign( + JWT::Signature::ToSign.new('RS256', input, RequestKeyManager.private_key) + ) end - allow(SamlIdp).to receive_message_chain(:config, :pkcs11, :active_slots, :first, :open).and_yield(MockSession) + allow(SamlIdp). + to receive_message_chain(:config, :pkcs11, :active_slots, :first, :open). + and_yield(MockSession) allow(SamlIdp).to receive_message_chain(:config, :cloudhsm_pin).and_return(true) end end diff --git a/spec/lib/queue_config_spec.rb b/spec/lib/queue_config_spec.rb index 78cb1a3b4a1..6078057307c 100644 --- a/spec/lib/queue_config_spec.rb +++ b/spec/lib/queue_config_spec.rb @@ -4,9 +4,9 @@ describe '.choose_queue_adapter' do it 'raises ArgumentError given invalid choice' do expect(Figaro.env).to receive(:queue_adapter_weights).and_return('{"invalid": 1}') - expect { + expect do Upaya::QueueConfig.choose_queue_adapter - }.to raise_error(ArgumentError, /Unknown queue adapter/) + end.to raise_error(ArgumentError, /Unknown queue adapter/) end it 'handles sidekiq' do diff --git a/spec/lib/random_tools_spec.rb b/spec/lib/random_tools_spec.rb index 1bd9deb5361..108da33e5b3 100644 --- a/spec/lib/random_tools_spec.rb +++ b/spec/lib/random_tools_spec.rb @@ -3,9 +3,9 @@ RSpec.describe Upaya::RandomTools do describe '#random_weighted_sample' do it 'raises ArgumentError given empty choices' do - expect { + expect do Upaya::RandomTools.random_weighted_sample({}) - }.to raise_error(ArgumentError, /empty choices/) + end.to raise_error(ArgumentError, /empty choices/) end it 'handles equal weights -- 0' do @@ -39,21 +39,21 @@ end it 'rejects non-integer weights' do - expect { + expect do Upaya::RandomTools.random_weighted_sample(a: 1.5) - }.to raise_error(ArgumentError, /integer/) + end.to raise_error(ArgumentError, /integer/) end it 'rejects negative weights' do - expect { + expect do Upaya::RandomTools.random_weighted_sample(a: 10, b: -1) - }.to raise_error(ArgumentError, />= 0/) + end.to raise_error(ArgumentError, />= 0/) end it 'rejects weights sum to zero' do - expect { + expect do Upaya::RandomTools.random_weighted_sample(a: 0) - }.to raise_error(ArgumentError, /non-zero/) + end.to raise_error(ArgumentError, /non-zero/) end end end diff --git a/spec/models/otp_requests_tracker_spec.rb b/spec/models/otp_requests_tracker_spec.rb index 596f800a690..d4c59dfb49e 100644 --- a/spec/models/otp_requests_tracker_spec.rb +++ b/spec/models/otp_requests_tracker_spec.rb @@ -1,10 +1,10 @@ require 'rails_helper' describe OtpRequestsTracker do - describe '.find_or_create_with_phone' do - let(:phone) { '+1 703 555 1212' } - let(:phone_fingerprint) { Pii::Fingerprinter.fingerprint(phone) } + let(:phone) { '+1 703 555 1212' } + let(:phone_fingerprint) { Pii::Fingerprinter.fingerprint(phone) } + describe '.find_or_create_with_phone' do context 'match found' do it 'returns the existing record and does not change it' do OtpRequestsTracker.create( @@ -48,4 +48,26 @@ end end end + + describe '.atomic_increment' do + it 'updates otp_last_sent_at' do + old_ort = OtpRequestsTracker.create( + phone_fingerprint: phone_fingerprint, + otp_send_count: 3, + otp_last_sent_at: Time.zone.now - 1.hour + ) + new_ort = OtpRequestsTracker.atomic_increment(old_ort.id) + expect(new_ort.otp_last_sent_at).to be > old_ort.otp_last_sent_at + end + + it 'increments the otp_send_count' do + old_ort = OtpRequestsTracker.create( + phone_fingerprint: phone_fingerprint, + otp_send_count: 3, + otp_last_sent_at: Time.zone.now + ) + new_ort = OtpRequestsTracker.atomic_increment(old_ort.id) + expect(new_ort.otp_send_count - 1).to eq(old_ort.otp_send_count) + end + end end diff --git a/spec/models/profile_spec.rb b/spec/models/profile_spec.rb index 0e65db1dd67..a64f522c80c 100644 --- a/spec/models/profile_spec.rb +++ b/spec/models/profile_spec.rb @@ -12,7 +12,6 @@ last_name: 'Doe' ) end - #let(:user_access_key) { user.unlock_user_access_key(user.password) } it { is_expected.to belong_to(:user) } it { is_expected.to have_many(:usps_confirmation_codes).dependent(:destroy) } diff --git a/spec/models/remote_setting_spec.rb b/spec/models/remote_setting_spec.rb new file mode 100644 index 00000000000..d3dd53de2b9 --- /dev/null +++ b/spec/models/remote_setting_spec.rb @@ -0,0 +1,23 @@ +require 'rails_helper' + +describe RemoteSetting do + describe 'validations' do + it 'validates that our github repo is white listed' do + location = 'https://raw.githubusercontent.com/18F/identity-idp/master/config/agencies.yml' + valid_setting = RemoteSetting.create(name: 'agencies.yml', url: location, contents:'') + expect(valid_setting).to be_valid + end + + it 'validates that the login.gov static site is white listed' do + location = 'https://login.gov/agencies.yml' + valid_setting = RemoteSetting.create(name: 'agencies.yml', url: location, contents:'') + expect(valid_setting).to be_valid + end + + it 'does not accept http' do + location = 'http://login.gov/agencies.yml' + valid_setting = RemoteSetting.create(name: 'agencies.yml', url: location, contents:'') + expect(valid_setting).to_not be_valid + end + end +end diff --git a/spec/models/service_provider_spec.rb b/spec/models/service_provider_spec.rb index 0c3b01aa3ca..6fb3c2def22 100644 --- a/spec/models/service_provider_spec.rb +++ b/spec/models/service_provider_spec.rb @@ -1,6 +1,8 @@ require 'rails_helper' describe ServiceProvider do + let(:service_provider) { ServiceProvider.from_issuer('http://localhost:3000') } + describe 'validations' do it 'validates that all redirect_uris are absolute, parsable uris' do valid_sp = build(:service_provider, redirect_uris: ['http://foo.com']) @@ -28,26 +30,23 @@ describe '#issuer' do it 'returns the constructor value' do - sp = ServiceProvider.from_issuer('http://localhost:3000') - expect(sp.issuer).to eq 'http://localhost:3000' + expect(service_provider.issuer).to eq 'http://localhost:3000' end end describe '#from_issuer' do context 'the record exists' do it 'fetches the record' do - sp = ServiceProvider.from_issuer('http://localhost:3000') - - expect(sp).to be_a ServiceProvider - expect(sp.persisted?).to eq true + expect(service_provider).to be_a ServiceProvider + expect(service_provider.persisted?).to eq true end end context 'the record does not exist' do - it 'returns NullServiceProvider' do - sp = ServiceProvider.from_issuer('no-such-issuer') + let(:service_provider) { ServiceProvider.from_issuer('no-such-issuer') } - expect(sp).to be_a NullServiceProvider + it 'returns NullServiceProvider' do + expect(service_provider).to be_a NullServiceProvider end end end @@ -55,8 +54,6 @@ describe '#metadata' do context 'when the service provider is defined in the YAML' do it 'returns a hash with symbolized attributes from YAML plus fingerprint' do - service_provider = ServiceProvider.from_issuer('http://localhost:3000') - fingerprint = { fingerprint: '40808e52ef80f92e697149e058af95f898cefd9a54d0dc2416bd607c8f9891fa', } @@ -70,13 +67,35 @@ end end + describe 'piv_cac_available?' do + context 'when the service provider is with an enabled agency' do + it 'is truthy' do + allow(Figaro.env).to receive(:piv_cac_agencies).and_return( + [service_provider.agency].to_json + ) + PivCacService.send(:reset_piv_cac_avaialable_agencies) + + expect(service_provider.piv_cac_available?).to be_truthy + end + end + + context 'when the service provider agency is not enabled' do + it 'is falsey' do + allow(Figaro.env).to receive(:piv_cac_agencies).and_return( + [service_provider.agency + 'X'].to_json + ) + PivCacService.send(:reset_piv_cac_avaialable_agencies) + + expect(service_provider.piv_cac_available?).to be_falsey + end + end + end + describe '#encryption_opts' do context 'when responses are not encrypted' do it 'returns nil' do # block_encryption is set to 'none' for this SP - sp = ServiceProvider.from_issuer('http://localhost:3000') - - expect(sp.encryption_opts).to be_nil + expect(service_provider.encryption_opts).to be_nil end end @@ -113,9 +132,7 @@ context 'when the service provider is included in the list of authorized providers' do it 'returns true' do - sp = ServiceProvider.from_issuer('http://localhost:3000') - - expect(sp.approved?).to be true + expect(service_provider.approved?).to be true end end end @@ -131,28 +148,37 @@ context 'when the service provider is approved but not active' do it 'returns false' do - sp = ServiceProvider.from_issuer('http://localhost:3000') - sp.update(active: false) + service_provider.update(active: false) - expect(sp.live?).to be false + expect(service_provider.live?).to be false end end context 'when the service provider is active and approved' do it 'returns true' do - sp = ServiceProvider.from_issuer('http://localhost:3000') - - expect(sp.live?).to be true + expect(service_provider.live?).to be true end end context 'when the service provider is active but not approved' do it 'returns false' do - sp = ServiceProvider.from_issuer('http://localhost:3000') - sp.update(approved: false) + service_provider.update(approved: false) - expect(sp.live?).to be false + expect(service_provider.live?).to be false end end end + + describe '#ssl_cert' do + it 'returns the remote setting cert' do + WebMock.allow_net_connect! + sp = create(:service_provider, issuer: 'foo', cert: 'https://raw.githubusercontent.com/18F/identity-idp/master/certs/sp/saml_test_sp.crt') + expect(sp.ssl_cert.class).to be(OpenSSL::X509::Certificate) + end + + it 'returns the local cert' do + sp = create(:service_provider, issuer: 'foo', cert: 'saml_test_sp') + expect(sp.ssl_cert.class).to be(OpenSSL::X509::Certificate) + end + end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index a45d41b2901..ff4926dd9e9 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -324,7 +324,9 @@ describe 'deleting identities' do it 'does not delete identities when the user is destroyed preventing uuid reuse' do user = create(:user, :signed_up) - user.identities << Identity.create(service_provider: 'entity_id', session_uuid: SecureRandom.uuid) + user.identities << Identity.create( + service_provider: 'entity_id', session_uuid: SecureRandom.uuid + ) user_id = user.id user.destroy! expect(Identity.where(user_id: user_id).length).to eq 1 @@ -408,14 +410,14 @@ end context 'when a password is updated' do - it 'encrypted_password_digest is a json string of encryption parameters' do + it 'writes encrypted_password_digest and the legacy password attributes' do user = create(:user) expected = { - encryption_key: user.encryption_key, encrypted_password: user.encrypted_password, - password_cost: user.password_cost, + encryption_key: user.encryption_key, password_salt: user.password_salt, + password_cost: user.password_cost, }.to_json expect(user.encrypted_password_digest).to eq(expected) diff --git a/spec/presenters/failure_presenter_spec.rb b/spec/presenters/failure_presenter_spec.rb new file mode 100644 index 00000000000..60e49832eba --- /dev/null +++ b/spec/presenters/failure_presenter_spec.rb @@ -0,0 +1,45 @@ +require 'rails_helper' + +describe FailurePresenter do + let(:state) { :warning } + let(:presenter) { described_class.new(state) } + + describe '#state' do + subject { presenter.state } + + it { is_expected.to eq(state) } + end + + context 'methods with default values of `nil`' do + %i[message title header description].each do |method| + describe "##{method}" do + subject { presenter.send(method) } + + it { is_expected.to be_nil } + end + end + end + + describe '#next_steps' do + subject { presenter.next_steps } + + it { is_expected.to be_empty } + end + + context 'methods configured by state' do + %i[icon alt_text color].each do |method| + %i[warning failure locked].each do |state| + describe "##{method} for #{state}" do + let(:state) { state } + subject { presenter.send('state_' + method.to_s) } + + it { is_expected.to eq(config(state, method)) } + end + end + end + end + + def config(state, key) + described_class::STATE_CONFIG.dig(state, key) + end +end diff --git a/spec/presenters/max_attempts_reached_presenter_spec.rb b/spec/presenters/max_attempts_reached_presenter_spec.rb new file mode 100644 index 00000000000..8ff66f42ede --- /dev/null +++ b/spec/presenters/max_attempts_reached_presenter_spec.rb @@ -0,0 +1,63 @@ +require 'rails_helper' + +describe TwoFactorAuthCode::MaxAttemptsReachedPresenter do + let(:type) { 'otp_requests' } + let(:decorated_user) { mock_decorated_user } + let(:presenter) { described_class.new(type, decorated_user) } + + describe 'it uses the :locked failure state' do + subject { presenter.state } + + it { is_expected.to eq(:locked) } + end + + describe '#type' do + subject { presenter.type } + + it { is_expected.to eq(type) } + end + + describe '#decorated_user' do + subject { presenter.decorated_user } + + it { is_expected.to eq(decorated_user) } + end + + context 'methods are overriden' do + %i[message title header description js].each do |method| + describe "##{method}" do + subject { presenter.send(method) } + + it { is_expected.to_not be_nil } + end + end + end + + describe '#next_steps' do + subject { presenter.next_steps } + + it 'includes `please_try_again` and `read_about_two_factor_authentication`' do + expect(subject).to eq( + [ + presenter.send(:please_try_again), + presenter.send(:read_about_two_factor_authentication), + ] + ) + end + end + + describe '#please_try_again' do + subject { presenter.send(:please_try_again) } + + it 'includes time remaining' do + expect(subject).to include('1000 years') + end + end + + def mock_decorated_user + decorated_user = instance_double(UserDecorator) + allow(decorated_user).to receive(:lockout_time_remaining_in_words).and_return('1000 years') + allow(decorated_user).to receive(:lockout_time_remaining).and_return(10_000) + decorated_user + end +end diff --git a/spec/presenters/openid_connect_user_info_presenter_spec.rb b/spec/presenters/openid_connect_user_info_presenter_spec.rb index 83e1634ca8f..9b722fc34d4 100644 --- a/spec/presenters/openid_connect_user_info_presenter_spec.rb +++ b/spec/presenters/openid_connect_user_info_presenter_spec.rb @@ -29,7 +29,7 @@ context 'when a piv/cac was used as second factor' do let(:x509) do { - subject: x509_subject + subject: x509_subject, } end @@ -71,7 +71,6 @@ end end end - end context 'when there is decrypted loa3 session data in redis' do diff --git a/spec/presenters/piv_cac_authentication_setup_error_presenter_spec.rb b/spec/presenters/piv_cac_authentication_setup_error_presenter_spec.rb index 121595efaa6..b952156ed1b 100644 --- a/spec/presenters/piv_cac_authentication_setup_error_presenter_spec.rb +++ b/spec/presenters/piv_cac_authentication_setup_error_presenter_spec.rb @@ -37,13 +37,13 @@ end describe '#title' do - let(:expected_title) { t('titles.piv_cac_setup.' + error ) } + let(:expected_title) { t('titles.piv_cac_setup.' + error) } it { expect(presenter.title).to eq expected_title } end describe '#heading' do - let(:expected_heading) { t('headings.piv_cac_setup.' + error ) } + let(:expected_heading) { t('headings.piv_cac_setup.' + error) } it { expect(presenter.heading).to eq expected_heading } end diff --git a/spec/presenters/piv_cac_authentication_setup_presenter_spec.rb b/spec/presenters/piv_cac_authentication_setup_presenter_spec.rb index ecbb20ff8fa..b33c28cf626 100644 --- a/spec/presenters/piv_cac_authentication_setup_presenter_spec.rb +++ b/spec/presenters/piv_cac_authentication_setup_presenter_spec.rb @@ -3,18 +3,17 @@ describe PivCacAuthenticationSetupPresenter do let(:presenter) { described_class.new(form) } let(:form) do - OpenStruct.new( - ) + OpenStruct.new end describe '#title' do - let(:expected_title) { t('titles.piv_cac_setup.new' ) } + let(:expected_title) { t('titles.piv_cac_setup.new') } it { expect(presenter.title).to eq expected_title } end describe '#heading' do - let(:expected_heading) { t('headings.piv_cac_setup.new' ) } + let(:expected_heading) { t('headings.piv_cac_setup.new') } it { expect(presenter.heading).to eq expected_heading } end diff --git a/spec/presenters/two_factor_auth_code/piv_cac_authentication_presenter_spec.rb b/spec/presenters/two_factor_auth_code/piv_cac_authentication_presenter_spec.rb index 5ad624c5fef..615ab590732 100644 --- a/spec/presenters/two_factor_auth_code/piv_cac_authentication_presenter_spec.rb +++ b/spec/presenters/two_factor_auth_code/piv_cac_authentication_presenter_spec.rb @@ -9,7 +9,7 @@ def presenter_with(arguments = {}, view = ActionController::Base.new.view_contex end let(:user_email) { 'user@example.com' } - let(:reauthn) { } + let(:reauthn) {} let(:presenter) { presenter_with(reauthn: reauthn, user_email: user_email) } describe '#header' do @@ -35,8 +35,24 @@ def presenter_with(arguments = {}, view = ActionController::Base.new.view_contex end describe '#fallback_links' do - it 'has two options' do - expect(presenter.fallback_links.count).to eq 2 + context 'with phone enabled' do + let(:presenter) do + presenter_with(reauthn: reauthn, user_email: user_email, phone_enabled: true) + end + + it 'has two options' do + expect(presenter.fallback_links.count).to eq 2 + end + end + + context 'with phone disabled' do + let(:presenter) do + presenter_with(reauthn: reauthn, user_email: user_email, phone_enabled: false) + end + + it 'has one option' do + expect(presenter.fallback_links.count).to eq 1 + end end end diff --git a/spec/requests/rack_attack_spec.rb b/spec/requests/rack_attack_spec.rb index f2172ff52c5..8597ca7d97d 100644 --- a/spec/requests/rack_attack_spec.rb +++ b/spec/requests/rack_attack_spec.rb @@ -205,15 +205,15 @@ end end - context 'when the number of logins per email and ip is higher than the limit per period' do + context 'when number of logins per email + ip is higher than limit per period' do it 'throttles with a custom response' do analytics = instance_double(Analytics) allow(Analytics).to receive(:new).and_return(analytics) allow(analytics).to receive(:track_event) - (logins_per_email_and_ip_limit + 1).times do + (logins_per_email_and_ip_limit + 1).times do |index| post '/', params: { - user: { email: 'test@example.com' }, + user: { email: index.even? ? 'test@example.com' : ' test@EXAMPLE.com ' }, }, headers: { REMOTE_ADDR: '1.2.3.4' } end diff --git a/spec/services/agency_seeder_spec.rb b/spec/services/agency_seeder_spec.rb index 58ead03fb9d..a02cb3798bb 100644 --- a/spec/services/agency_seeder_spec.rb +++ b/spec/services/agency_seeder_spec.rb @@ -32,5 +32,25 @@ expect(Agency.find_by(id: 1).name).to eq('CBP') end end + + context 'when agencies.yml has a remote setting' do + before do + location = 'https://raw.githubusercontent.com/18F/identity-idp/master/config/agencies.yml' + RemoteSetting.create(name: 'agencies.yml', url:location, contents: "test:\n 1:\n name: 'CBP'") + end + + it 'updates the attributes based on the current value of the yml file' do + Agency.create(id: 1, name: 'FOO') + expect(Agency.find_by(id: 1).name).to eq('FOO') + run + expect(Agency.find_by(id: 1).name).to eq('CBP') + end + + it 'insert the attributes based on the contents of the remote setting' do + run + expect(Agency.find_by(id: 1).name).to eq('CBP') + expect(Agency.count).to eq(1) + end + end end end diff --git a/spec/services/encrypted_attribute_spec.rb b/spec/services/encrypted_attribute_spec.rb index 8b56ae499a9..bb627374916 100644 --- a/spec/services/encrypted_attribute_spec.rb +++ b/spec/services/encrypted_attribute_spec.rb @@ -29,7 +29,7 @@ rotate_attribute_encryption_key_with_invalid_queue expect { EncryptedAttribute.new(encrypted_with_old_key) }. - to raise_error Pii::EncryptionError, 'unable to decrypt attribute with any key' + to raise_error Encryption::EncryptionError, 'unable to decrypt attribute with any key' end end diff --git a/spec/services/pii/cipher_spec.rb b/spec/services/encryption/aes_cipher_spec.rb similarity index 87% rename from spec/services/pii/cipher_spec.rb rename to spec/services/encryption/aes_cipher_spec.rb index c0012f07051..6290996fcfd 100644 --- a/spec/services/pii/cipher_spec.rb +++ b/spec/services/encryption/aes_cipher_spec.rb @@ -1,8 +1,8 @@ require 'rails_helper' -describe Pii::Cipher do +describe Encryption::AesCipher do let(:plaintext) { 'some long secret' } - let(:cek) { SecureRandom.uuid } + let(:cek) { SecureRandom.random_bytes(32) } describe '#encrypt' do it 'returns JSON string containing AES-encrypted ciphertext' do @@ -25,7 +25,7 @@ ciphertext = subject.encrypt(plaintext, cek) ciphertext += 'foo' - expect { subject.decrypt(ciphertext, cek) }.to raise_error Pii::EncryptionError + expect { subject.decrypt(ciphertext, cek) }.to raise_error Encryption::EncryptionError end end end diff --git a/spec/services/pii/encryptor_spec.rb b/spec/services/encryption/encryptors/aes_encryptor_spec.rb similarity index 78% rename from spec/services/pii/encryptor_spec.rb rename to spec/services/encryption/encryptors/aes_encryptor_spec.rb index 554d64a3bed..b6142be0cfc 100644 --- a/spec/services/pii/encryptor_spec.rb +++ b/spec/services/encryption/encryptors/aes_encryptor_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' -describe Pii::Encryptor do - let(:aes_cek) { SecureRandom.uuid } +describe Encryption::Encryptors::AesEncryptor do + let(:aes_cek) { SecureRandom.random_bytes(32) } let(:plaintext) { 'four score and seven years ago' } describe '#encrypt' do @@ -21,9 +21,9 @@ it 'requires same password used for encrypt' do encrypted = subject.encrypt(plaintext, aes_cek) - diff_cek = aes_cek.tr('-', 'z') + diff_cek = SecureRandom.random_bytes(32) - expect { subject.decrypt(encrypted, diff_cek) }.to raise_error Pii::EncryptionError + expect { subject.decrypt(encrypted, diff_cek) }.to raise_error Encryption::EncryptionError end end end diff --git a/spec/services/encryption/encryptors/attribute_encryptor_spec.rb b/spec/services/encryption/encryptors/attribute_encryptor_spec.rb index 66610de3639..6906062a1bb 100644 --- a/spec/services/encryption/encryptors/attribute_encryptor_spec.rb +++ b/spec/services/encryption/encryptors/attribute_encryptor_spec.rb @@ -83,7 +83,7 @@ it 'raises and encryption error' do expect { subject.decrypt(ciphertext) }.to raise_error( - Pii::EncryptionError, 'unable to decrypt attribute with any key' + Encryption::EncryptionError, 'unable to decrypt attribute with any key' ) end end diff --git a/spec/services/encryption/encryptors/pii_encryptor_spec.rb b/spec/services/encryption/encryptors/pii_encryptor_spec.rb index 033b8be58a8..bc3d8440e2c 100644 --- a/spec/services/encryption/encryptors/pii_encryptor_spec.rb +++ b/spec/services/encryption/encryptors/pii_encryptor_spec.rb @@ -2,10 +2,9 @@ describe Encryption::Encryptors::PiiEncryptor do let(:password) { 'password' } - let(:salt) { 'n-pepa' } let(:plaintext) { 'Oooh baby baby' } - subject { described_class.new(password: password, salt: salt) } + subject { described_class.new(password) } describe '#encrypt' do it 'returns encrypted text' do @@ -15,14 +14,17 @@ end it 'uses the user access key encryptor to encrypt the plaintext' do + salt = '0' * 20 + allow(Devise).to receive(:friendly_token).and_return(salt) + scrypt_digest = '1' * 64 scrypt_password = instance_double(SCrypt::Password) expect(scrypt_password).to receive(:digest).and_return(scrypt_digest) expect(SCrypt::Password).to receive(:new).and_return(scrypt_password) - cipher = instance_double(Pii::Cipher) - expect(Pii::Cipher).to receive(:new).and_return(cipher) + cipher = instance_double(Encryption::AesCipher) + expect(Encryption::AesCipher).to receive(:new).and_return(cipher) expect(cipher).to receive(:encrypt). with(plaintext, scrypt_digest[0...32]). and_return('aes_ciphertext') @@ -37,7 +39,11 @@ ciphertext = subject.encrypt(plaintext) - expect(ciphertext).to eq(expected_ciphertext) + expect(ciphertext).to eq({ + encrypted_data: expected_ciphertext, + salt: salt, + cost: '800$8$1$', + }.to_json) end end @@ -51,12 +57,15 @@ it 'requires the same password used for encrypt' do ciphertext = subject.encrypt(plaintext) - new_encryptor = described_class.new(password: 'This is not the passowrd', salt: salt) + new_encryptor = described_class.new('This is not the passowrd') - expect { new_encryptor.decrypt(ciphertext) }.to raise_error Pii::EncryptionError + expect { new_encryptor.decrypt(ciphertext) }.to raise_error Encryption::EncryptionError end it 'uses layered AES and KMS to decrypt the contents' do + salt = '0' * 20 + allow(Devise).to receive(:friendly_token).and_return(salt) + scrypt_digest = '1' * 64 scrypt_password = instance_double(SCrypt::Password) @@ -69,13 +78,17 @@ with('kms_ciphertext'). and_return('aes_ciphertext') - cipher = instance_double(Pii::Cipher) - expect(Pii::Cipher).to receive(:new).and_return(cipher) + cipher = instance_double(Encryption::AesCipher) + expect(Encryption::AesCipher).to receive(:new).and_return(cipher) expect(cipher).to receive(:decrypt). with('aes_ciphertext', scrypt_digest[0...32]). and_return(plaintext) - result = subject.decrypt(Base64.strict_encode64('kms_ciphertext')) + result = subject.decrypt({ + encrypted_data: Base64.strict_encode64('kms_ciphertext'), + salt: salt, + cost: '800$8$1$', + }.to_json) expect(result).to eq(plaintext) end diff --git a/spec/services/encryption/encryptors/user_access_key_encryptor_spec.rb b/spec/services/encryption/encryptors/user_access_key_encryptor_spec.rb index 3f83443cbdd..c78776164d0 100644 --- a/spec/services/encryption/encryptors/user_access_key_encryptor_spec.rb +++ b/spec/services/encryption/encryptors/user_access_key_encryptor_spec.rb @@ -37,11 +37,11 @@ wrong_key = Encryption::UserAccessKey.new(password: 'This is not the password', salt: salt) new_encryptor = described_class.new(wrong_key) - expect { new_encryptor.decrypt(ciphertext) }.to raise_error Pii::EncryptionError + expect { new_encryptor.decrypt(ciphertext) }.to raise_error Encryption::EncryptionError end it 'raises an error if the ciphertext is not base64 encoded' do - expect { subject.decrypt('@@@@@@@') }.to raise_error Pii::EncryptionError + expect { subject.decrypt('@@@@@@@') }.to raise_error Encryption::EncryptionError end it 'only unlocks the user access key once' do @@ -57,20 +57,20 @@ end it 'can decrypt contents created by different user access keys if the password is the same' do - uak_1 = Encryption::UserAccessKey.new(password: password, salt: salt) - uak_2 = Encryption::UserAccessKey.new(password: password, salt: salt) - payload_1 = described_class.new(uak_1).encrypt(plaintext) - payload_2 = described_class.new(uak_2).encrypt(plaintext) + uak1 = Encryption::UserAccessKey.new(password: password, salt: salt) + uak2 = Encryption::UserAccessKey.new(password: password, salt: salt) + payload1 = described_class.new(uak1).encrypt(plaintext) + payload2 = described_class.new(uak2).encrypt(plaintext) - expect(payload_1).to_not eq(payload_2) + expect(payload1).to_not eq(payload2) expect(user_access_key).to receive(:unlock).twice.and_call_original - result_1 = subject.decrypt(payload_1) - result_2 = subject.decrypt(payload_2) + result1 = subject.decrypt(payload1) + result2 = subject.decrypt(payload2) - expect(result_1).to eq(plaintext) - expect(result_2).to eq(plaintext) + expect(result1).to eq(plaintext) + expect(result2).to eq(plaintext) end end end diff --git a/spec/services/encryption/kms_client_spec.rb b/spec/services/encryption/kms_client_spec.rb index 769deadcab6..29469b966de 100644 --- a/spec/services/encryption/kms_client_spec.rb +++ b/spec/services/encryption/kms_client_spec.rb @@ -11,14 +11,14 @@ before do allow(Figaro.env).to receive(:password_pepper).and_return(password_pepper) - encryptor = Pii::Encryptor.new + encryptor = Encryption::Encryptors::AesEncryptor.new allow(encryptor).to receive(:encrypt). with(local_plaintext, password_pepper). and_return(local_ciphertext) allow(encryptor).to receive(:decrypt). with(local_ciphertext, password_pepper). and_return(local_plaintext) - allow(Pii::Encryptor).to receive(:new).and_return(encryptor) + allow(Encryption::Encryptors::AesEncryptor).to receive(:new).and_return(encryptor) stub_aws_kms_client(kms_plaintext, kms_ciphertext) allow(FeatureManagement).to receive(:use_kms?).and_return(kms_enabled) diff --git a/spec/services/encryption/password_verifier_spec.rb b/spec/services/encryption/password_verifier_spec.rb new file mode 100644 index 00000000000..6fd9d0e6212 --- /dev/null +++ b/spec/services/encryption/password_verifier_spec.rb @@ -0,0 +1,44 @@ +require 'rails_helper' + +describe Encryption::PasswordVerifier do + describe '.digest' do + it 'creates a digest from the password' do + salt = '1' * 20 + allow(Devise).to receive(:friendly_token).and_return(salt) + + digest = described_class.digest('saltypickles') + + uak = Encryption::UserAccessKey.new(password: 'saltypickles', salt: salt) + uak.unlock(digest.encryption_key) + + expect(digest.encrypted_password).to eq(uak.encrypted_password) + expect(digest.encryption_key).to eq(uak.encryption_key) + expect(digest.password_salt).to eq(salt) + expect(digest.password_cost).to eq(uak.cost) + end + end + + describe '.verify' do + it 'returns true if the password matches' do + password = 'saltypickles' + + digest = described_class.digest(password).to_s + result = described_class.verify(password: password, digest: digest) + + expect(result).to eq(true) + end + + it 'returns false if the password does not match' do + digest = described_class.digest('saltypickles').to_s + result = described_class.verify(password: 'pepperpickles', digest: digest) + + expect(result).to eq(false) + end + + it 'returns false for nonsese' do + result = described_class.verify(password: 'saltypickles', digest: 'this is fake') + + expect(result).to eq(false) + end + end +end diff --git a/spec/services/idv/agent_spec.rb b/spec/services/idv/agent_spec.rb index 0d6e7944972..6a5ced0f146 100644 --- a/spec/services/idv/agent_spec.rb +++ b/spec/services/idv/agent_spec.rb @@ -86,7 +86,7 @@ errors: {}, messages: [resolution_message, state_id_message], success: true, - exception: nil, + exception: nil ) end end @@ -99,7 +99,7 @@ errors: { bad: ['stuff'] }, messages: [failed_message], success: false, - exception: nil, + exception: nil ) end end diff --git a/spec/services/idv/proofer_spec.rb b/spec/services/idv/proofer_spec.rb index 43a643254cf..93c0d841666 100644 --- a/spec/services/idv/proofer_spec.rb +++ b/spec/services/idv/proofer_spec.rb @@ -163,7 +163,7 @@ let(:vendors) { { bar: class_double('Proofer::Base') } } it 'does raises an error' do - expect { subject }.to raise_error("No proofer vendor configured for stage(s): foo") + expect { subject }.to raise_error('No proofer vendor configured for stage(s): foo') end end end @@ -200,20 +200,21 @@ before do expect(config).to receive(:mock_fallback).and_return(false) expect(config).to receive(:raise_on_missing_proofers).and_return(true) - expect(described_class).to receive(:loaded_vendors).and_return(loaded_vendors, loaded_vendors) + expect(described_class). + to receive(:loaded_vendors).and_return(loaded_vendors, loaded_vendors) end context 'when a stage is missing an external vendor' do let(:stages) { %i[foo baz] } it 'raises' do - expect { subject }.to raise_error("No proofer vendor configured for stage(s): baz") + expect { subject }.to raise_error('No proofer vendor configured for stage(s): baz') end end context 'when all stages have vendors' do it 'maps the vendors, ignoring non-configured ones' do - expect(subject).to eq({ foo: loaded_vendors.second }) + expect(subject).to eq(foo: loaded_vendors.second) end end end @@ -242,7 +243,8 @@ before do expect(config).to receive(:mock_fallback).and_return(false) expect(config).to receive(:raise_on_missing_proofers).and_return(false) - expect(described_class).to receive(:loaded_vendors).and_return(loaded_vendors, loaded_vendors) + expect(described_class). + to receive(:loaded_vendors).and_return(loaded_vendors, loaded_vendors) end context 'when a stage is missing an external vendor' do diff --git a/spec/services/otp_rate_limiter_spec.rb b/spec/services/otp_rate_limiter_spec.rb index f2e6b2eb349..481448a53ac 100644 --- a/spec/services/otp_rate_limiter_spec.rb +++ b/spec/services/otp_rate_limiter_spec.rb @@ -11,10 +11,10 @@ expect(otp_rate_limiter.exceeded_otp_send_limit?).to eq(false) end - it 'is true after maxretry_times attemps in findtime minutes' do + it 'is true after maxretry_times attemps +1 in findtime minutes' do expect(otp_rate_limiter.exceeded_otp_send_limit?).to eq(false) - Figaro.env.otp_delivery_blocklist_maxretry.to_i.times do + (Figaro.env.otp_delivery_blocklist_maxretry.to_i + 1).times do otp_rate_limiter.increment end diff --git a/spec/services/personal_key_generator_spec.rb b/spec/services/personal_key_generator_spec.rb index a66b7afd046..0740657cb84 100644 --- a/spec/services/personal_key_generator_spec.rb +++ b/spec/services/personal_key_generator_spec.rb @@ -40,6 +40,25 @@ def stub_random_phrase fourteen_letters_and_spaces_start_end_with_letter = /\A(\w+\-){13}\w+\z/ expect(generator.create).to match(fourteen_letters_and_spaces_start_end_with_letter) end + + it 'sets the encrypted recovery code digest' do + user = create(:user) + generator = PersonalKeyGenerator.new(user) + generator.create + + encrypted_recovery_code_data = JSON.parse( + user.encrypted_recovery_code_digest, symbolize_names: true + ) + + expect( + encrypted_recovery_code_data[:encryption_key] + ).to eq(user.personal_key.split('.').first) + expect( + encrypted_recovery_code_data[:encrypted_password] + ).to eq(user.personal_key.split('.').second) + expect(encrypted_recovery_code_data[:password_cost]).to eq(user.recovery_cost) + expect(encrypted_recovery_code_data[:password_salt]).to eq(user.recovery_salt) + end end describe '#verify' do diff --git a/spec/services/pii/attributes_spec.rb b/spec/services/pii/attributes_spec.rb index 3a0e0e78850..6baa45322ee 100644 --- a/spec/services/pii/attributes_spec.rb +++ b/spec/services/pii/attributes_spec.rb @@ -1,10 +1,7 @@ require 'rails_helper' describe Pii::Attributes do - # let(:user_access_key) { Encryption::UserAccessKey.new(password: 'sekrit', salt: SecureRandom.uuid) } let(:password) { 'I am the password' } - let(:salt) { 'I am the salt' } - let(:cost) { '800$8$1$' } describe '#new_from_hash' do it 'initializes from plain Hash' do @@ -34,20 +31,16 @@ describe '#new_from_encrypted' do it 'inflates from encrypted string' do orig_attrs = described_class.new_from_hash(first_name: 'Jane') - encrypted_pii = orig_attrs.encrypted(password: password, salt: salt, cost: cost) - pii_attrs = described_class.new_from_encrypted( - encrypted_pii, password: password, salt: salt, cost: cost - ) + encrypted_pii = orig_attrs.encrypted(password) + pii_attrs = described_class.new_from_encrypted(encrypted_pii, password: password) expect(pii_attrs.first_name).to eq 'Jane' end it 'allows deprecated attributes that are no longer added to the hash schema' do deprecated_atts = described_class.new_from_hash(otp: '123abc') - encrypted_pii = deprecated_atts.encrypted(password: password, salt: salt, cost: cost) - pii_attrs = described_class.new_from_encrypted( - encrypted_pii, password: password, salt: salt, cost: cost - ) + encrypted_pii = deprecated_atts.encrypted(password) + pii_attrs = described_class.new_from_encrypted(encrypted_pii, password: password) expect(pii_attrs[:otp]).to eq('123abc') end @@ -71,7 +64,7 @@ it 'returns the object as encrypted string' do pii_attrs = described_class.new_from_hash(first_name: 'Jane') - encrypted = pii_attrs.encrypted(password: password, salt: salt, cost: cost) + encrypted = pii_attrs.encrypted(password) expect(encrypted).to_not match 'Jane' end end diff --git a/spec/services/pii/cacher_spec.rb b/spec/services/pii/cacher_spec.rb index f0d9e8f94b3..36b0e0d3ca3 100644 --- a/spec/services/pii/cacher_spec.rb +++ b/spec/services/pii/cacher_spec.rb @@ -45,7 +45,6 @@ # Create a new user object to drop the memoized encrypted attributes user_id = user.id reloaded_user = User.find(user_id) - reloaded_profile = user.profiles.first described_class.new(reloaded_user, user_session).save(password) diff --git a/spec/services/pii/nist_encryption_spec.rb b/spec/services/pii/nist_encryption_spec.rb index e95190f7d58..2f50c5aaf1d 100644 --- a/spec/services/pii/nist_encryption_spec.rb +++ b/spec/services/pii/nist_encryption_spec.rb @@ -3,22 +3,22 @@ # duplicated code in order to explicitly show the algorithm at work. describe 'NIST Encryption Model' do -# Generate and store a 128-bit salt S. -# Z1, Z2 = scrypt(S, password) # split 256-bit output into two halves -# Generate random R. -# D = KMS_GCM_Encrypt(key=server_secret, plaintext=R) ^ Z1 -# E = hash( Z2 + R ) -# F = hash(E) -# C = GCM_Encrypt(key = E, plaintext=PII) #occurs outside AWS-KMS -# Store F in password file, and store C and D. -# -# To decrypt PII and to verify passwords: -# Compute Z1’, Z2’ = scrypt(S, password’) -# R’ = KMS_GCM_Decrypt(key=server_secret, ciphertext=(D ^ Z1*)). -# E’ = hash( Z2’ + R’) -# F’ = hash(E’) -# Check to see if F’ matches the entry in the password file; if so, allow the login. -# plaintext_PII = GCM_Decrypt(key=E’, ciphertext = C) + # Generate and store a 128-bit salt S. + # Z1, Z2 = scrypt(S, password) # split 256-bit output into two halves + # Generate random R. + # D = KMS_GCM_Encrypt(key=server_secret, plaintext=R) ^ Z1 + # E = hash( Z2 + R ) + # F = hash(E) + # C = GCM_Encrypt(key = E, plaintext=PII) #occurs outside AWS-KMS + # Store F in password file, and store C and D. + # + # To decrypt PII and to verify passwords: + # Compute Z1, Z2 = scrypt(S, password) + # R = KMS_GCM_Decrypt(key=server_secret, ciphertext=(D ^ Z1)). + # E = hash(Z2 + R) + # F = hash(E) + # Check to see if F matches the entry in the password file; if so, allow the login. + # plaintext_PII = GCM_Decrypt(key=E, ciphertext = C) before do allow(FeatureManagement).to receive(:use_kms?).and_return(true) @@ -74,24 +74,34 @@ user = create(:user, password: password) expect(user.valid_password?(password)).to eq true - expect(user.user_access_key).to be_a Encryption::UserAccessKey - expect(user.user_access_key.random_r).to eq random_R - expect(user.encryption_key).to_not be_nil - expect(user.password_salt).to_not be_nil - hash_E = OpenSSL::Digest::SHA256.hexdigest(user.user_access_key.z2 + random_R) - hash_F = OpenSSL::Digest::SHA256.hexdigest(user.user_access_key.cek) + digest = Encryption::PasswordVerifier::PasswordDigest.parse_from_string( + user.encrypted_password_digest + ) + user_access_key = Encryption::UserAccessKey.new( + password: password, + salt: digest.password_salt, + cost: digest.password_cost + ) + user_access_key.unlock(digest.encryption_key) - expect(user.encrypted_password).to eq hash_F - expect(user.user_access_key.cek).to eq hash_E - expect(user.user_access_key.encrypted_password).to eq hash_F + expect(user_access_key).to be_a Encryption::UserAccessKey + expect(user_access_key.random_r).to eq random_R + expect(digest.encryption_key).to_not be_nil + expect(digest.password_salt).to_not be_nil - user.user_access_key.unlock(user.encryption_key) - expect(user.user_access_key.cek).to eq(hash_E) + hash_E = OpenSSL::Digest::SHA256.hexdigest(user_access_key.z2 + random_R) + hash_F = OpenSSL::Digest::SHA256.hexdigest(user_access_key.cek) - encrypted_D = Base64.strict_decode64(user.encryption_key) + expect(digest.encrypted_password).to eq hash_F + expect(user_access_key.cek).to eq hash_E + expect(user_access_key.encrypted_password).to eq hash_F - expect(kms_prefix + xor(user.user_access_key.z1, ciphered_R)).to eq(encrypted_D) + expect(user_access_key.cek).to eq(hash_E) + + encrypted_D = Base64.strict_decode64(digest.encryption_key) + + expect(kms_prefix + xor(user_access_key.z1, ciphered_R)).to eq(encrypted_D) end end @@ -125,7 +135,7 @@ expect(Base64.strict_decode64(encrypted_key)).to eq(kms_prefix + encrypted_D) # unroll encrypted_C to verify it was encrypted with hash_E - cipher = Pii::Cipher.new + cipher = Encryption::AesCipher.new expect { cipher.decrypt(encrypted_C, hash_E) }.not_to raise_error @@ -140,7 +150,9 @@ end def open_envelope(envelope) - envelope.split(Pii::Encryptor::DELIMITER).map { |segment| Base64.strict_decode64(segment) } + envelope.split(Encryption::Encryptors::AesEncryptor::DELIMITER).map do |segment| + Base64.strict_decode64(segment) + end end def hex_to_bin(str) diff --git a/spec/services/piv_cac_service_spec.rb b/spec/services/piv_cac_service_spec.rb index 9032ec6aa52..78446c738f5 100644 --- a/spec/services/piv_cac_service_spec.rb +++ b/spec/services/piv_cac_service_spec.rb @@ -3,6 +3,27 @@ describe PivCacService do include Rails.application.routes.url_helpers + describe '#randomize_uri' do + let(:result) { PivCacService.send(:randomize_uri, uri) } + + context 'when a static URL is configured' do + let(:uri) { 'http://localhost:1234/' } + + it 'returns the URL unchanged' do + expect(result).to eq uri + end + end + + context 'when a random URL is configured' do + let(:uri) { 'http://{random}.example.com/' } + + it 'returns the URL with random bytes' do + expect(result).to_not eq uri + expect(result).to match(%r{http://[0-9a-f]+\.example\.com/$}) + end + end + end + describe '#decode_token' do context 'when configured for local development' do before(:each) do @@ -10,17 +31,17 @@ end it 'raises an error if no token provided' do - expect { + expect do PivCacService.decode_token - }.to raise_error ArgumentError + end.to raise_error ArgumentError end it 'returns the test data' do token = 'TEST:{"uuid":"hijackedUUID","dn":"hijackedDN"}' - expect(PivCacService.decode_token(token)).to eq({ + expect(PivCacService.decode_token(token)).to eq( 'uuid' => 'hijackedUUID', 'dn' => 'hijackedDN' - }) + ) end end @@ -30,7 +51,7 @@ end it 'returns an error' do - expect(PivCacService.decode_token('foo')).to eq({ 'error' => 'service.disabled' }) + expect(PivCacService.decode_token('foo')).to eq('error' => 'service.disabled') end end @@ -41,9 +62,9 @@ end it 'raises an error if no token provided' do - expect { + expect do PivCacService.decode_token - }.to raise_error ArgumentError + end.to raise_error ArgumentError end describe 'when configured with a user-facing endpoint' do @@ -83,7 +104,10 @@ let!(:request) do stub_request(:post, 'localhost:8443'). - with(body: 'token=foo'). + with( + body: 'token=foo', + headers: {'Authentication' => %r<^hmac\s+:.+:.+$>} + ). to_return( status: [200, 'Ok'], body: '{"dn":"dn","uuid":"uuid"}' @@ -96,18 +120,18 @@ end it 'returns the decoded JSON from the target service' do - expect(PivCacService.decode_token('foo')).to eq({ + expect(PivCacService.decode_token('foo')).to eq( 'dn' => 'dn', 'uuid' => 'uuid' - }) + ) end describe 'with test data' do it 'returns an error' do token = 'TEST:{"uuid":"hijackedUUID","dn":"hijackedDN"}' - expect(PivCacService.decode_token(token)).to eq({ + expect(PivCacService.decode_token(token)).to eq( 'error' => 'token.bad' - }) + ) end end end @@ -130,9 +154,9 @@ it 'returns an error' do token = 'foo' - expect(PivCacService.decode_token(token)).to eq({ + expect(PivCacService.decode_token(token)).to eq( 'error' => 'token.bad' - }) + ) end end end diff --git a/spec/services/remote_settings_service_spec.rb b/spec/services/remote_settings_service_spec.rb new file mode 100644 index 00000000000..5b499f88dc6 --- /dev/null +++ b/spec/services/remote_settings_service_spec.rb @@ -0,0 +1,89 @@ +require 'rails_helper' + +describe RemoteSettingsService do + subject(:service) { RemoteSettingsService } + before { + WebMock.allow_net_connect! + } + + describe '.load_yml_erb' do + it 'loads the remote location' do + location = 'https://raw.githubusercontent.com/18F/identity-idp/master/config/agencies.yml' + expect { service.load_yml_erb(location) }.to_not raise_error + end + + it 'raises an error if the location is not https://' do + location = 'agencies.yml' + expect do + RemoteSettingsService.load_yml_erb(location) + end.to raise_error(RuntimeError, "Location must begin with 'https://': #{location}") + end + + it 'raises an error if the file is not found' do + location = 'https://raw.githubusercontent.com/18F/identity-idp/master/config/agencies' + expect do + service.load_yml_erb(location) + end.to raise_error(RuntimeError, "Error retrieving: #{location}") + end + + it 'raises an error if the file is not a yml file' do + location = 'https://github.com/18F/identity-idp/blob/master/public/images/logo.svg' + expect do + service.load_yml_erb(location) + end.to raise_error(RuntimeError, "Error parsing yml file: #{location}") + end + end + + + describe '.load' do + it 'loads the remote location' do + expect do + service.load('https://github.com/18F/identity-idp/blob/master/public/images/logo.svg') + end.to_not raise_error + + end + + it 'raises an error if the location is not https://' do + location = 'agencies.yml' + expect do + RemoteSettingsService.load(location) + end.to raise_error(RuntimeError, "Location must begin with 'https://': #{location}") + end + + it 'raises an error if the file is not found' do + location = 'https://github.com/18F/identity-idp/blob/master/public/images/logo' + expect do + service.load(location) + end.to raise_error(RuntimeError, "Error retrieving: #{location}") + end + end + + describe '.update_setting' do + it 'it creates a setting if it does not exist' do + location = 'https://raw.githubusercontent.com/18F/identity-idp/master/config/agencies.yml' + service.update_setting('agencies.yml', location) + expect(RemoteSetting.find_by(name: 'agencies.yml').url).to eq(location) + end + + it 'it updates the setting if it exists' do + location = 'https://raw.githubusercontent.com/18F/identity-idp/master/config/agencies.yml' + agencies = 'agencies.yml' + service.update_setting(agencies, location) + location2 = 'https://raw.githubusercontent.com/18F/identity-idp/master/config/agencies.yml' + service.update_setting(agencies, location2) + expect(RemoteSetting.find_by(name: agencies).url).to eq(location2) + end + end + + describe '.remote?' do + it 'returns true if it is a remote location' do + location = 'https://raw.githubusercontent.com/18F/identity-idp/master/config/agencies.yml' + expect(subject.remote?(location)).to eq(true) + end + + it 'returns false if it is not a remote location' do + location = 'agencies.yml' + expect(subject.remote?(location)).to eq(false) + end + end +end diff --git a/spec/services/service_provider_seeder_spec.rb b/spec/services/service_provider_seeder_spec.rb index 72fcfce0211..a2990dd16b3 100644 --- a/spec/services/service_provider_seeder_spec.rb +++ b/spec/services/service_provider_seeder_spec.rb @@ -112,5 +112,25 @@ end end end + + context 'when service_providers.yml has a remote setting' do + before do + location = 'https://raw.githubusercontent.com/18F/identity-idp/master/config/service_providers.yml' + RemoteSetting.create(name: 'service_providers.yml', url:location, + contents: "test:\n 'issuer1':\n friendly_name: 'name1'") + end + + it 'updates the attributes based on the current value of the yml file' do + ServiceProvider.create(issuer: 'issuer1', friendly_name: 'FOO') + expect(ServiceProvider.find_by(issuer: 'issuer1').friendly_name).to eq('FOO') + run + expect(ServiceProvider.find_by(issuer: 'issuer1').friendly_name).to eq('name1') + end + + it 'insert the service_provider based on the contents of the remote setting' do + run + expect(ServiceProvider.find_by(issuer: 'issuer1').friendly_name).to eq('name1') + end + end end end diff --git a/spec/services/twilio_service_spec.rb b/spec/services/twilio_service_spec.rb index 4902f7b82e9..f1a72e9c66d 100644 --- a/spec/services/twilio_service_spec.rb +++ b/spec/services/twilio_service_spec.rb @@ -78,7 +78,8 @@ raw_message = 'Unable to create record: Account not authorized to call +123456789012.' error_code = '21215' status_code = 400 - sanitized_message = "[HTTP #{status_code}] #{error_code} : Unable to create record: Account " \ + sanitized_message = "[HTTP #{status_code}] #{error_code} : " \ + "Unable to create record: Account " \ "not authorized to call +12345#######.\n\n" service = TwilioService.new diff --git a/spec/services/x509/attribute_spec.rb b/spec/services/x509/attribute_spec.rb index f23062645b6..6dac3e9611f 100644 --- a/spec/services/x509/attribute_spec.rb +++ b/spec/services/x509/attribute_spec.rb @@ -4,8 +4,6 @@ let(:x509_subject) { 'O=US, OU=DoD, CN=John.Doe.1234' } subject { described_class.new(raw: x509_subject) } - - # rubocop:disable UnneededInterpolation describe 'delegation' do it 'delegates to raw' do expect(subject.blank?).to eq false @@ -15,5 +13,4 @@ expect(subject).to eq x509_subject end end - # rubocop:enable UnneededInterpolation end diff --git a/spec/services/x509/attributes_spec.rb b/spec/services/x509/attributes_spec.rb index f4b22cade00..80df613441a 100644 --- a/spec/services/x509/attributes_spec.rb +++ b/spec/services/x509/attributes_spec.rb @@ -12,7 +12,7 @@ it 'initializes from complex Hash' do x509 = described_class.new_from_hash( - subject: { raw: 'O=US, OU=DoD, CN=José', norm: 'O=US, OU=DoD, CN=Jose' }, + subject: { raw: 'O=US, OU=DoD, CN=José', norm: 'O=US, OU=DoD, CN=Jose' } ) expect(x509.subject.to_s).to eq 'O=US, OU=DoD, CN=José' diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb index 649b34e2dcd..a8205e5f44f 100644 --- a/spec/support/capybara.rb +++ b/spec/support/capybara.rb @@ -1,7 +1,7 @@ require 'capybara/rspec' require 'capybara-screenshot/rspec' require 'rack_session_access/capybara' -require "selenium/webdriver" +require 'selenium/webdriver' Capybara.register_driver :headless_chrome do |app| capabilities = Selenium::WebDriver::Remote::Capabilities.chrome( @@ -9,8 +9,8 @@ ) Capybara::Selenium::Driver.new app, - browser: :chrome, - desired_capabilities: capabilities + browser: :chrome, + desired_capabilities: capabilities end Capybara.javascript_driver = :headless_chrome diff --git a/spec/support/features/idv_helper.rb b/spec/support/features/idv_helper.rb index 40284773e87..74c5a877e9e 100644 --- a/spec/support/features/idv_helper.rb +++ b/spec/support/features/idv_helper.rb @@ -88,7 +88,7 @@ def click_idv_cancel click_on t('idv.buttons.cancel') end - def complete_idv_profile_ok(user, password = user_password) + def complete_idv_profile_ok(_user, password = user_password) fill_out_idv_form_ok click_idv_continue click_idv_continue diff --git a/spec/support/features/idv_step_helper.rb b/spec/support/features/idv_step_helper.rb index 2a3b1fd164f..fb6484a75a1 100644 --- a/spec/support/features/idv_step_helper.rb +++ b/spec/support/features/idv_step_helper.rb @@ -71,7 +71,9 @@ def complete_idv_steps_with_phone_before_confirmation_step(user = user_with_2fa) end alias complete_idv_steps_before_review_step complete_idv_steps_with_phone_before_review_step + # rubocop:disable Metrics/LineLength alias complete_idv_steps_before_confirmation_step complete_idv_steps_with_phone_before_confirmation_step + # rubocop:enable Metrics/LineLength def complete_idv_steps_with_usps_before_review_step(user = user_with_2fa) complete_idv_steps_before_usps_step(user) diff --git a/spec/support/features/session_helper.rb b/spec/support/features/session_helper.rb index 84a0b2c871f..96672d210e2 100644 --- a/spec/support/features/session_helper.rb +++ b/spec/support/features/session_helper.rb @@ -10,10 +10,16 @@ def sign_up_with(email) click_button t('forms.buttons.submit.default') end + def select_2fa_option(option) + find("label[for='two_factor_options_form_selection_#{option}']").click + click_on t('forms.buttons.continue') + end + def sign_up_and_2fa_loa1_user allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) user = sign_up_and_set_password - fill_in 'Phone', with: '202-555-1212' + select_2fa_option('sms') + fill_in 'user_phone_form_phone', with: '202-555-1212' click_send_security_code click_submit_default click_acknowledge_personal_key @@ -44,7 +50,7 @@ def begin_sign_up_with_sp_and_loa(loa3:) Warden.on_next_request do |proxy| session = proxy.env['rack.session'] sp = ServiceProvider.from_issuer('http://localhost:3000') - session[:sp] = { loa3: loa3, issuer: sp.issuer } + session[:sp] = { loa3: loa3, issuer: sp.issuer, request_id: '123' } end visit account_path @@ -101,9 +107,8 @@ def user_with_2fa def user_with_piv_cac create(:user, :signed_up, :with_piv_or_cac, - phone: '+1 (555) 555-0000', - password: VALID_PASSWORD - ) + phone: '+1 (555) 555-0000', + password: VALID_PASSWORD) end def confirm_last_user @@ -136,8 +141,8 @@ def sign_in_live_with_piv_cac(user = user_with_piv_cac) visit login_two_factor_piv_cac_path stub_piv_cac_service visit_piv_cac_service( - dn: "C=US, O=U.S. Government, OU=DoD, OU=PKI, CN=DOE.JOHN.1234", - uuid: user.x509_dn_uuid, + dn: 'C=US, O=U.S. Government, OU=DoD, OU=PKI, CN=DOE.JOHN.1234', + uuid: user.x509_dn_uuid ) end @@ -367,6 +372,7 @@ def submit_form_with_valid_password(password = VALID_PASSWORD) end def set_up_2fa_with_valid_phone + select_2fa_option('sms') fill_in 'user_phone_form[phone]', with: '202-555-1212' click_send_security_code end @@ -392,7 +398,7 @@ def register_user_with_authenticator_app(email = 'test@test.com') end def set_up_2fa_with_authenticator_app - click_link t('links.two_factor_authentication.app_option') + select_2fa_option('auth_app') expect(page).to have_current_path authenticator_setup_path @@ -401,6 +407,32 @@ def set_up_2fa_with_authenticator_app click_button 'Submit' end + def register_user_with_piv_cac(email = 'test@test.com') + allow(PivCacService).to receive(:piv_cac_available_for_agency?).and_return(true) + allow(FeatureManagement).to receive(:piv_cac_enabled?).and_return(true) + confirm_email_and_password(email) + + expect(page).to have_current_path two_factor_options_path + expect(page).to have_content( + t('devise.two_factor_authentication.two_factor_choice_options.piv_cac') + ) + + set_up_2fa_with_piv_cac + end + + def set_up_2fa_with_piv_cac + stub_piv_cac_service + select_2fa_option('piv_cac') + + expect(page).to have_current_path setup_piv_cac_path + + nonce = get_piv_cac_nonce_from_link(find_link(t('forms.piv_cac_setup.submit'))) + visit_piv_cac_service(setup_piv_cac_url, + nonce: nonce, + uuid: SecureRandom.uuid, + subject: 'SomeIgnoredSubject') + end + def sign_in_via_branded_page(user) allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) click_link t('links.sign_in') @@ -438,8 +470,16 @@ def visit_login_two_factor_piv_cac_and_get_nonce get_piv_cac_nonce_from_link(find_link(t('forms.piv_cac_mfa.submit'))) end + # This is a bit convoluted because we generate a nonce when we visit the + # link. The link provides a redirect to the piv/cac service with the nonce. + # This way, even if JavaScript fetches the link to grab the nonce, a new nonce + # is generated when the user clicks on the link. def get_piv_cac_nonce_from_link(link) - CGI.unescape(URI(link['href']).query.sub(/^nonce=/, '')) + go_back = current_path + visit link['href'] + nonce = CGI.unescape(URI(current_url).query.sub(/^nonce=/, '')) + visit go_back + nonce end def link_identity(user, client_id, ial = nil) @@ -450,5 +490,12 @@ def link_identity(user, client_id, ial = nil) ial: ial ) end + + def configure_backup_phone + select_2fa_option('sms') + fill_in 'user_phone_form_phone', with: '202-555-1212' + click_send_security_code + click_submit_default + end end end diff --git a/spec/support/idv_examples/failed_idv_job.rb b/spec/support/idv_examples/failed_idv_job.rb index 8355165469b..e9efa5bb055 100644 --- a/spec/support/idv_examples/failed_idv_job.rb +++ b/spec/support/idv_examples/failed_idv_job.rb @@ -50,7 +50,8 @@ fill_out_phone_form_ok('5202691958') if step == :phone click_idv_continue - Timecop.travel (Figaro.env.async_job_refresh_max_wait_seconds.to_i + 1).seconds + seconds_to_travel = (Figaro.env.async_job_refresh_max_wait_seconds.to_i + 1).seconds + Timecop.travel seconds_to_travel visit current_path end @@ -81,16 +82,18 @@ end end + # rubocop:disable Lint/HandleExceptions def stub_idv_job_to_raise_error_in_background(idv_job_class) allow(Idv::Agent).to receive(:new).and_raise('this is a test error') allow(idv_job_class).to receive(:perform_now).and_wrap_original do |perform_now, *args| begin perform_now.call(*args) - rescue StandardError => err + rescue StandardError # Swallow the error so it does not get re-raised by the job end end end + # rubocop:enable Lint/HandleExceptions def stub_idv_job_to_timeout_in_background(idv_job_class) allow(idv_job_class).to receive(:perform_now) diff --git a/spec/support/idv_examples/max_attempts.rb b/spec/support/idv_examples/max_attempts.rb index 22bd2b408ba..ffda4afb25f 100644 --- a/spec/support/idv_examples/max_attempts.rb +++ b/spec/support/idv_examples/max_attempts.rb @@ -8,70 +8,97 @@ before do start_idv_from_sp(sp) complete_idv_steps_before_step(step, user) - if step == :profile - perfom_maximum_allowed_idv_step_attempts { fill_out_idv_form_fail } - elsif step == :phone - perfom_maximum_allowed_idv_step_attempts { fill_out_phone_form_fail } - end end - scenario 'more than 3 attempts in 24 hours prevents further attempts' do - # Blocked if visiting verify directly - visit idv_url - advance_to_phone_step if step == :phone - expect_user_to_be_unable_to_perform_idv(sp) + context 'after completing the max number of attempts' do + before do + if step == :profile + perfom_maximum_allowed_idv_step_attempts { fill_out_idv_form_fail } + elsif step == :phone + perfom_maximum_allowed_idv_step_attempts { fill_out_phone_form_fail } + end + end - # Blocked if visiting from an SP - visit_idp_from_sp_with_loa3(:oidc) - advance_to_phone_step if step == :phone - expect_user_to_be_unable_to_perform_idv(sp) + scenario 'more than 3 attempts in 24 hours prevents further attempts' do + # Blocked if visiting verify directly + visit idv_url + advance_to_phone_step if step == :phone + expect_user_to_be_unable_to_perform_idv(sp) - if step == :sessions - user.reload + # Blocked if visiting from an SP + visit_idp_from_sp_with_loa3(:oidc) + advance_to_phone_step if step == :phone + expect_user_to_be_unable_to_perform_idv(sp) - expect(user.idv_attempted_at).to_not be_nil + if step == :sessions + user.reload + + expect(user.idv_attempted_at).to_not be_nil + end end - end - scenario 'after 24 hours the user can retry and complete idv' do - visit account_path - first(:link, t('links.sign_out')).click - reattempt_interval = (Figaro.env.idv_attempt_window_in_hours.to_i + 1).hours + scenario 'after 24 hours the user can retry and complete idv' do + visit account_path + first(:link, t('links.sign_out')).click + reattempt_interval = (Figaro.env.idv_attempt_window_in_hours.to_i + 1).hours - Timecop.travel reattempt_interval do - visit_idp_from_sp_with_loa3(:oidc) - click_link t('links.sign_in') - sign_in_live_with_2fa(user) + Timecop.travel reattempt_interval do + visit_idp_from_sp_with_loa3(:oidc) + click_link t('links.sign_in') + sign_in_live_with_2fa(user) - expect(page).to_not have_content(t("idv.modal.#{step_locale_key}.heading")) - expect(current_url).to eq(idv_jurisdiction_url) + expect(page).to_not have_content(t("idv.modal.#{step_locale_key}.heading")) + expect(current_url).to eq(idv_jurisdiction_url) - fill_out_idv_jurisdiction_ok - click_idv_continue - complete_idv_profile_ok(user) - click_acknowledge_personal_key - click_idv_continue + fill_out_idv_jurisdiction_ok + click_idv_continue + complete_idv_profile_ok(user) + click_acknowledge_personal_key + click_idv_continue - expect(current_url).to start_with('http://localhost:7654/auth/result') + expect(current_url).to start_with('http://localhost:7654/auth/result') + end end - end - scenario 'user sees failure flash message' do - expect(page).to have_css('.alert-error', text: t("idv.modal.#{step_locale_key}.heading")) - expect(page).to have_css( - '.alert-error', - text: strip_tags(t("idv.modal.#{step_locale_key}.fail")) - ) - end - - context 'with js', :js do - scenario 'user sees the failure modal' do - expect(page).to have_css('.modal-fail', text: t("idv.modal.#{step_locale_key}.heading")) + scenario 'user sees failure flash message' do + expect(page).to have_css('.alert-error', text: t("idv.modal.#{step_locale_key}.heading")) expect(page).to have_css( - '.modal-fail', + '.alert-error', text: strip_tags(t("idv.modal.#{step_locale_key}.fail")) ) end + + context 'with js', :js do + scenario 'user sees the failure modal' do + expect(page).to have_css('.modal-fail', text: t("idv.modal.#{step_locale_key}.heading")) + expect(page).to have_css( + '.modal-fail', + text: strip_tags(t("idv.modal.#{step_locale_key}.fail")) + ) + end + end + end + + context 'after completing one less than the max attempts' do + it 'allows the user to continue if their last attempt is successful' do + max_attempts_less_one.times do + fill_out_idv_form_fail if step == :profile + fill_out_phone_form_fail if step == :phone + click_continue + end + + fill_out_idv_form_ok if step == :profile + fill_out_phone_form_ok if step == :phone + click_continue + + if step == :profile + expect(page).to have_content(t('idv.titles.session.success')) + expect(page).to have_current_path(idv_session_success_path) + elsif step == :phone + expect(page).to have_content(t('idv.titles.otp_delivery_method')) + expect(page).to have_current_path(idv_otp_delivery_method_path) + end + end end def perfom_maximum_allowed_idv_step_attempts diff --git a/spec/support/shared_examples/account_creation.rb b/spec/support/shared_examples/account_creation.rb index 8c19d125035..afe342b0246 100644 --- a/spec/support/shared_examples/account_creation.rb +++ b/spec/support/shared_examples/account_creation.rb @@ -68,3 +68,36 @@ 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) + visit_idp_from_sp_with_loa1(sp) + register_user_with_piv_cac + + expect(page).to have_current_path(account_recovery_setup_path) + expect(page).to have_content t('instructions.account_recovery_setup.piv_cac_next_step') + + select_2fa_option('sms') + click_link t('devise.two_factor_authentication.two_factor_choice_cancel') + + expect(page).to have_current_path account_recovery_setup_path + + configure_backup_phone + click_acknowledge_personal_key + + if sp == :oidc + expect(page.response_headers['Content-Security-Policy']). + to(include('form-action \'self\' http://localhost:7654')) + end + + click_on t('forms.buttons.continue') + expect(current_url).to eq @saml_authn_request if sp == :saml + + if sp == :oidc + redirect_uri = URI(current_url) + + expect(redirect_uri.to_s).to start_with('http://localhost:7654/auth/result') + end + end +end diff --git a/spec/support/shared_examples/remember_device.rb b/spec/support/shared_examples/remember_device.rb index d45f13b64e3..25f1d717911 100644 --- a/spec/support/shared_examples/remember_device.rb +++ b/spec/support/shared_examples/remember_device.rb @@ -9,7 +9,8 @@ it 'requires 2FA on sign in after expiration' do user = remember_device_and_sign_out_user - Timecop.travel (Figaro.env.remember_device_expiration_days.to_i + 1).days.from_now do + days_to_travel = (Figaro.env.remember_device_expiration_days.to_i + 1).days.from_now + Timecop.travel days_to_travel do sign_in_user(user) expect(current_path).to eq(login_two_factor_path(otp_delivery_preference: :sms)) diff --git a/spec/support/shared_examples/sign_in.rb b/spec/support/shared_examples/sign_in.rb index 40631bdf3fe..6a89df88c95 100644 --- a/spec/support/shared_examples/sign_in.rb +++ b/spec/support/shared_examples/sign_in.rb @@ -177,6 +177,28 @@ end end +shared_examples 'signing with while PIV/CAC enabled but not phone enabled' do |sp| + 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) + visit_idp_from_sp_with_loa1(sp) + click_link t('links.sign_in') + fill_in_credentials_and_submit(user.email, user.password) + nonce = visit_login_two_factor_piv_cac_and_get_nonce + visit_piv_cac_service(login_two_factor_piv_cac_path, + uuid: user.x509_dn_uuid, + dn: 'C=US, O=U.S. Government, OU=DoD, OU=PKI, CN=DOE.JOHN.1234', + nonce: nonce) + + expect(current_path).to eq account_recovery_setup_path + + visit_idp_from_sp_with_loa1(sp) + + expect(current_path).to eq account_recovery_setup_path + end +end + def personal_key_for_loa3_user(user, pii) pii_attrs = Pii::Attributes.new_from_hash(pii) profile = user.profiles.last diff --git a/spec/views/accounts/show.html.slim_spec.rb b/spec/views/accounts/show.html.slim_spec.rb index 9be748282d7..d380e69ef7e 100644 --- a/spec/views/accounts/show.html.slim_spec.rb +++ b/spec/views/accounts/show.html.slim_spec.rb @@ -32,7 +32,7 @@ expect(rendered).to have_content t('account.items.delete_your_account', app: APP_NAME) expect(rendered). - to have_link(t('account.links.delete_account'), href: account_delete_path ) + to have_link(t('account.links.delete_account'), href: account_delete_path) end end diff --git a/spec/views/devise/passwords/new.html.slim_spec.rb b/spec/views/devise/passwords/new.html.slim_spec.rb index a2ed8630266..cc3521e89b0 100644 --- a/spec/views/devise/passwords/new.html.slim_spec.rb +++ b/spec/views/devise/passwords/new.html.slim_spec.rb @@ -9,6 +9,9 @@ return_to_sp_url: 'www.awesomeness.com' ) view_context = ActionController::Base.new.view_context + allow(view_context).to receive(:sign_up_start_url). + and_return('https://www.example.com/sign_up/start') + @decorated_session = DecoratedSession.new( sp: @sp, view_context: view_context, diff --git a/spec/views/idv/come_back_later/show.html.slim_spec.rb b/spec/views/idv/come_back_later/show.html.slim_spec.rb index 8936f8ac829..91559aa8fe7 100644 --- a/spec/views/idv/come_back_later/show.html.slim_spec.rb +++ b/spec/views/idv/come_back_later/show.html.slim_spec.rb @@ -24,8 +24,8 @@ render expect(rendered).to have_content( strip_tags(t( - 'idv.messages.come_back_later_sp_html', - sp: @decorated_session.sp_name + 'idv.messages.come_back_later_sp_html', + sp: @decorated_session.sp_name )) ) end @@ -59,8 +59,8 @@ render expect(rendered).to have_content( strip_tags(t( - 'idv.messages.come_back_later_no_sp_html', - app: APP_NAME + 'idv.messages.come_back_later_no_sp_html', + app: APP_NAME )) ) end diff --git a/spec/views/two_factor_authentication_setup/index.html.slim_spec.rb b/spec/views/phone_setup/index.html.slim_spec.rb similarity index 50% rename from spec/views/two_factor_authentication_setup/index.html.slim_spec.rb rename to spec/views/phone_setup/index.html.slim_spec.rb index 6cd6180aae2..94c1c5ebd31 100644 --- a/spec/views/two_factor_authentication_setup/index.html.slim_spec.rb +++ b/spec/views/phone_setup/index.html.slim_spec.rb @@ -1,17 +1,24 @@ require 'rails_helper' -describe 'users/two_factor_authentication_setup/index.html.slim' do +describe 'users/phone_setup/index.html.slim' do before do user = build_stubbed(:user) allow(view).to receive(:current_user).and_return(user) @user_phone_form = UserPhoneForm.new(user) - + @presenter = PhoneSetupPresenter.new('voice') render end it 'sets form autocomplete to off' do expect(rendered).to have_xpath("//form[@autocomplete='off']") end + + it 'renders a link to choose a different option' do + expect(rendered).to have_link( + t('devise.two_factor_authentication.two_factor_choice_cancel'), + href: two_factor_options_path + ) + end end diff --git a/spec/views/sign_up/registrations/new.html.slim_spec.rb b/spec/views/sign_up/registrations/new.html.slim_spec.rb index e4fbf730107..1f5dac50952 100644 --- a/spec/views/sign_up/registrations/new.html.slim_spec.rb +++ b/spec/views/sign_up/registrations/new.html.slim_spec.rb @@ -12,6 +12,7 @@ sp: nil, view_context: view_context, sp_session: {}, service_provider_request: nil ).call allow(view).to receive(:decorated_session).and_return(@decorated_session) + allow(view_context).to receive(:root_url).and_return('http://www.example.com') end it 'has a localized title' do @@ -54,4 +55,55 @@ expect(rendered).to have_selector('#recaptcha') end + + context 'when SAM is present' do + before do + @sp = build_stubbed( + :service_provider, + friendly_name: 'SAM', + return_to_sp_url: 'www.awesomeness.com' + ) + view_context = ActionController::Base.new.view_context + allow(view_context).to receive(:sign_up_start_url). + and_return('https://www.example.com/sign_up/start') + @decorated_session = DecoratedSession.new( + sp: @sp, + view_context: view_context, + sp_session: {}, + service_provider_request: ServiceProviderRequest.new + ).call + allow(view).to receive(:decorated_session).and_return(@decorated_session) + end + + it 'displays a custom alert message for SAM' do + render + + expect(rendered).to \ + have_content(t('service_providers.sam.create_account_page.body')) + end + + it 'has sp alert for the SAM service provider' do + @sp.friendly_name = 'SAM' + + render + + expect(rendered).to have_selector('.alert') + end + + it 'does not have an sp alert for the other service providers' do + @sp.friendly_name = 'other' + render + + expect(rendered).to_not have_selector('.alert') + end + end + + context 'when SP is not present' do + it 'does not display the branded content' do + render + + expect(rendered).not_to \ + have_content(t('service_providers.sam.create_account_page.body')) + end + end end diff --git a/spec/views/two_factor_authentication/shared/max_login_attempts_reached.html.erb_spec.rb b/spec/views/two_factor_authentication/shared/max_login_attempts_reached.html.erb_spec.rb deleted file mode 100644 index 7b3af201719..00000000000 --- a/spec/views/two_factor_authentication/shared/max_login_attempts_reached.html.erb_spec.rb +++ /dev/null @@ -1,21 +0,0 @@ -require 'rails_helper' - -describe 'two_factor_authentication/shared/max_login_attempts_reached.html.erb' do - context 'locked out account' do - it 'includes localized error message with time remaining' do - user_decorator = instance_double(UserDecorator) - allow(view).to receive(:decorator).and_return(user_decorator) - allow(view).to receive(:type).and_return('otp') - allow(user_decorator).to receive(:lockout_time_remaining_in_words).and_return('1000 years') - allow(user_decorator).to receive(:lockout_time_remaining).and_return(10_000) - - render - - expect(rendered).to include(t('titles.account_locked')) - expect(rendered).to include( - t('devise.two_factor_authentication.max_otp_login_attempts_reached') - ) - expect(rendered).to include('1000 years') - 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 1b7e4724516..adece4b916a 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 @@ -5,7 +5,8 @@ let(:presenter_data) do attributes_for(:generic_otp_presenter).merge( two_factor_authentication_method: 'authenticator', - user_email: view.current_user.email + user_email: view.current_user.email, + phone_enabled: user.phone_enabled? ) end diff --git a/spec/views/users/delete/show.html.slim_spec.rb b/spec/views/users/delete/show.html.slim_spec.rb index e2d39889709..5fb0bcbfa52 100644 --- a/spec/views/users/delete/show.html.slim_spec.rb +++ b/spec/views/users/delete/show.html.slim_spec.rb @@ -1,8 +1,8 @@ require 'rails_helper' describe 'users/delete/show.html.slim' do - let(:user) {build_stubbed(:user, :signed_up)} - let(:decorated_user) {user.decorate} + let(:user) { build_stubbed(:user, :signed_up) } + let(:decorated_user) { user.decorate } before do allow(user).to receive(:decorate).and_return(decorated_user) diff --git a/spec/views/users/phones/edit.html.slim_spec.rb b/spec/views/users/phones/edit.html.slim_spec.rb index 25e42529823..a18ee38b7ab 100644 --- a/spec/views/users/phones/edit.html.slim_spec.rb +++ b/spec/views/users/phones/edit.html.slim_spec.rb @@ -6,6 +6,7 @@ user = build_stubbed(:user, :signed_up) allow(view).to receive(:current_user).and_return(user) @user_phone_form = UserPhoneForm.new(user) + @presenter = PhoneSetupPresenter.new('voice') end it 'has a localized title' do diff --git a/spec/views/users/piv_cac_authentication_setup/new.html.slim_spec.rb b/spec/views/users/piv_cac_authentication_setup/new.html.slim_spec.rb new file mode 100644 index 00000000000..0077477a2d3 --- /dev/null +++ b/spec/views/users/piv_cac_authentication_setup/new.html.slim_spec.rb @@ -0,0 +1,32 @@ +require 'rails_helper' + +describe 'users/piv_cac_authentication_setup/new.html.slim' do + before { @presenter = OpenStruct.new(title: 'foo', heading: 'bar', description: 'foobar') } + + context 'user is fully authenticated' do + it 'renders a link to cancel and go back to the account page' do + user = build_stubbed(:user, :signed_up) + allow(view).to receive(:current_user).and_return(user) + allow(view).to receive(:user_fully_authenticated?).and_return(true) + + render + + expect(rendered).to have_link(t('links.cancel'), href: account_path) + end + end + + context 'user is setting up 2FA' do + it 'renders a link to choose a different option' do + user = build_stubbed(:user) + allow(view).to receive(:current_user).and_return(user) + allow(view).to receive(:user_fully_authenticated?).and_return(false) + + render + + expect(rendered).to have_link( + t('devise.two_factor_authentication.two_factor_choice_cancel'), + href: two_factor_options_path + ) + end + end +end diff --git a/spec/views/users/totp_setup/new.html.slim_spec.rb b/spec/views/users/totp_setup/new.html.slim_spec.rb index 91b2437fd8e..84fe4756f0e 100644 --- a/spec/views/users/totp_setup/new.html.slim_spec.rb +++ b/spec/views/users/totp_setup/new.html.slim_spec.rb @@ -3,21 +3,47 @@ describe 'users/totp_setup/new.html.slim' do let(:user) { build_stubbed(:user, :signed_up) } - before do - allow(view).to receive(:current_user).and_return(user) - @code = 'D4C2L47CVZ3JJHD7' - @qrcode = 'qrcode.png' - end + context 'user is fully authenticated' do + before do + allow(view).to receive(:current_user).and_return(user) + allow(view).to receive(:user_fully_authenticated?).and_return(true) + @code = 'D4C2L47CVZ3JJHD7' + @qrcode = 'qrcode.png' + end + + it 'renders the QR code' do + render + + expect(rendered).to have_css('#qr-code', text: 'D4C2L47CVZ3JJHD7') + end - it 'renders the QR code' do - render + it 'renders the QR code image' do + render - expect(rendered).to have_css('#qr-code', text: 'D4C2L47CVZ3JJHD7') + expect(rendered).to have_css('img[src^="/images/qrcode.png"]') + end + + it 'renders a link to cancel and go back to the account page' do + render + + expect(rendered).to have_link(t('links.cancel'), href: account_path) + end end - it 'renders the QR code image' do - render + context 'user is setting up 2FA' do + it 'renders a link to choose a different option' do + user = build_stubbed(:user) + allow(view).to receive(:current_user).and_return(user) + allow(view).to receive(:user_fully_authenticated?).and_return(false) + @code = 'D4C2L47CVZ3JJHD7' + @qrcode = 'qrcode.png' + + render - expect(rendered).to have_css('img[src^="/images/qrcode.png"]') + expect(rendered).to have_link( + t('devise.two_factor_authentication.two_factor_choice_cancel'), + href: two_factor_options_path + ) + end end end