From 030210c6b912f90d048a736fcec9daef0e1713cd Mon Sep 17 00:00:00 2001 From: James Smith Date: Mon, 27 Aug 2018 11:20:54 -0400 Subject: [PATCH 01/61] Make user_access_key_overrides fasterer **Why**: Fasterer is one of our linters to make sure we are writing efficient code. **How**: Rather than make a method that returns the value of an instance attribute, we use an attribute reader. --- app/models/concerns/user_access_key_overrides.rb | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/app/models/concerns/user_access_key_overrides.rb b/app/models/concerns/user_access_key_overrides.rb index cb604ad68e6..167939a8adc 100644 --- a/app/models/concerns/user_access_key_overrides.rb +++ b/app/models/concerns/user_access_key_overrides.rb @@ -5,6 +5,8 @@ module UserAccessKeyOverrides extend ActiveSupport::Concern + attr_reader :personal_key + def valid_password?(password) result = Encryption::PasswordVerifier.verify( password: password, @@ -27,10 +29,6 @@ def valid_personal_key?(normalized_personal_key) ) end - def personal_key - @personal_key - end - def personal_key=(new_personal_key) @personal_key = new_personal_key return if @personal_key.blank? From 1f961caa6b8a73b41a9ab877882397d6e26fc8d6 Mon Sep 17 00:00:00 2001 From: James Smith Date: Tue, 28 Aug 2018 13:54:50 -0400 Subject: [PATCH 02/61] Fix Idv::Proofer vendor initialization **Why**: We want to start with the right set of vendors for proofing. **How**: We initialize `@vendor` to `nil` rather than a truthy value. --- app/services/idv/proofer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/idv/proofer.rb b/app/services/idv/proofer.rb index 50eb6902904..af56cdb4763 100644 --- a/app/services/idv/proofer.rb +++ b/app/services/idv/proofer.rb @@ -11,7 +11,7 @@ module Proofer STAGES = %i[resolution state_id address].freeze - @vendors = {} + @vendors = nil class << self def attribute?(key) From 96f420f3dd7d94a2286e54cf4d2b0eafe6ca445a Mon Sep 17 00:00:00 2001 From: James Smith Date: Wed, 29 Aug 2018 09:12:19 -0400 Subject: [PATCH 03/61] Add nil phone_configuration to anonymous user **Why**: Sometimes, we have an anonymous user. They don't have any configured phones. **How**: Add a method that returns `nil` --- app/models/anonymous_user.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/models/anonymous_user.rb b/app/models/anonymous_user.rb index 83eab07e127..eb15aa74eba 100644 --- a/app/models/anonymous_user.rb +++ b/app/models/anonymous_user.rb @@ -7,6 +7,10 @@ def second_factor_locked_at nil end + def phone_configuration + nil + end + def phone nil end From 5e5103ebb4aea327ca8dec92f6aedd19c04ce902 Mon Sep 17 00:00:00 2001 From: James Smith Date: Wed, 29 Aug 2018 09:48:56 -0400 Subject: [PATCH 04/61] Run `bundle install` in devops repo when releasing **Why**: Sometimes our gem bundle isn't in sync when we fetch the most recent `master` of `identity-devops`. This fixes that. **How**: Run `bundle install` after fetching the most recent `master` of `identity-devops` --- bin/release | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bin/release b/bin/release index 76ccacbb58e..ed86df81b7d 100755 --- a/bin/release +++ b/bin/release @@ -88,6 +88,9 @@ def clone_identity_devops_repo run "mkdir login-dot-gov" Dir.chdir "#{ENV['HOME']}/login-dot-gov" do run "git clone git@github.com:18F/identity-devops.git" + Dir.chdir "identity-devops" do + run "bundle install" + end end end end From a549e0e660a543f87bd0d6b6eabf1ef84fab9f70 Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Mon, 27 Aug 2018 23:48:34 -0400 Subject: [PATCH 05/61] LG-597 Create WebAuthn Configurations Table **Why**: We want a table to hold configurations for webauthn as a second factor for users. **How**: Create a new table with a one to many from users. Store the public_key with each configuration and create a unique index on user_id + public_key --- app/models/user.rb | 1 + app/models/webauthn_configuration.rb | 7 +++++++ ...27225542_create_webauthn_configurations_table.rb | 13 +++++++++++++ db/schema.rb | 12 +++++++++++- spec/models/user_spec.rb | 1 + 5 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 app/models/webauthn_configuration.rb create mode 100644 db/migrate/20180827225542_create_webauthn_configurations_table.rb diff --git a/app/models/user.rb b/app/models/user.rb index b9357ebe893..f2cca7486c9 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -42,6 +42,7 @@ class User < ApplicationRecord has_many :events, dependent: :destroy has_one :account_reset_request, dependent: :destroy has_one :phone_configuration, dependent: :destroy, inverse_of: :user + has_many :webauthn_configurations, dependent: :destroy validates :x509_dn_uuid, uniqueness: true, allow_nil: true diff --git a/app/models/webauthn_configuration.rb b/app/models/webauthn_configuration.rb new file mode 100644 index 00000000000..8b69a5de172 --- /dev/null +++ b/app/models/webauthn_configuration.rb @@ -0,0 +1,7 @@ +class WebauthnConfiguration < ApplicationRecord + belongs_to :user, inverse_of: :webauthn_configuration + validates :user_id, presence: true + validates :name, presence: true + validates :credential_id, presence: true + validates :credential_public_key, presence: true +end diff --git a/db/migrate/20180827225542_create_webauthn_configurations_table.rb b/db/migrate/20180827225542_create_webauthn_configurations_table.rb new file mode 100644 index 00000000000..bf8be771e9e --- /dev/null +++ b/db/migrate/20180827225542_create_webauthn_configurations_table.rb @@ -0,0 +1,13 @@ +class CreateWebauthnConfigurationsTable < ActiveRecord::Migration[5.1] + def change + create_table :webauthn_configurations do |t| + t.references :user, null: false + t.string :name, null: false + t.text :credential_id, null: false + t.text :credential_public_key, null: false + t.timestamps + + t.index ['user_id'], name: "index_webauthn_configurations_on_user_id" + end + end +end diff --git a/db/schema.rb b/db/schema.rb index bf2f5a85943..c1412afb409 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20180728122856) do +ActiveRecord::Schema.define(version: 20180827225542) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -251,5 +251,15 @@ t.datetime "updated_at", null: false end + create_table "webauthn_configurations", force: :cascade do |t| + t.bigint "user_id", null: false + t.string "name", null: false + t.text "credential_id", null: false + t.text "credential_public_key", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_webauthn_configurations_on_user_id" + end + add_foreign_key "events", "users" end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index bd029347a3b..7adf3b2b53b 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -12,6 +12,7 @@ it { is_expected.to have_many(:events) } it { is_expected.to have_one(:account_reset_request) } it { is_expected.to have_one(:phone_configuration) } + it { is_expected.to have_many(:webauthn_configurations) } end it 'does not send an email when #create is called' do From b7db73ca64aa13e2f0036f526edda1cd25b2481b Mon Sep 17 00:00:00 2001 From: James Smith Date: Wed, 29 Aug 2018 15:42:57 -0400 Subject: [PATCH 06/61] Remove dup webauthn_configurations index creation **Why**: The migration fails because the `references` keyword creates an index, so we don't need to call the index creation independently. Doing so causes the migration to fail. **How**: Remove the offending line. --- .../20180827225542_create_webauthn_configurations_table.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/db/migrate/20180827225542_create_webauthn_configurations_table.rb b/db/migrate/20180827225542_create_webauthn_configurations_table.rb index bf8be771e9e..7d5cac532f5 100644 --- a/db/migrate/20180827225542_create_webauthn_configurations_table.rb +++ b/db/migrate/20180827225542_create_webauthn_configurations_table.rb @@ -6,8 +6,6 @@ def change t.text :credential_id, null: false t.text :credential_public_key, null: false t.timestamps - - t.index ['user_id'], name: "index_webauthn_configurations_on_user_id" end end end From 777d84c57a5fe23c895751e7470b94a05248cb12 Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Wed, 22 Aug 2018 11:27:45 -0400 Subject: [PATCH 07/61] Refactor AccountReset::DeleteAccountController **Why**: To make it more consistent with other controllers and ensure all desired analytics are properly captured. **How**: - Use service objects to validate the granted token, capture analytics, and process the account deletion - Fix the link to create a new account to point to the email sign up page instead of the home page - Remove unnecessary token param from `delete_account/show` view - Move localizations out of Devise and into the `errors` file - Update controller specs to test all analytics and before_action - Update feature spec to test that the deletion confirmation email is sent --- .../delete_account_controller.rb | 76 +++++------ app/models/account_reset_request.rb | 5 - app/models/anonymous_user.rb | 2 + app/services/account_reset/delete_account.rb | 41 ++++++ .../account_reset/validate_granted_token.rb | 27 ++++ .../account_reset/granted_token_validator.rb | 38 ++++++ .../confirm_delete_account/show.html.slim | 3 +- .../delete_account/show.html.slim | 2 +- config/locales/devise/en.yml | 2 - config/locales/devise/es.yml | 2 - config/locales/devise/fr.yml | 2 - config/locales/errors/en.yml | 6 + config/locales/errors/es.yml | 4 + config/locales/errors/fr.yml | 4 + .../delete_account_controller_spec.rb | 120 ++++++++++++++---- .../account_reset/delete_account_spec.rb | 5 + spec/models/account_reset_request_spec.rb | 21 --- .../show.html.slim_spec.rb | 2 +- 18 files changed, 258 insertions(+), 104 deletions(-) create mode 100644 app/services/account_reset/delete_account.rb create mode 100644 app/services/account_reset/validate_granted_token.rb create mode 100644 app/validators/account_reset/granted_token_validator.rb diff --git a/app/controllers/account_reset/delete_account_controller.rb b/app/controllers/account_reset/delete_account_controller.rb index 88614da8654..1719fd22d72 100644 --- a/app/controllers/account_reset/delete_account_controller.rb +++ b/app/controllers/account_reset/delete_account_controller.rb @@ -1,18 +1,30 @@ module AccountReset class DeleteAccountController < ApplicationController before_action :check_feature_enabled - before_action :prevent_parameter_leak, only: :show - before_action :check_granted_token - def show; end + def show + render :show and return unless token + + result = AccountReset::ValidateGrantedToken.new(token).call + analytics.track_event(Analytics::ACCOUNT_RESET, result.to_h) + + if result.success? + handle_valid_token + else + handle_invalid_token(result) + end + end def delete - user = @account_reset_request.user - analytics.track_event(Analytics::ACCOUNT_RESET, - event: :delete, token_valid: true, user_id: user.uuid) - email = reset_session_and_set_email(user) - UserMailer.account_reset_complete(email).deliver_later - redirect_to account_reset_confirm_delete_account_url + granted_token = session.delete(:granted_token) + result = AccountReset::DeleteAccount.new(granted_token).call + analytics.track_event(Analytics::ACCOUNT_RESET, result.to_h.except(:email)) + + if result.success? + handle_successful_deletion(result) + else + handle_invalid_token(result) + end end private @@ -21,48 +33,24 @@ def check_feature_enabled redirect_to root_url unless FeatureManagement.account_reset_enabled? end - def reset_session_and_set_email(user) - email = user.email - user.destroy! - sign_out - flash[:email] = email + def token + params[:token] end - def check_granted_token - @account_reset_request = AccountResetRequest.from_valid_granted_token(session[:granted_token]) - return if @account_reset_request - analytics.track_event(Analytics::ACCOUNT_RESET, event: :delete, token_valid: false) - redirect_to root_url + def handle_valid_token + session[:granted_token] = token + redirect_to url_for end - def prevent_parameter_leak - token = params[:token] - return if token.blank? - remove_token_from_url(token) - end - - def remove_token_from_url(token) - ar = AccountResetRequest.find_by(granted_token: token) - if ar&.granted_token_valid? - session[:granted_token] = token - redirect_to url_for - return - end - handle_expired_token(ar) if ar&.granted_token_expired? + def handle_invalid_token(result) + flash[:error] = result.errors[:token].first redirect_to root_url end - def handle_expired_token(ar) - analytics.track_event(Analytics::ACCOUNT_RESET, - event: :delete, - token_valid: true, - expired: true, - user_id: ar&.user&.uuid) - flash[:error] = link_expired - end - - def link_expired - t('devise.two_factor_authentication.account_reset.link_expired') + def handle_successful_deletion(result) + sign_out + flash[:email] = result.extra[:email] + redirect_to account_reset_confirm_delete_account_url end end end diff --git a/app/models/account_reset_request.rb b/app/models/account_reset_request.rb index 0f123999a2d..db304cee1e1 100644 --- a/app/models/account_reset_request.rb +++ b/app/models/account_reset_request.rb @@ -1,11 +1,6 @@ class AccountResetRequest < ApplicationRecord belongs_to :user - def self.from_valid_granted_token(granted_token) - account_reset = AccountResetRequest.find_by(granted_token: granted_token) - account_reset&.granted_token_valid? ? account_reset : nil - end - def granted_token_valid? granted_token.present? && !granted_token_expired? end diff --git a/app/models/anonymous_user.rb b/app/models/anonymous_user.rb index 83eab07e127..57fec648bee 100644 --- a/app/models/anonymous_user.rb +++ b/app/models/anonymous_user.rb @@ -10,4 +10,6 @@ def second_factor_locked_at def phone nil end + + def email; end end diff --git a/app/services/account_reset/delete_account.rb b/app/services/account_reset/delete_account.rb new file mode 100644 index 00000000000..81a95f6b126 --- /dev/null +++ b/app/services/account_reset/delete_account.rb @@ -0,0 +1,41 @@ +module AccountReset + class DeleteAccount + include ActiveModel::Model + include GrantedTokenValidator + + def initialize(token) + @token = token + end + + def call + @success = valid? + + if success + notify_user_via_email_of_deletion + destroy_user + end + + FormResponse.new(success: success, errors: errors.messages, extra: extra_analytics_attributes) + end + + private + + attr_reader :success + + def destroy_user + user.destroy! + end + + def notify_user_via_email_of_deletion + UserMailer.account_reset_complete(user.email).deliver_later + end + + def extra_analytics_attributes + { + user_id: user.uuid, + event: 'delete', + email: user.email, + } + end + end +end diff --git a/app/services/account_reset/validate_granted_token.rb b/app/services/account_reset/validate_granted_token.rb new file mode 100644 index 00000000000..385416032e7 --- /dev/null +++ b/app/services/account_reset/validate_granted_token.rb @@ -0,0 +1,27 @@ +module AccountReset + class ValidateGrantedToken + include ActiveModel::Model + include GrantedTokenValidator + + def initialize(token) + @token = token + end + + def call + @success = valid? + + FormResponse.new(success: success, errors: errors.messages, extra: extra_analytics_attributes) + end + + private + + attr_reader :success + + def extra_analytics_attributes + { + user_id: user.uuid, + event: 'granted token validation', + } + end + end +end diff --git a/app/validators/account_reset/granted_token_validator.rb b/app/validators/account_reset/granted_token_validator.rb new file mode 100644 index 00000000000..e60ad74064e --- /dev/null +++ b/app/validators/account_reset/granted_token_validator.rb @@ -0,0 +1,38 @@ +module AccountReset + module GrantedTokenValidator + extend ActiveSupport::Concern + + included do + validates :token, presence: { message: I18n.t('errors.account_reset.granted_token_missing') } + validate :token_exists, if: :token_present? + validate :token_not_expired, if: :token_present? + end + + private + + attr_reader :token + + def token_exists + return if account_reset_request + + errors.add(:token, I18n.t('errors.account_reset.granted_token_invalid')) + end + + def token_not_expired + return unless account_reset_request&.granted_token_expired? + errors.add(:token, I18n.t('errors.account_reset.granted_token_expired')) + end + + def token_present? + token.present? + end + + def account_reset_request + @account_reset_request ||= AccountResetRequest.find_by(granted_token: token) + end + + def user + account_reset_request&.user || AnonymousUser.new + end + end +end diff --git a/app/views/account_reset/confirm_delete_account/show.html.slim b/app/views/account_reset/confirm_delete_account/show.html.slim index 5a72212e4bb..f08b4c9c699 100644 --- a/app/views/account_reset/confirm_delete_account/show.html.slim +++ b/app/views/account_reset/confirm_delete_account/show.html.slim @@ -5,4 +5,5 @@ class: 'absolute top-n24 left-0 right-0 mx-auto') h3 = t('account_reset.confirm_delete_account.title') p == t('account_reset.confirm_delete_account.info', \ - email: email, link: link_to(t('account_reset.confirm_delete_account.link_text'), root_path)) + email: email, \ + link: link_to(t('account_reset.confirm_delete_account.link_text'), sign_up_email_path)) diff --git a/app/views/account_reset/delete_account/show.html.slim b/app/views/account_reset/delete_account/show.html.slim index 592457d5d52..0ad05055252 100644 --- a/app/views/account_reset/delete_account/show.html.slim +++ b/app/views/account_reset/delete_account/show.html.slim @@ -7,7 +7,7 @@ br h4.my2 = t('account_reset.delete_account.are_you_sure') = button_to t('account_reset.delete_account.delete_button'), \ - account_reset_delete_account_path(token: session[:granted_token]), method: :delete, \ + account_reset_delete_account_path, method: :delete, \ class: 'btn btn-red col-6 p2 rounded-lg border bw2 bg-lightest-red border-red border-box' br br diff --git a/config/locales/devise/en.yml b/config/locales/devise/en.yml index 2f25bd393b2..6deb87ec655 100644 --- a/config/locales/devise/en.yml +++ b/config/locales/devise/en.yml @@ -73,8 +73,6 @@ en: account_reset: cancel_link: Cancel your request link: deleting your account - link_expired: The link to delete your login.gov account has expired. Please - create another request to delete your account. pending_html: You currently have a pending request to delete your account. It takes 24 hours from the time you made the request to complete the process. Please check back later. %{cancel_link} diff --git a/config/locales/devise/es.yml b/config/locales/devise/es.yml index ab6d32c6982..f20a4377120 100644 --- a/config/locales/devise/es.yml +++ b/config/locales/devise/es.yml @@ -75,8 +75,6 @@ es: account_reset: cancel_link: Cancelar su solicitud link: eliminando su cuenta - link_expired: El enlace para eliminar su cuenta de login.gov ha caducado. - Crea otra solicitud para eliminar tu cuenta. pending_html: Actualmente tiene una solicitud pendiente para eliminar su cuenta. Se necesitan 24 horas desde el momento en que realizó la solicitud para completar el proceso. Por favor, vuelva más tarde. %{cancel_link} diff --git a/config/locales/devise/fr.yml b/config/locales/devise/fr.yml index 07d87cb707b..8c9695baae5 100644 --- a/config/locales/devise/fr.yml +++ b/config/locales/devise/fr.yml @@ -81,8 +81,6 @@ fr: account_reset: cancel_link: Annuler votre demande link: supprimer votre compte - link_expired: Le lien pour supprimer votre compte login.gov a expiré. Veuillez - créer une autre demande pour supprimer votre compte. pending_html: Vous avez actuellement une demande en attente pour supprimer votre compte. Il faut compter 24 heures à partir du moment où vous avez fait la demande pour terminer le processus. Veuillez vérifier plus tard. diff --git a/config/locales/errors/en.yml b/config/locales/errors/en.yml index 0ec588520c6..423bacf7616 100644 --- a/config/locales/errors/en.yml +++ b/config/locales/errors/en.yml @@ -4,6 +4,12 @@ en: account_reset: cancel_token_invalid: cancel token is invalid cancel_token_missing: cancel token is missing + granted_token_expired: The link to delete your login.gov account has expired. + Please create another request to delete your account. + granted_token_invalid: The link to delete your login.gov account is invalid. + Please try clicking the link in your email again. + granted_token_missing: The link to delete your login.gov account is invalid. + Please try clicking the link in your email again. confirm_password_incorrect: Incorrect password. invalid_authenticity_token: Oops, something went wrong. Please try again. invalid_totp: Invalid code. Please try again. diff --git a/config/locales/errors/es.yml b/config/locales/errors/es.yml index 303a54eaa22..ec1c8027781 100644 --- a/config/locales/errors/es.yml +++ b/config/locales/errors/es.yml @@ -4,6 +4,10 @@ es: account_reset: cancel_token_invalid: NOT TRANSLATED YET cancel_token_missing: NOT TRANSLATED YET + granted_token_expired: El enlace para eliminar su cuenta de login.gov ha caducado. + Crea otra solicitud para eliminar tu cuenta. + granted_token_invalid: NOT TRANSLATED YET + granted_token_missing: NOT TRANSLATED YET confirm_password_incorrect: La contraseña es incorrecta. invalid_authenticity_token: "¡Oops! Algo salió mal. Inténtelo de nuevo." invalid_totp: El código es inválido. Vuelva a intentarlo. diff --git a/config/locales/errors/fr.yml b/config/locales/errors/fr.yml index 038d0fd5486..c082243df38 100644 --- a/config/locales/errors/fr.yml +++ b/config/locales/errors/fr.yml @@ -4,6 +4,10 @@ fr: account_reset: cancel_token_invalid: NOT TRANSLATED YET cancel_token_missing: NOT TRANSLATED YET + granted_token_expired: Le lien pour supprimer votre compte login.gov a expiré. + Veuillez créer une autre demande pour supprimer votre compte. + granted_token_invalid: NOT TRANSLATED YET + granted_token_missing: NOT TRANSLATED YET confirm_password_incorrect: Mot de passe incorrect. invalid_authenticity_token: Oups, une erreur s'est produite. Veuillez essayer de nouveau. diff --git a/spec/controllers/account_reset/delete_account_controller_spec.rb b/spec/controllers/account_reset/delete_account_controller_spec.rb index d219b7ad611..2675a82b723 100644 --- a/spec/controllers/account_reset/delete_account_controller_spec.rb +++ b/spec/controllers/account_reset/delete_account_controller_spec.rb @@ -11,53 +11,104 @@ session[:granted_token] = AccountResetRequest.all[0].granted_token stub_analytics - expect(@analytics).to receive(:track_event). - with(Analytics::ACCOUNT_RESET, event: :delete, token_valid: true, user_id: user.uuid) + properties = { + user_id: user.uuid, + event: 'delete', + success: true, + errors: {}, + } + expect(@analytics). + to receive(:track_event).with(Analytics::ACCOUNT_RESET, properties) delete :delete + + expect(response).to redirect_to account_reset_confirm_delete_account_url end - it 'logs a bad token to the analytics' do + it 'redirects to root if the token does not match one in the DB' do + session[:granted_token] = 'foo' stub_analytics - expect(@analytics).to receive(:track_event). - with(Analytics::ACCOUNT_RESET, event: :delete, token_valid: false) + properties = { + user_id: 'anonymous-uuid', + event: 'delete', + success: false, + errors: { token: [t('errors.account_reset.granted_token_invalid')] }, + } + expect(@analytics). + to receive(:track_event).with(Analytics::ACCOUNT_RESET, properties) - delete :delete, params: { token: 'FOO' } + delete :delete + + expect(response).to redirect_to(root_url) + expect(flash[:error]).to eq t('errors.account_reset.granted_token_invalid') end - it 'redirects to root if there is no token' do + it 'displays a flash and redirects to root if the token is missing' do + stub_analytics + properties = { + user_id: 'anonymous-uuid', + event: 'delete', + success: false, + errors: { token: [t('errors.account_reset.granted_token_missing')] }, + } + expect(@analytics).to receive(:track_event). + with(Analytics::ACCOUNT_RESET, properties) + delete :delete expect(response).to redirect_to(root_url) + expect(flash[:error]).to eq t('errors.account_reset.granted_token_missing') end - end - describe '#show' do - it 'prevents parameter leak' do + it 'displays a flash and redirects to root if the token is expired' do user = create(:user) create_account_reset_request_for(user) AccountResetService.new(user).grant_request - get :show, params: { token: AccountResetRequest.all[0].granted_token } + stub_analytics + properties = { + user_id: user.uuid, + event: 'delete', + success: false, + errors: { token: [t('errors.account_reset.granted_token_expired')] }, + } + expect(@analytics).to receive(:track_event). + with(Analytics::ACCOUNT_RESET, properties) + + Timecop.travel(Time.zone.now + 2.days) do + session[:granted_token] = AccountResetRequest.all[0].granted_token + delete :delete + end - expect(response).to redirect_to(account_reset_delete_account_url) + expect(response).to redirect_to(root_url) + expect(flash[:error]).to eq t('errors.account_reset.granted_token_expired') end - it 'redirects to root if the token is bad' do - get :show, params: { token: 'FOO' } + it 'redirects to root if feature is not enabled' do + allow(FeatureManagement).to receive(:account_reset_enabled?).and_return(false) - expect(response).to redirect_to(root_url) + delete :delete + + expect(response).to redirect_to root_url end + end - it 'renders the page' do - user = create(:user) - create_account_reset_request_for(user) - AccountResetService.new(user).grant_request - session[:granted_token] = AccountResetRequest.all[0].granted_token + describe '#show' do + it 'redirects to root if the token does not match one in the DB' do + stub_analytics + properties = { + user_id: 'anonymous-uuid', + event: 'granted token validation', + success: false, + errors: { token: [t('errors.account_reset.granted_token_invalid')] }, + } + expect(@analytics). + to receive(:track_event).with(Analytics::ACCOUNT_RESET, properties) - get :show + get :show, params: { token: 'FOO' } - expect(response).to render_template(:show) + expect(response).to redirect_to(root_url) + expect(flash[:error]).to eq t('errors.account_reset.granted_token_invalid') end it 'displays a flash and redirects to root if the token is expired' do @@ -66,16 +117,35 @@ AccountResetService.new(user).grant_request stub_analytics + properties = { + user_id: user.uuid, + event: 'granted token validation', + success: false, + errors: { token: [t('errors.account_reset.granted_token_expired')] }, + } expect(@analytics).to receive(:track_event). - with(Analytics::ACCOUNT_RESET, - event: :delete, token_valid: true, expired: true, user_id: user.uuid) + with(Analytics::ACCOUNT_RESET, properties) Timecop.travel(Time.zone.now + 2.days) do get :show, params: { token: AccountResetRequest.all[0].granted_token } end expect(response).to redirect_to(root_url) - expect(flash[:error]).to eq t('devise.two_factor_authentication.account_reset.link_expired') + expect(flash[:error]).to eq t('errors.account_reset.granted_token_expired') + end + + it 'renders the show view if the token is missing' do + get :show + + expect(response).to render_template(:show) + end + + it 'redirects to root if feature is not enabled' do + allow(FeatureManagement).to receive(:account_reset_enabled?).and_return(false) + + get :show + + expect(response).to redirect_to root_url end end end diff --git a/spec/features/account_reset/delete_account_spec.rb b/spec/features/account_reset/delete_account_spec.rb index 7daa30108fe..6d260c2c291 100644 --- a/spec/features/account_reset/delete_account_spec.rb +++ b/spec/features/account_reset/delete_account_spec.rb @@ -44,6 +44,11 @@ ) expect(page).to have_current_path(account_reset_confirm_delete_account_path) expect(User.where(id: user.id)).to be_empty + expect(last_email.subject).to eq t('user_mailer.account_reset_complete.subject') + + click_link t('account_reset.confirm_delete_account.link_text') + + expect(page).to have_current_path(sign_up_email_path) end end end diff --git a/spec/models/account_reset_request_spec.rb b/spec/models/account_reset_request_spec.rb index 404c7259986..1e1f9bee061 100644 --- a/spec/models/account_reset_request_spec.rb +++ b/spec/models/account_reset_request_spec.rb @@ -50,25 +50,4 @@ expect(subject.granted_token_expired?).to eq(false) end end - - describe '.from_valid_granted_token' do - it 'returns nil if the token does not exist' do - expect(AccountResetRequest.from_valid_granted_token('123')).to eq(nil) - end - - it 'returns nil if the token is expired' do - granted_at = Time.zone.now - 7.days - AccountResetRequest.create(id: 1, user_id: 2, granted_token: '123', granted_at: granted_at) - - expect(AccountResetRequest.from_valid_granted_token('123')).to eq(nil) - end - - it 'returns the record if the token is valid' do - arr = AccountResetRequest.create( - id: 1, user_id: 2, granted_token: '123', granted_at: Time.zone.now - ) - - expect(AccountResetRequest.from_valid_granted_token('123')).to eq(arr) - end - end end diff --git a/spec/views/account_reset/confirm_delete_account/show.html.slim_spec.rb b/spec/views/account_reset/confirm_delete_account/show.html.slim_spec.rb index 795f8092ccd..3118353bb4f 100644 --- a/spec/views/account_reset/confirm_delete_account/show.html.slim_spec.rb +++ b/spec/views/account_reset/confirm_delete_account/show.html.slim_spec.rb @@ -25,7 +25,7 @@ expect(rendered).to have_link( t('account_reset.confirm_delete_account.link_text', app: APP_NAME), - href: root_path + href: sign_up_email_path ) end end From 2f01b0a91b10d5b7e9fc021c825ed8d2cdf80519 Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Thu, 30 Aug 2018 12:51:20 -0400 Subject: [PATCH 08/61] Take into account nil user in SmsLoginOptionPolicy **Why**: To prevent a 500 error when a user visits the `/account_reset/confirm_request` path while signed out --- app/policies/sms_login_option_policy.rb | 1 + .../account_reset/confirm_request_controller_spec.rb | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/app/policies/sms_login_option_policy.rb b/app/policies/sms_login_option_policy.rb index f038277f191..45611708dbd 100644 --- a/app/policies/sms_login_option_policy.rb +++ b/app/policies/sms_login_option_policy.rb @@ -4,6 +4,7 @@ def initialize(user) end def configured? + return false unless user user.phone_configuration.present? end diff --git a/spec/controllers/account_reset/confirm_request_controller_spec.rb b/spec/controllers/account_reset/confirm_request_controller_spec.rb index 4db995aa123..80225f41317 100644 --- a/spec/controllers/account_reset/confirm_request_controller_spec.rb +++ b/spec/controllers/account_reset/confirm_request_controller_spec.rb @@ -9,5 +9,14 @@ expect(response).to redirect_to(root_url) end end + + context 'email is present in flash' do + it 'renders the show template' do + allow(controller).to receive(:flash).and_return(email: 'test@test.com') + get :show + + expect(response).to render_template(:show) + end + end end end From 54c3a6e35784ef870b57e2fb452c460f2c03e194 Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Thu, 30 Aug 2018 13:45:42 -0400 Subject: [PATCH 09/61] Fix failure screens throwing 500 error with failure_to_proof_url --- app/decorators/service_provider_session_decorator.rb | 2 +- app/views/idv/shared/_failure_to_proof_url.html.slim | 9 --------- app/views/idv/shared/verification_failure.html.slim | 2 +- app/views/shared/_failure.html.slim | 2 +- app/views/shared/_failure_url.html.slim | 12 ++++++++++++ .../service_provider_session_decorator_spec.rb | 8 ++++++++ 6 files changed, 23 insertions(+), 12 deletions(-) delete mode 100644 app/views/idv/shared/_failure_to_proof_url.html.slim create mode 100644 app/views/shared/_failure_url.html.slim diff --git a/app/decorators/service_provider_session_decorator.rb b/app/decorators/service_provider_session_decorator.rb index 2d6e511ec95..c025283e6e2 100644 --- a/app/decorators/service_provider_session_decorator.rb +++ b/app/decorators/service_provider_session_decorator.rb @@ -92,7 +92,7 @@ def sp_agency end def sp_return_url - if sp.redirect_uris.present? && openid_connect_redirector.valid? + if sp.redirect_uris.present? && request_url.is_a?(String) && openid_connect_redirector.valid? openid_connect_redirector.decline_redirect_uri else sp.return_to_sp_url diff --git a/app/views/idv/shared/_failure_to_proof_url.html.slim b/app/views/idv/shared/_failure_to_proof_url.html.slim deleted file mode 100644 index 20691c38430..00000000000 --- a/app/views/idv/shared/_failure_to_proof_url.html.slim +++ /dev/null @@ -1,9 +0,0 @@ -- if decorated_session.sp_name - hr - .mb2.mt2 - .right = link_to image_tag(asset_url('carat-right.svg'), size: '10'), - decorated_session.failure_to_proof_url, class: 'bold block btn-link text-decoration-none' - = link_to t('idv.failure.help.get_help_html', sp_name: decorated_session.sp_name), - decorated_session.failure_to_proof_url, - class: 'block btn-link text-decoration-none' - hr diff --git a/app/views/idv/shared/verification_failure.html.slim b/app/views/idv/shared/verification_failure.html.slim index 72430f36e3f..3855b78af71 100644 --- a/app/views/idv/shared/verification_failure.html.slim +++ b/app/views/idv/shared/verification_failure.html.slim @@ -1,7 +1,7 @@ = render 'shared/failure', presenter: presenter p == presenter.warning_message -= render 'idv/shared/failure_to_proof_url', presenter: presenter += render 'shared/failure_url', presenter: presenter .mt3 = link_to presenter.button_text, presenter.button_path, class: 'btn btn-primary btn-link' diff --git a/app/views/shared/_failure.html.slim b/app/views/shared/_failure.html.slim index fec4ea47aa5..6fcfd44107c 100644 --- a/app/views/shared/_failure.html.slim +++ b/app/views/shared/_failure.html.slim @@ -12,7 +12,7 @@ p == presenter.description - if presenter.message.present? h2.h4.mb2.mt3.my0 = presenter.message - = render 'idv/shared/failure_to_proof_url', presenter: presenter + = render 'shared/failure_url', presenter: presenter - presenter.next_steps.each do |step| p == step diff --git a/app/views/shared/_failure_url.html.slim b/app/views/shared/_failure_url.html.slim new file mode 100644 index 00000000000..5b13e8d7bec --- /dev/null +++ b/app/views/shared/_failure_url.html.slim @@ -0,0 +1,12 @@ +- if decorated_session.sp_name + hr + .mb2.mt2 + .right = link_to image_tag(asset_url('carat-right.svg'), size: '10'), + decorated_session.failure_to_proof_url, class: 'bold block btn-link text-decoration-none' + - if request.path.starts_with?('/verify/') + = link_to t('idv.failure.help.get_help_html', sp_name: decorated_session.sp_name), + decorated_session.failure_to_proof_url, class: 'block btn-link text-decoration-none' + - elsif decorated_session.sp_return_url + = link_to t('links.back_to_sp', sp: decorated_session.sp_name), + decorated_session.sp_return_url, class: 'block btn-link text-decoration-none' + hr diff --git a/spec/decorators/service_provider_session_decorator_spec.rb b/spec/decorators/service_provider_session_decorator_spec.rb index b8b76baeaa6..d00c99bdcda 100644 --- a/spec/decorators/service_provider_session_decorator_spec.rb +++ b/spec/decorators/service_provider_session_decorator_spec.rb @@ -204,4 +204,12 @@ expect(subject.failure_to_proof_url).to eq url end end + + describe '#sp_return_url' do + it 'does not raise an error if request_url is nil' do + allow(subject).to receive(:request_url).and_return(nil) + allow(sp).to receive(:redirect_uris).and_return(['foo']) + subject.sp_return_url + end + end end From d9c61d058bc19915691c1a30652ca0b101ee4b66 Mon Sep 17 00:00:00 2001 From: James Smith Date: Fri, 31 Aug 2018 09:13:18 -0400 Subject: [PATCH 10/61] Catch no method error in formatted phone **Why**: A user can go from no phone to a phone, and this fixes an infrequent exception around that. **How**: Move the formatting to the phone configuration model for this case and only call it if we have a phone configuration. --- app/forms/user_phone_form.rb | 2 +- app/models/phone_configuration.rb | 4 ++++ spec/forms/user_phone_form_spec.rb | 13 +++++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/app/forms/user_phone_form.rb b/app/forms/user_phone_form.rb index 3f7edba07af..3acaa11a420 100644 --- a/app/forms/user_phone_form.rb +++ b/app/forms/user_phone_form.rb @@ -68,6 +68,6 @@ def update_otp_delivery_preference_for_user end def formatted_user_phone - Phonelib.parse(user.phone_configuration.phone).international + user.phone_configuration&.formatted_phone end end diff --git a/app/models/phone_configuration.rb b/app/models/phone_configuration.rb index 80448e82c15..dfd8007a0cb 100644 --- a/app/models/phone_configuration.rb +++ b/app/models/phone_configuration.rb @@ -8,4 +8,8 @@ class PhoneConfiguration < ApplicationRecord encrypted_attribute(name: :phone) enum delivery_preference: { sms: 0, voice: 1 } + + def formatted_phone + Phonelib.parse(phone).international + end end diff --git a/spec/forms/user_phone_form_spec.rb b/spec/forms/user_phone_form_spec.rb index b64bd59bd11..157ee5b7dc1 100644 --- a/spec/forms/user_phone_form_spec.rb +++ b/spec/forms/user_phone_form_spec.rb @@ -217,5 +217,18 @@ expect(subject.phone_changed?).to eq(false) end + + context 'when a user has no phone' do + it 'returns true' do + user.phone_configuration.destroy + user.update!(phone: nil) + user.reload + + params[:phone] = '+1 504 444 1643' + subject.submit(params) + + expect(subject.phone_changed?).to eq(true) + end + end end end From 887dd56b9230579d578e64f2fe94770e0a2ebdb6 Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Fri, 31 Aug 2018 12:25:58 -0400 Subject: [PATCH 11/61] Allow full exception logs for users without phone **Why**: So the exception notifier doesn't raise an error and to allow the full log to be generated. --- app/views/exception_notifier/_session.text.erb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/views/exception_notifier/_session.text.erb b/app/views/exception_notifier/_session.text.erb index 082443ef447..6b6db940d9b 100644 --- a/app/views/exception_notifier/_session.text.erb +++ b/app/views/exception_notifier/_session.text.erb @@ -18,6 +18,8 @@ Session: <%= session %> <% user = @kontroller.analytics_user || AnonymousUser.new %> User UUID: <%= user.uuid %> -User's Country (based on phone): <%= Phonelib.parse(user.phone_configuration.phone).country %> +<% if user.phone_configuration %> + User's Country (based on phone): <%= Phonelib.parse(user.phone_configuration.phone).country %> +<% end %> Visitor ID: <%= @request.cookies['ahoy_visitor'] %> From 6ec132d5cfe8d571400e4ce4bd7fdb75ef95c928 Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Thu, 30 Aug 2018 12:41:57 -0400 Subject: [PATCH 12/61] Remove unused personal_key method **Why**: I originally created this PR to address a performance issue flagged by the `fasterer` gem, which said to use `attr_reader` instead of an ivar for `personal_key`, but then I noticed that we're not even using that method except for in some specs, so I removed it and updated the specs to use `encrypted_recovery_code_digest` instead. --- app/models/concerns/user_access_key_overrides.rb | 9 ++------- .../personal_key_verification_controller_spec.rb | 14 ++++++++------ .../sign_in_via_personal_key_spec.rb | 12 +++++------- spec/forms/personal_key_form_spec.rb | 6 +++--- spec/models/profile_spec.rb | 10 +++++----- 5 files changed, 23 insertions(+), 28 deletions(-) diff --git a/app/models/concerns/user_access_key_overrides.rb b/app/models/concerns/user_access_key_overrides.rb index cb604ad68e6..fdf0fa55e64 100644 --- a/app/models/concerns/user_access_key_overrides.rb +++ b/app/models/concerns/user_access_key_overrides.rb @@ -27,14 +27,9 @@ def valid_personal_key?(normalized_personal_key) ) end - def personal_key - @personal_key - end - def personal_key=(new_personal_key) - @personal_key = new_personal_key - return if @personal_key.blank? - self.encrypted_recovery_code_digest = Encryption::PasswordVerifier.digest(@personal_key) + return if new_personal_key.blank? + self.encrypted_recovery_code_digest = Encryption::PasswordVerifier.digest(new_personal_key) end # This is a devise method, which we are overriding. This should not be removed diff --git a/spec/controllers/two_factor_authentication/personal_key_verification_controller_spec.rb b/spec/controllers/two_factor_authentication/personal_key_verification_controller_spec.rb index 3615ee49fb1..9d1de9b92c0 100644 --- a/spec/controllers/two_factor_authentication/personal_key_verification_controller_spec.rb +++ b/spec/controllers/two_factor_authentication/personal_key_verification_controller_spec.rb @@ -65,13 +65,14 @@ it 'generates a new personal key after the user signs in with their old one' do user = create(:user) - old_key = PersonalKeyGenerator.new(user).create + raw_key = PersonalKeyGenerator.new(user).create + old_key = user.reload.encrypted_recovery_code_digest stub_sign_in_before_2fa(user) - post :create, params: { personal_key_form: { personal_key: old_key } } + post :create, params: { personal_key_form: { personal_key: raw_key } } user.reload - expect(user.personal_key).to_not be_nil - expect(user.personal_key).to_not eq old_key + expect(user.encrypted_recovery_code_digest).to_not be_nil + expect(user.encrypted_recovery_code_digest).to_not eq old_key end context 'when the personal key field is empty' do @@ -140,12 +141,13 @@ end it 'does not generate a new personal key if the user enters an invalid key' do - user = create(:user, personal_key: 'ABCD-EFGH-IJKL-MNOP') + user = create(:user, :with_personal_key) + old_key = user.reload.encrypted_recovery_code_digest stub_sign_in_before_2fa(user) post :create, params: payload user.reload - expect(user.personal_key).to eq 'ABCD-EFGH-IJKL-MNOP' + expect(user.encrypted_recovery_code_digest).to eq old_key end end end diff --git a/spec/features/two_factor_authentication/sign_in_via_personal_key_spec.rb b/spec/features/two_factor_authentication/sign_in_via_personal_key_spec.rb index 9d0d05793a0..fa68d848a1a 100644 --- a/spec/features/two_factor_authentication/sign_in_via_personal_key_spec.rb +++ b/spec/features/two_factor_authentication/sign_in_via_personal_key_spec.rb @@ -3,18 +3,16 @@ feature 'Signing in via one-time use personal key' do it 'destroys old key, displays new one, and redirects to profile after acknowledging' do user = create(:user, :signed_up) - sign_in_before_2fa(user) - - personal_key = PersonalKeyGenerator.new(user).create + raw_key = PersonalKeyGenerator.new(user).create + old_key = user.reload.encrypted_recovery_code_digest + sign_in_before_2fa(user) choose_another_security_option('personal_key') - - enter_personal_key(personal_key: personal_key) - + enter_personal_key(personal_key: raw_key) click_submit_default click_acknowledge_personal_key - expect(user.reload.personal_key).to_not eq personal_key + expect(user.reload.encrypted_recovery_code_digest).to_not eq old_key expect(current_path).to eq account_path end diff --git a/spec/forms/personal_key_form_spec.rb b/spec/forms/personal_key_form_spec.rb index 6398f43997d..83120fc7bb9 100644 --- a/spec/forms/personal_key_form_spec.rb +++ b/spec/forms/personal_key_form_spec.rb @@ -6,7 +6,7 @@ it 'returns FormResponse with success: true' do user = create(:user) raw_code = PersonalKeyGenerator.new(user).create - old_key = user.reload.personal_key + old_code = user.reload.encrypted_recovery_code_digest form = PersonalKeyForm.new(user, raw_code) result = instance_double(FormResponse) @@ -15,7 +15,7 @@ expect(FormResponse).to receive(:new). with(success: true, errors: {}, extra: extra).and_return(result) expect(form.submit).to eq result - expect(user.reload.personal_key).to eq old_key + expect(user.reload.encrypted_recovery_code_digest).to eq old_code end end @@ -31,7 +31,7 @@ expect(FormResponse).to receive(:new). with(success: false, errors: errors, extra: extra).and_return(result) expect(form.submit).to eq result - expect(user.personal_key).to_not be_nil + expect(user.encrypted_recovery_code_digest).to_not be_nil expect(form.personal_key).to be_nil end end diff --git a/spec/models/profile_spec.rb b/spec/models/profile_spec.rb index a64f522c80c..35524caa210 100644 --- a/spec/models/profile_spec.rb +++ b/spec/models/profile_spec.rb @@ -30,12 +30,12 @@ it 'generates new personal key' do expect(profile.encrypted_pii_recovery).to be_nil - initial_personal_key = user.personal_key + initial_personal_key = user.encrypted_recovery_code_digest profile.encrypt_pii(pii, user.password) expect(profile.encrypted_pii_recovery).to_not be_nil - expect(user.personal_key).to_not eq initial_personal_key + expect(user.reload.encrypted_recovery_code_digest).to_not eq initial_personal_key end end @@ -43,13 +43,13 @@ it 'generates new personal key' do expect(profile.encrypted_pii_recovery).to be_nil - initial_personal_key = user.personal_key + initial_personal_key = user.encrypted_recovery_code_digest profile.encrypt_recovery_pii(pii) expect(profile.encrypted_pii_recovery).to_not be_nil - expect(user.personal_key).to_not eq initial_personal_key - expect(profile.personal_key).to_not eq user.personal_key + expect(user.reload.encrypted_recovery_code_digest).to_not eq initial_personal_key + expect(profile.personal_key).to_not eq user.encrypted_recovery_code_digest end end From 70e5abde7432164d36c555d1b0c3f87c0ab00c7e Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Tue, 4 Sep 2018 10:43:01 -0400 Subject: [PATCH 13/61] Set up a TOTP user for local development **Why**: To make it easier and faster to test with users who use an authentication app only. **How**: - Update dev:rake task to create a TOTP user - Prefill the TOTP code for that user --- .../totp_verification_controller.rb | 2 ++ .../totp_verification/show.html.slim | 2 +- lib/tasks/dev.rake | 13 +++++++++++++ spec/factories/users.rb | 2 +- spec/lib/tasks/dev_rake_spec.rb | 2 +- 5 files changed, 18 insertions(+), 3 deletions(-) diff --git a/app/controllers/two_factor_authentication/totp_verification_controller.rb b/app/controllers/two_factor_authentication/totp_verification_controller.rb index 60fe755c1c8..d13dfc4fec0 100644 --- a/app/controllers/two_factor_authentication/totp_verification_controller.rb +++ b/app/controllers/two_factor_authentication/totp_verification_controller.rb @@ -6,6 +6,8 @@ class TotpVerificationController < ApplicationController def show @presenter = presenter_for_two_factor_authentication_method + return unless FeatureManagement.prefill_otp_codes? + @code = ROTP::TOTP.new(current_user.otp_secret_key).now end def create diff --git a/app/views/two_factor_authentication/totp_verification/show.html.slim b/app/views/two_factor_authentication/totp_verification/show.html.slim index 57c41fa030e..4acee106973 100644 --- a/app/views/two_factor_authentication/totp_verification/show.html.slim +++ b/app/views/two_factor_authentication/totp_verification/show.html.slim @@ -7,7 +7,7 @@ h1.h3.my0 = @presenter.header = label_tag 'code', t('simple_form.required.html') + t('forms.two_factor.code'), class: 'block bold' .col-12.sm-col-5.mb4.sm-mb0.sm-mr-20p.inline-block - = text_field_tag :code, '', required: true, autofocus: true, + = text_field_tag :code, '', value: @code, required: true, autofocus: true, pattern: '[0-9]*', class: 'col-12 field monospace mfa', type: 'tel', 'aria-describedby': 'code-instructs', maxlength: Devise.otp_length, autocomplete: 'off' = submit_tag 'Submit', class: 'btn btn-primary align-top' diff --git a/lib/tasks/dev.rake b/lib/tasks/dev.rake index 82873dce3bc..a1c212a71f2 100644 --- a/lib/tasks/dev.rake +++ b/lib/tasks/dev.rake @@ -9,6 +9,11 @@ namespace :dev do end end + ee = EncryptedAttribute.new_from_decrypted('totp@test.com') + User.find_or_create_by!(email_fingerprint: ee.fingerprint) do |user| + setup_totp_user(user, ee: ee, pw: pw) + end + loa3_user = User.find_by(email_fingerprint: fingerprint('test2@test.com')) profile = Profile.new(user: loa3_user) pii = Pii::Attributes.new_from_hash( @@ -89,6 +94,14 @@ namespace :dev do Event.create(user_id: user.id, event_type: :account_created) end + def setup_totp_user(user, args) + user.encrypted_email = args[:ee].encrypted + user.skip_confirmation! + user.reset_password(args[:pw], args[:pw]) + user.otp_secret_key = ROTP::Base32.random_base32 + Event.create(user_id: user.id, event_type: :account_created) + end + def fingerprint(email) Pii::Fingerprinter.fingerprint(email) end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 10a64ff94f1..0bc4c575dbd 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -43,7 +43,7 @@ trait :with_authentication_app do with_personal_key - otp_secret_key 'abc123' + otp_secret_key ROTP::Base32.random_base32 end trait :admin do diff --git a/spec/lib/tasks/dev_rake_spec.rb b/spec/lib/tasks/dev_rake_spec.rb index 8b3078bfb56..5c40b182417 100644 --- a/spec/lib/tasks/dev_rake_spec.rb +++ b/spec/lib/tasks/dev_rake_spec.rb @@ -12,7 +12,7 @@ it 'runs successfully' do Rake::Task['dev:prime'].invoke - expect(User.count).to eq 2 + expect(User.count).to eq 3 end end From a81d54b6c29511d9d445519a7e24db220bd5d97b Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Tue, 4 Sep 2018 20:50:59 -0500 Subject: [PATCH 14/61] Drop personal key columns (#2374) **Why**: We are no longer reading form these columns in favor of encrypted_recovery_code_digest --- ...54947_drop_personal_key_columns_from_user.rb | 17 +++++++++++++++++ db/schema.rb | 4 ---- 2 files changed, 17 insertions(+), 4 deletions(-) create mode 100644 db/migrate/20180724154947_drop_personal_key_columns_from_user.rb diff --git a/db/migrate/20180724154947_drop_personal_key_columns_from_user.rb b/db/migrate/20180724154947_drop_personal_key_columns_from_user.rb new file mode 100644 index 00000000000..32e9337f9af --- /dev/null +++ b/db/migrate/20180724154947_drop_personal_key_columns_from_user.rb @@ -0,0 +1,17 @@ +class DropPersonalKeyColumnsFromUser < ActiveRecord::Migration[5.1] + def up + safety_assured do + remove_column :users, :recovery_code + remove_column :users, :encryption_key + remove_column :users, :recovery_salt + remove_column :users, :recovery_cost + end + end + + def down + add_column :users, :recovery_code, :string + add_column :users, :encryption_key, :string + add_column :users, :recovery_salt, :string + add_column :users, :recovery_cost, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index c1412afb409..4ca42917948 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -212,11 +212,7 @@ t.datetime "direct_otp_sent_at" t.datetime "idv_attempted_at" t.integer "idv_attempts", default: 0 - t.string "recovery_code" - t.string "encryption_key" t.string "unique_session_id" - t.string "recovery_salt" - t.string "recovery_cost" t.string "email_fingerprint", default: "", null: false t.text "encrypted_email", default: "", null: false t.string "attribute_cost" From 635df157e6c2d31b4afd2e7150bc69b639ba2d61 Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Tue, 4 Sep 2018 20:52:25 -0500 Subject: [PATCH 15/61] LG-528 Use IdV specific phone otp confirmation (#2430) **Why**: The otp verification controller serves multiple puprose, phone confirmation during sign up, sign in, edit phone, and idv. As a result the logic is very complex and error prone. This commit creates a separate controller specific for the IdV phone OTP confirmation since it differs a good bit from the other use cases for phone confirmation. --- .../concerns/idv/phone_otp_rate_limitable.rb | 53 ++++++++ .../concerns/idv/phone_otp_sendable.rb | 57 +++++++++ .../idv/otp_delivery_method_controller.rb | 43 ++++--- .../idv/otp_verification_controller.rb | 67 ++++++++++ app/controllers/idv/resend_otp_controller.rb | 37 ++++++ app/controllers/idv/review_controller.rb | 6 +- ...hone_confirmation_otp_verification_form.rb | 61 +++++++++ .../idv/otp_verification_presenter.rb | 35 +++++ app/services/analytics.rb | 7 + .../idv/generate_phone_confirmation_otp.rb | 8 ++ .../idv/send_phone_confirmation_otp.rb | 96 ++++++++++++++ app/services/idv/session.rb | 3 + app/views/idv/otp_verification/show.html.slim | 28 ++++ config/routes.rb | 3 + .../otp_delivery_method_controller_spec.rb | 71 +++++++++- .../idv/otp_verification_controller_spec.rb | 94 ++++++++++++++ .../idv/resend_otp_controller_spec.rb | 116 +++++++++++++++++ .../controllers/idv/review_controller_spec.rb | 4 +- .../idv/phone_otp_rate_limiting_spec.rb | 120 +++++++++++++++++ .../phone_otp_delivery_selection_step_spec.rb | 49 ++++++- .../steps/phone_otp_verification_step_spec.rb | 76 ++++++++++- spec/features/idv/steps/phone_step_spec.rb | 4 +- spec/features/idv/usps_disabled_spec.rb | 5 +- .../remember_device_spec.rb | 2 +- ...confirmation_otp_verification_form_spec.rb | 107 ++++++++++++++++ .../idv/send_phone_confirmation_otp_spec.rb | 121 ++++++++++++++++++ 26 files changed, 1236 insertions(+), 37 deletions(-) create mode 100644 app/controllers/concerns/idv/phone_otp_rate_limitable.rb create mode 100644 app/controllers/concerns/idv/phone_otp_sendable.rb create mode 100644 app/controllers/idv/otp_verification_controller.rb create mode 100644 app/controllers/idv/resend_otp_controller.rb create mode 100644 app/forms/idv/phone_confirmation_otp_verification_form.rb create mode 100644 app/presenters/idv/otp_verification_presenter.rb create mode 100644 app/services/idv/generate_phone_confirmation_otp.rb create mode 100644 app/services/idv/send_phone_confirmation_otp.rb create mode 100644 app/views/idv/otp_verification/show.html.slim create mode 100644 spec/controllers/idv/otp_verification_controller_spec.rb create mode 100644 spec/controllers/idv/resend_otp_controller_spec.rb create mode 100644 spec/features/idv/phone_otp_rate_limiting_spec.rb create mode 100644 spec/forms/idv/phone_confirmation_otp_verification_form_spec.rb create mode 100644 spec/services/idv/send_phone_confirmation_otp_spec.rb diff --git a/app/controllers/concerns/idv/phone_otp_rate_limitable.rb b/app/controllers/concerns/idv/phone_otp_rate_limitable.rb new file mode 100644 index 00000000000..f20e7b05151 --- /dev/null +++ b/app/controllers/concerns/idv/phone_otp_rate_limitable.rb @@ -0,0 +1,53 @@ +module Idv + module PhoneOtpRateLimitable + extend ActiveSupport::Concern + + included do + before_action :confirm_two_factor_authenticated + before_action :handle_locked_out_user + end + + def handle_locked_out_user + reset_attempt_count_if_user_no_longer_locked_out + return unless decorated_user.locked_out? + analytics.track_event(Analytics::IDV_PHONE_CONFIRMATION_OTP_RATE_LIMIT_LOCKED_OUT) + handle_too_many_otp_attempts + false + end + + def reset_attempt_count_if_user_no_longer_locked_out + return unless decorated_user.no_longer_locked_out? + + UpdateUser.new( + user: current_user, + attributes: { + second_factor_attempts_count: 0, + second_factor_locked_at: nil, + } + ).call + end + + def handle_too_many_otp_sends + analytics.track_event(Analytics::IDV_PHONE_CONFIRMATION_OTP_RATE_LIMIT_SENDS) + handle_max_attempts('otp_requests') + end + + def handle_too_many_otp_attempts + analytics.track_event(Analytics::IDV_PHONE_CONFIRMATION_OTP_RATE_LIMIT_ATTEMPTS) + handle_max_attempts('otp_login_attempts') + end + + def handle_max_attempts(type) + presenter = TwoFactorAuthCode::MaxAttemptsReachedPresenter.new( + type, + decorated_user + ) + sign_out + render_full_width('shared/_failure', locals: { presenter: presenter }) + end + + def decorated_user + current_user.decorate + end + end +end diff --git a/app/controllers/concerns/idv/phone_otp_sendable.rb b/app/controllers/concerns/idv/phone_otp_sendable.rb new file mode 100644 index 00000000000..f19473ff63d --- /dev/null +++ b/app/controllers/concerns/idv/phone_otp_sendable.rb @@ -0,0 +1,57 @@ +module Idv + module PhoneOtpSendable + extend ActiveSupport::Concern + + included do + before_action :confirm_two_factor_authenticated + before_action :handle_locked_out_user + end + + def send_phone_confirmation_otp + send_phone_confirmation_otp_service.call + end + + def send_phone_confirmation_otp_rate_limited? + send_phone_confirmation_otp_service.user_locked_out? + end + + def invalid_phone_number(exception) + capture_analytics_for_twilio_exception(exception) + twilio_errors = TwilioErrors::REST_ERRORS.merge(TwilioErrors::VERIFY_ERRORS) + flash[:error] = twilio_errors.fetch(exception.code, t('errors.messages.otp_failed')) + redirect_to idv_phone_url + end + + private + + def send_phone_confirmation_otp_service + @send_phone_confirmation_otp_service ||= Idv::SendPhoneConfirmationOtp.new( + user: current_user, + idv_session: idv_session, + locale: user_locale + ) + end + + def user_locale + available_locales = PhoneVerification::AVAILABLE_LOCALES + http_accept_language.language_region_compatible_from(available_locales) + end + + # rubocop:disable Metrics/MethodLength + # :reek:FeatureEnvy + def capture_analytics_for_twilio_exception(exception) + attributes = { + error: exception.message, + code: exception.code, + context: 'idv', + country: Phonelib.parse(send_phone_confirmation_otp_service.phone).country, + } + if exception.is_a?(PhoneVerification::VerifyError) + attributes[:status] = exception.status + attributes[:response] = exception.response + end + analytics.track_event(Analytics::TWILIO_PHONE_VALIDATION_FAILED, attributes) + end + # rubocop:enable Metrics/MethodLength + end +end diff --git a/app/controllers/idv/otp_delivery_method_controller.rb b/app/controllers/idv/otp_delivery_method_controller.rb index e8e0a147542..89d2dd5da56 100644 --- a/app/controllers/idv/otp_delivery_method_controller.rb +++ b/app/controllers/idv/otp_delivery_method_controller.rb @@ -1,11 +1,13 @@ module Idv class OtpDeliveryMethodController < ApplicationController include IdvSession - include PhoneConfirmation + include PhoneOtpRateLimitable + include PhoneOtpSendable + # confirm_two_factor_authenticated before action is in PhoneOtpRateLimitable before_action :confirm_phone_step_complete before_action :confirm_step_needed - before_action :idv_phone # Memoize to use ivar in the view + before_action :set_idv_phone def new analytics.track_event(Analytics::IDV_PHONE_OTP_DELIVERY_SELECTION_VISIT) @@ -14,11 +16,10 @@ def new def create result = otp_delivery_selection_form.submit(otp_delivery_selection_params) analytics.track_event(Analytics::IDV_PHONE_OTP_DELIVERY_SELECTION_SUBMITTED, result.to_h) - if result.success? - prompt_to_confirm_idv_phone - else - render :new - end + return render(:new) unless result.success? + send_phone_confirmation_otp_and_handle_result + rescue Twilio::REST::RestError, PhoneVerification::VerifyError => exception + invalid_phone_number(exception) end private @@ -32,16 +33,8 @@ def confirm_step_needed idv_session.user_phone_confirmation == true end - def idv_phone - @idv_phone ||= PhoneFormatter.format(idv_session.params[:phone]) - end - - def prompt_to_confirm_idv_phone - prompt_to_confirm_phone( - phone: idv_phone, - context: 'idv', - selected_delivery_method: otp_delivery_selection_form.otp_delivery_preference - ) + def set_idv_phone + @idv_phone = PhoneFormatter.format(idv_session.params[:phone]) end def otp_delivery_selection_params @@ -50,6 +43,22 @@ def otp_delivery_selection_params ) end + def send_phone_confirmation_otp_and_handle_result + save_delivery_preference_in_session + result = send_phone_confirmation_otp + analytics.track_event(Analytics::IDV_PHONE_CONFIRMATION_OTP_SENT, result.to_h) + if send_phone_confirmation_otp_rate_limited? + handle_too_many_otp_sends + else + redirect_to idv_otp_verification_url + end + end + + def save_delivery_preference_in_session + idv_session.phone_confirmation_otp_delivery_method = + @otp_delivery_selection_form.otp_delivery_preference + end + def otp_delivery_selection_form @otp_delivery_selection_form ||= Idv::OtpDeliveryMethodForm.new end diff --git a/app/controllers/idv/otp_verification_controller.rb b/app/controllers/idv/otp_verification_controller.rb new file mode 100644 index 00000000000..3ee9294bdb1 --- /dev/null +++ b/app/controllers/idv/otp_verification_controller.rb @@ -0,0 +1,67 @@ +module Idv + class OtpVerificationController < ApplicationController + include IdvSession + include PhoneOtpRateLimitable + + # confirm_two_factor_authenticated before action is in PhoneOtpRateLimitable + before_action :confirm_step_needed + before_action :confirm_otp_sent + before_action :set_code + before_action :set_otp_verification_presenter + + def show + # memoize the form so the ivar is available to the view + phone_confirmation_otp_verification_form + analytics.track_event(Analytics::IDV_PHONE_CONFIRMATION_OTP_VISIT) + end + + def update + result = phone_confirmation_otp_verification_form.submit(code: params[:code]) + analytics.track_event(Analytics::IDV_PHONE_CONFIRMATION_OTP_SUBMITTED, result.to_h) + if result.success? + redirect_to idv_review_url + else + handle_otp_confirmation_failure + end + end + + private + + def confirm_step_needed + return unless idv_session.user_phone_confirmation + redirect_to idv_review_url + end + + def confirm_otp_sent + return if idv_session.phone_confirmation_otp.present? && + idv_session.phone_confirmation_otp_sent_at.present? + + redirect_to idv_otp_delivery_method_url + end + + def set_code + return unless FeatureManagement.prefill_otp_codes? + @code = idv_session.phone_confirmation_otp + end + + def set_otp_verification_presenter + @presenter = OtpVerificationPresenter.new(idv_session: idv_session) + end + + def handle_otp_confirmation_failure + if decorated_user.locked_out? + handle_too_many_otp_attempts + else + flash.now[:error] = t('devise.two_factor_authentication.invalid_otp') + render :show + end + end + + def phone_confirmation_otp_verification_form + @phone_confirmation_otp_verification_form ||= PhoneConfirmationOtpVerificationForm.new( + user: current_user, + idv_session: idv_session + ) + end + end +end diff --git a/app/controllers/idv/resend_otp_controller.rb b/app/controllers/idv/resend_otp_controller.rb new file mode 100644 index 00000000000..83482415e4b --- /dev/null +++ b/app/controllers/idv/resend_otp_controller.rb @@ -0,0 +1,37 @@ +module Idv + class ResendOtpController < ApplicationController + include IdvSession + include PhoneOtpRateLimitable + include PhoneOtpSendable + + # confirm_two_factor_authenticated before action is in PhoneOtpRateLimitable + before_action :confirm_user_phone_confirmation_needed + before_action :confirm_otp_delivery_preference_selected + + def create + result = send_phone_confirmation_otp + analytics.track_event(Analytics::IDV_PHONE_CONFIRMATION_OTP_RESENT, result.to_h) + if send_phone_confirmation_otp_rate_limited? + handle_too_many_otp_sends + else + redirect_to idv_otp_verification_url + end + rescue Twilio::REST::RestError, PhoneVerification::VerifyError => exception + invalid_phone_number(exception) + end + + private + + def confirm_user_phone_confirmation_needed + return unless idv_session.user_phone_confirmation + redirect_to idv_review_url + end + + def confirm_otp_delivery_preference_selected + return if idv_session.params[:phone].present? && + idv_session.phone_confirmation_otp_delivery_method.present? + + redirect_to idv_otp_delivery_method_url + end + end +end diff --git a/app/controllers/idv/review_controller.rb b/app/controllers/idv/review_controller.rb index 857f180cf55..88bd1dda2d3 100644 --- a/app/controllers/idv/review_controller.rb +++ b/app/controllers/idv/review_controller.rb @@ -15,11 +15,7 @@ def confirm_idv_steps_complete def confirm_idv_phone_confirmed return unless idv_session.address_verification_mechanism == 'phone' return if idv_session.phone_confirmed? - - prompt_to_confirm_phone( - phone: idv_session.params[:phone], - context: 'idv' - ) + redirect_to idv_otp_verification_path end def confirm_current_password diff --git a/app/forms/idv/phone_confirmation_otp_verification_form.rb b/app/forms/idv/phone_confirmation_otp_verification_form.rb new file mode 100644 index 00000000000..a26586c67c7 --- /dev/null +++ b/app/forms/idv/phone_confirmation_otp_verification_form.rb @@ -0,0 +1,61 @@ +module Idv + class PhoneConfirmationOtpVerificationForm + attr_reader :user, :idv_session, :code + + def initialize(user:, idv_session:) + @user = user + @idv_session = idv_session + end + + def submit(code:) + @code = code + success = code_valid? + if success + idv_session.user_phone_confirmation = true + clear_second_factor_attempts + else + increment_second_factor_attempts + end + FormResponse.new(success: success, errors: {}, extra: extra_analytics_attributes) + end + + private + + def code_valid? + return false if code_expired? + code_matches? + end + + # Ignore duplicate method call on Time.zone :reek:DuplicateMethodCall + def code_expired? + sent_at_time = Time.zone.parse(idv_session.phone_confirmation_otp_sent_at) + expiration_time = sent_at_time + Figaro.env.otp_valid_for.to_i.minutes + Time.zone.now > expiration_time + end + + def code_matches? + Devise.secure_compare(code, idv_session.phone_confirmation_otp) + end + + def clear_second_factor_attempts + UpdateUser.new(user: user, attributes: { second_factor_attempts_count: 0 }).call + end + + def increment_second_factor_attempts + user.second_factor_attempts_count += 1 + attributes = {} + attributes[:second_factor_locked_at] = Time.zone.now if user.max_login_attempts? + + UpdateUser.new(user: user, attributes: attributes).call + end + + def extra_analytics_attributes + { + code_expired: code_expired?, + code_matches: code_matches?, + second_factor_attempts_count: user.second_factor_attempts_count, + second_factor_locked_at: user.second_factor_locked_at, + } + end + end +end diff --git a/app/presenters/idv/otp_verification_presenter.rb b/app/presenters/idv/otp_verification_presenter.rb new file mode 100644 index 00000000000..82b0c50ec9c --- /dev/null +++ b/app/presenters/idv/otp_verification_presenter.rb @@ -0,0 +1,35 @@ +module Idv + class OtpVerificationPresenter + include ActionView::Helpers::UrlHelper + include ActionView::Helpers::TagHelper + include ActionView::Helpers::TranslationHelper + + attr_reader :idv_session + + def initialize(idv_session:) + @idv_session = idv_session + end + + def phone_number_message + t("instructions.mfa.#{otp_delivery_preference}.number_message", + number: content_tag(:strong, phone_number), + expiration: Figaro.env.otp_valid_for) + end + + def update_phone_link + phone_path = Rails.application.routes.url_helpers.idv_phone_path + link = link_to(t('forms.two_factor.try_again'), phone_path) + t('instructions.mfa.wrong_number_html', link: link) + end + + private + + def phone_number + PhoneFormatter.format(idv_session.params[:phone]) + end + + def otp_delivery_preference + idv_session.phone_confirmation_otp_delivery_method + end + end +end diff --git a/app/services/analytics.rb b/app/services/analytics.rb index 788fa6f9edd..0d1c9be8d89 100644 --- a/app/services/analytics.rb +++ b/app/services/analytics.rb @@ -73,6 +73,13 @@ def browser IDV_JURISDICTION_FORM = 'IdV: jurisdiction form submitted'.freeze IDV_PHONE_CONFIRMATION_FORM = 'IdV: phone confirmation form'.freeze IDV_PHONE_CONFIRMATION_VENDOR = 'IdV: phone confirmation vendor'.freeze + IDV_PHONE_CONFIRMATION_OTP_RATE_LIMIT_ATTEMPTS = 'Idv: Phone OTP attempts rate limited'.freeze + IDV_PHONE_CONFIRMATION_OTP_RATE_LIMIT_LOCKED_OUT = 'Idv: Phone OTP rate limited user'.freeze + IDV_PHONE_CONFIRMATION_OTP_RATE_LIMIT_SENDS = 'Idv: Phone OTP sends rate limited'.freeze + IDV_PHONE_CONFIRMATION_OTP_RESENT = 'IdV: phone confirmation otp resent'.freeze + IDV_PHONE_CONFIRMATION_OTP_SENT = 'IdV: phone confirmation otp sent'.freeze + IDV_PHONE_CONFIRMATION_OTP_SUBMITTED = 'IdV: phone confirmation otp submitted'.freeze + IDV_PHONE_CONFIRMATION_OTP_VISIT = 'IdV: phone confirmation otp visited'.freeze IDV_PHONE_OTP_DELIVERY_SELECTION_SUBMITTED = 'IdV: Phone OTP Delivery Selection Submitted'.freeze IDV_PHONE_OTP_DELIVERY_SELECTION_VISIT = 'IdV: Phone OTP delivery Selection Visited'.freeze IDV_PHONE_RECORD_VISIT = 'IdV: phone of record visited'.freeze diff --git a/app/services/idv/generate_phone_confirmation_otp.rb b/app/services/idv/generate_phone_confirmation_otp.rb new file mode 100644 index 00000000000..8d2e1282f96 --- /dev/null +++ b/app/services/idv/generate_phone_confirmation_otp.rb @@ -0,0 +1,8 @@ +module Idv + class GeneratePhoneConfirmationOtp + def self.call + digits = Devise.direct_otp_length + SecureRandom.random_number(10**digits).to_s.rjust(digits, '0') + end + end +end diff --git a/app/services/idv/send_phone_confirmation_otp.rb b/app/services/idv/send_phone_confirmation_otp.rb new file mode 100644 index 00000000000..414bd143696 --- /dev/null +++ b/app/services/idv/send_phone_confirmation_otp.rb @@ -0,0 +1,96 @@ +module Idv + # Ignore instance variable assumption on @user_locked_out :reek:InstanceVariableAssumption + class SendPhoneConfirmationOtp + def initialize(user:, idv_session:, locale:) + @user = user + @idv_session = idv_session + @locale = locale + end + + def call + otp_rate_limiter.reset_count_and_otp_last_sent_at if user.decorate.no_longer_locked_out? + + return too_many_otp_sends_response if rate_limit_exceeded? + otp_rate_limiter.increment + return too_many_otp_sends_response if rate_limit_exceeded? + + send_otp + FormResponse.new(success: true, errors: {}, extra: extra_analytics_attributes) + end + + def user_locked_out? + @user_locked_out + end + + def phone + @phone ||= PhoneFormatter.format(idv_session.params[:phone]) + end + + private + + attr_reader :user, :idv_session, :locale + + def too_many_otp_sends_response + FormResponse.new( + success: false, + errors: {}, + extra: extra_analytics_attributes + ) + end + + def rate_limit_exceeded? + if otp_rate_limiter.exceeded_otp_send_limit? + otp_rate_limiter.lock_out_user + return @user_locked_out = true + end + false + end + + def otp_rate_limiter + @otp_rate_limiter ||= OtpRateLimiter.new(user: user, phone: phone) + end + + def send_otp + idv_session.phone_confirmation_otp = GeneratePhoneConfirmationOtp.call + idv_session.phone_confirmation_otp_sent_at = Time.zone.now.to_s + if otp_delivery_preference == :sms + send_sms_otp + elsif otp_delivery_preference == :voice + send_voice_otp + end + end + + def send_sms_otp + SmsOtpSenderJob.perform_later( + code: idv_session.phone_confirmation_otp, + phone: phone, + otp_created_at: idv_session.phone_confirmation_otp_sent_at, + message: 'jobs.sms_otp_sender_job.verify_message', + locale: locale + ) + end + + def send_voice_otp + VoiceOtpSenderJob.perform_later( + code: idv_session.phone_confirmation_otp, + phone: phone, + otp_created_at: idv_session.phone_confirmation_otp_sent_at, + locale: locale + ) + end + + def otp_delivery_preference + idv_session.phone_confirmation_otp_delivery_method.to_sym + end + + def extra_analytics_attributes + parsed_phone = Phonelib.parse(phone) + { + otp_delivery_preference: otp_delivery_preference, + country_code: parsed_phone.country, + area_code: parsed_phone.area_code, + rate_limit_exceeded: rate_limit_exceeded?, + } + end + end +end diff --git a/app/services/idv/session.rb b/app/services/idv/session.rb index bdc4c12ecf9..a6e59634c29 100644 --- a/app/services/idv/session.rb +++ b/app/services/idv/session.rb @@ -8,6 +8,9 @@ class Session params vendor_phone_confirmation user_phone_confirmation + phone_confirmation_otp_delivery_method + phone_confirmation_otp_sent_at + phone_confirmation_otp pii profile_confirmation profile_id diff --git a/app/views/idv/otp_verification/show.html.slim b/app/views/idv/otp_verification/show.html.slim new file mode 100644 index 00000000000..997a4007232 --- /dev/null +++ b/app/views/idv/otp_verification/show.html.slim @@ -0,0 +1,28 @@ +- title t('titles.enter_2fa_code') + +h1.h3.my0 = t('devise.two_factor_authentication.header_text') + +p == @presenter.phone_number_message + += form_tag(:idv_otp_verification, method: :put, role: 'form', class: 'mt3') do + = label_tag 'code', \ + t('simple_form.required.html') + t('forms.two_factor.code'), \ + class: 'block bold' + .col-12.sm-col-5.mb2.sm-mb0.sm-mr-20p.inline-block + = text_field_tag(:code, '', value: @code, required: true, + autofocus: true, pattern: '[0-9]*', class: 'col-12 field monospace mfa', + 'aria-describedby': 'code-instructs', maxlength: Devise.direct_otp_length, + autocomplete: 'off', type: 'tel') + = submit_tag t('forms.buttons.submit.default'), class: 'btn btn-primary align-top sm-col-6 col-12' + br + br += button_to(t('links.two_factor_authentication.get_another_code'), idv_resend_otp_path, + method: :post, + class: 'btn btn-link btn-border ico ico-refresh text-decoration-none', + form_class: 'inline-block') + +p.mt4 = @presenter.update_phone_link + +.mt3.border-top + .mt1 + = link_to t('links.cancel'), idv_cancel_path diff --git a/config/routes.rb b/config/routes.rb index d4e78b4bad5..002058618f5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -188,6 +188,9 @@ put '/phone' => 'phone#create' get '/phone/result' => 'phone#show' get '/phone/failure/:reason' => 'phone#failure', as: :phone_failure + post '/phone/resend_code' => 'resend_otp#create', as: :resend_otp + get '/phone_confirmation' => 'otp_verification#show', as: :otp_verification + put '/phone_confirmation' => 'otp_verification#update', as: :nil get '/review' => 'review#new' put '/review' => 'review#create' get '/session' => 'sessions#new' diff --git a/spec/controllers/idv/otp_delivery_method_controller_spec.rb b/spec/controllers/idv/otp_delivery_method_controller_spec.rb index a5f3a3671c5..5faa5629464 100644 --- a/spec/controllers/idv/otp_delivery_method_controller_spec.rb +++ b/spec/controllers/idv/otp_delivery_method_controller_spec.rb @@ -6,7 +6,7 @@ before do stub_verify_steps_one_and_two(user) subject.idv_session.address_verification_mechanism = 'phone' - subject.idv_session.params[:phone] = '5555555000' + subject.idv_session.params[:phone] = '2255555000' subject.idv_session.vendor_phone_confirmation = true subject.idv_session.user_phone_confirmation = false end @@ -108,7 +108,8 @@ context 'user has selected sms' do it 'redirects to the otp send path for sms' do post :create, params: params - expect(response).to redirect_to otp_send_path(params) + expect(subject.idv_session.phone_confirmation_otp_delivery_method).to eq('sms') + expect(response).to redirect_to idv_otp_verification_path end it 'tracks an analytics event' do @@ -139,7 +140,8 @@ it 'redirects to the otp send path for voice' do post :create, params: params - expect(response).to redirect_to otp_send_path(params) + expect(subject.idv_session.phone_confirmation_otp_delivery_method).to eq('voice') + expect(response).to redirect_to idv_otp_verification_path end it 'tracks an analytics event' do @@ -189,5 +191,68 @@ with(Analytics::IDV_PHONE_OTP_DELIVERY_SELECTION_SUBMITTED, result) end end + + context 'twilio raises an exception' do + let(:twilio_error_analytics_hash) do + { + error: "[HTTP 400] : error message\n\n", + code: '', + context: 'idv', + country: 'US', + } + end + let(:twilio_error) do + Twilio::REST::RestError.new('error message', FakeTwilioErrorResponse.new) + end + + before do + stub_analytics + allow(SmsOtpSenderJob).to receive(:perform_later).and_raise(twilio_error) + end + + context 'twilio rest error' do + it 'tracks an analytics events' do + expect(@analytics).to receive(:track_event).ordered.with( + Analytics::IDV_PHONE_OTP_DELIVERY_SELECTION_SUBMITTED, hash_including(success: true) + ) + expect(@analytics).to receive(:track_event).ordered.with( + Analytics::TWILIO_PHONE_VALIDATION_FAILED, twilio_error_analytics_hash + ) + + post :create, params: params + end + end + + context 'phone verification verify error' do + let(:twilio_error_analytics_hash) do + analytics_hash = super() + analytics_hash.merge( + error: 'error', + code: 60_033, + status: 400, + response: '{"error_code":"60004"}' + ) + end + let(:twilio_error) do + PhoneVerification::VerifyError.new( + code: 60_033, + message: 'error', + status: 400, + response: '{"error_code":"60004"}' + ) + end + + it 'tracks an analytics event' do + expect(@analytics).to receive(:track_event).ordered.with( + Analytics::IDV_PHONE_OTP_DELIVERY_SELECTION_SUBMITTED, hash_including(success: true) + ) + expect(@analytics).to receive(:track_event).ordered.with( + Analytics::TWILIO_PHONE_VALIDATION_FAILED, twilio_error_analytics_hash + ) + + post :create, params: params + end + end + end end end diff --git a/spec/controllers/idv/otp_verification_controller_spec.rb b/spec/controllers/idv/otp_verification_controller_spec.rb new file mode 100644 index 00000000000..fd516a2a51c --- /dev/null +++ b/spec/controllers/idv/otp_verification_controller_spec.rb @@ -0,0 +1,94 @@ +require 'rails_helper' + +describe Idv::OtpVerificationController do + let(:user) { build(:user) } + + let(:phone) { '2255555000' } + let(:user_phone_confirmation) { false } + let(:phone_confirmation_otp_delivery_method) { 'sms' } + let(:phone_confirmation_otp) { '777777' } + let(:phone_confirmation_otp_sent_at) { Time.zone.now.to_s } + + before do + stub_analytics + allow(@analytics).to receive(:track_event) + + sign_in(user) + stub_verify_steps_one_and_two(user) + subject.idv_session.params[:phone] = phone + subject.idv_session.vendor_phone_confirmation = true + subject.idv_session.user_phone_confirmation = user_phone_confirmation + subject.idv_session.phone_confirmation_otp_delivery_method = + phone_confirmation_otp_delivery_method + subject.idv_session.phone_confirmation_otp = phone_confirmation_otp + subject.idv_session.phone_confirmation_otp_sent_at = phone_confirmation_otp_sent_at + end + + describe '#show' do + context 'the user has not been sent an otp' do + let(:phone_confirmation_otp) { nil } + let(:phone_confirmation_otp_sent_at) { nil } + + it 'redirects to the delivery method path' do + get :show + expect(response).to redirect_to(idv_otp_delivery_method_path) + end + end + + context 'the user has already confirmed their phone' do + let(:user_phone_confirmation) { true } + + it 'redirects to the review step' do + get :show + expect(response).to redirect_to(idv_review_path) + end + end + + it 'tracks an analytics event' do + get :show + + expect(@analytics).to have_received(:track_event).with( + Analytics::IDV_PHONE_CONFIRMATION_OTP_VISIT + ) + end + end + + describe '#update' do + context 'the user has not been sent an otp' do + let(:phone_confirmation_otp) { nil } + let(:phone_confirmation_otp_sent_at) { nil } + + it 'redirects to otp delivery method selection' do + put :update, params: { code: phone_confirmation_otp } + expect(response).to redirect_to(idv_otp_delivery_method_path) + end + end + + context 'the user has already confirmed their phone' do + let(:user_phone_confirmation) { true } + + it 'redirects to the review step' do + put :update, params: { code: phone_confirmation_otp } + expect(response).to redirect_to(idv_review_path) + end + end + + it 'tracks an analytics event' do + put :update, params: { code: phone_confirmation_otp } + + expected_result = { + success: true, + errors: {}, + code_expired: false, + code_matches: true, + second_factor_attempts_count: 0, + second_factor_locked_at: nil, + } + + expect(@analytics).to have_received(:track_event).with( + Analytics::IDV_PHONE_CONFIRMATION_OTP_SUBMITTED, + expected_result + ) + end + end +end diff --git a/spec/controllers/idv/resend_otp_controller_spec.rb b/spec/controllers/idv/resend_otp_controller_spec.rb new file mode 100644 index 00000000000..7d73377f7c1 --- /dev/null +++ b/spec/controllers/idv/resend_otp_controller_spec.rb @@ -0,0 +1,116 @@ +require 'rails_helper' + +describe Idv::ResendOtpController do + let(:user) { build(:user) } + + let(:phone) { '2255555000' } + let(:user_phone_confirmation) { false } + let(:phone_confirmation_otp_delivery_method) { 'sms' } + + before do + stub_analytics + allow(@analytics).to receive(:track_event) + + sign_in(user) + stub_verify_steps_one_and_two(user) + subject.idv_session.params[:phone] = phone + subject.idv_session.vendor_phone_confirmation = true + subject.idv_session.user_phone_confirmation = user_phone_confirmation + subject.idv_session.phone_confirmation_otp_delivery_method = + phone_confirmation_otp_delivery_method + end + + describe '#create' do + context 'the user has not selected a delivery method' do + let(:phone_confirmation_otp_delivery_method) { nil } + + it 'redirects to otp delivery method selection' do + post :create + expect(response).to redirect_to(idv_otp_delivery_method_path) + end + end + + context 'the user has already confirmed their phone' do + let(:user_phone_confirmation) { true } + + it 'redirects to the review step' do + post :create + expect(response).to redirect_to(idv_review_path) + end + end + + it 'tracks an analytics event' do + post :create + + expected_result = { + success: true, + errors: {}, + otp_delivery_preference: :sms, + country_code: 'US', + area_code: '225', + rate_limit_exceeded: false, + } + + expect(@analytics).to have_received(:track_event).with( + Analytics::IDV_PHONE_CONFIRMATION_OTP_RESENT, + expected_result + ) + end + + context 'twilio raises an exception' do + let(:twilio_error_analytics_hash) do + { + error: "[HTTP 400] : error message\n\n", + code: '', + context: 'idv', + country: 'US', + } + end + let(:twilio_error) do + Twilio::REST::RestError.new('error message', FakeTwilioErrorResponse.new) + end + + before do + stub_analytics + allow(SmsOtpSenderJob).to receive(:perform_later).and_raise(twilio_error) + end + + context 'twilio rest error' do + it 'tracks an analytics events' do + expect(@analytics).to receive(:track_event).ordered.with( + Analytics::TWILIO_PHONE_VALIDATION_FAILED, twilio_error_analytics_hash + ) + + post :create + end + end + + context 'phone verification verify error' do + let(:twilio_error_analytics_hash) do + super().merge( + error: 'error', + code: 60_033, + status: 400, + response: '{"error_code":"60004"}' + ) + end + let(:twilio_error) do + PhoneVerification::VerifyError.new( + code: 60_033, + message: 'error', + status: 400, + response: '{"error_code":"60004"}' + ) + end + + it 'tracks an analytics event' do + expect(@analytics).to receive(:track_event).ordered.with( + Analytics::TWILIO_PHONE_VALIDATION_FAILED, twilio_error_analytics_hash + ) + + post :create + end + end + end + end +end diff --git a/spec/controllers/idv/review_controller_spec.rb b/spec/controllers/idv/review_controller_spec.rb index bfa4d713cc9..73d47e96423 100644 --- a/spec/controllers/idv/review_controller_spec.rb +++ b/spec/controllers/idv/review_controller_spec.rb @@ -130,9 +130,7 @@ def show it 'redirects to phone confirmation' do get :show - expect(response).to redirect_to otp_send_path( - otp_delivery_selection_form: { otp_delivery_preference: :sms } - ) + expect(response).to redirect_to idv_otp_verification_path end end end diff --git a/spec/features/idv/phone_otp_rate_limiting_spec.rb b/spec/features/idv/phone_otp_rate_limiting_spec.rb new file mode 100644 index 00000000000..713333e2596 --- /dev/null +++ b/spec/features/idv/phone_otp_rate_limiting_spec.rb @@ -0,0 +1,120 @@ +require 'rails_helper' + +feature 'phone otp rate limiting', :idv_job do + include IdvStepHelper + + let(:user) { user_with_2fa } + let(:otp_code) { '777777' } + + before do + allow(Idv::GeneratePhoneConfirmationOtp).to receive(:call).and_return(otp_code) + end + + describe 'otp sends' do + let(:max_attempts) { Figaro.env.otp_delivery_blocklist_maxretry.to_i + 1 } + + it 'rate limits sends from the otp delivery method step' do + start_idv_from_sp + complete_idv_steps_before_phone_otp_delivery_selection_step(user) + + (max_attempts - 1).times do + choose_idv_otp_delivery_method_sms + visit idv_otp_delivery_method_path + end + choose_idv_otp_delivery_method_sms + + expect_max_otp_request_rate_limiting + end + + it 'rate limits resends from the otp verification step' do + start_idv_from_sp + complete_idv_steps_before_phone_otp_verification_step(user) + + (max_attempts - 1).times do + click_on t('links.two_factor_authentication.get_another_code') + end + + expect_max_otp_request_rate_limiting + end + + it 'rate limits sends from the otp delivery methods and verification step in combination' do + send_attempts = max_attempts - 2 + + start_idv_from_sp + complete_idv_steps_before_phone_otp_delivery_selection_step(user) + + # (n - 2)th attempt + send_attempts.times do + choose_idv_otp_delivery_method_sms + visit idv_otp_delivery_method_path + end + + # (n - 1)th attempt + choose_idv_otp_delivery_method_sms + + # nth attempt + click_on t('links.two_factor_authentication.get_another_code') + + expect_max_otp_request_rate_limiting + end + + def expect_max_otp_request_rate_limiting + expect(page).to have_content t('titles.account_locked') + expect(page).to have_content t( + 'devise.two_factor_authentication.max_otp_requests_reached' + ) + + expect_rate_limit_circumvention_to_be_disallowed(user) + expect_rate_limit_to_expire(user) + end + end + + describe 'otp attempts' do + let(:max_attempts) { 3 } + + it 'rate limits otp attempts at the otp verification step' do + start_idv_from_sp + complete_idv_steps_before_phone_otp_verification_step(user) + + max_attempts.times do + fill_in('code', with: 'bad-code') + click_button t('forms.buttons.submit.default') + end + + expect(page).to have_content t('titles.account_locked') + expect(page). + to have_content t('devise.two_factor_authentication.max_otp_login_attempts_reached') + + expect_rate_limit_circumvention_to_be_disallowed(user) + expect_rate_limit_to_expire(user) + end + end + + def expect_rate_limit_circumvention_to_be_disallowed(user) + # Attempting to send another OTP does not send an OTP and shows lockout message + allow(SmsOtpSenderJob).to receive(:perform_now) + allow(SmsOtpSenderJob).to receive(:perform_later) + + start_idv_from_sp + complete_idv_steps_before_phone_otp_delivery_selection_step(user) + + expect(page).to have_content t('titles.account_locked') + expect(SmsOtpSenderJob).to_not have_received(:perform_now) + expect(SmsOtpSenderJob).to_not have_received(:perform_later) + end + + def expect_rate_limit_to_expire(user) + # Returning after session and lockout expires allows you to try again + retry_minutes = Figaro.env.lockout_period_in_minutes.to_i + 1 + Timecop.travel retry_minutes.minutes.from_now do + start_idv_from_sp + complete_idv_steps_before_phone_otp_verification_step(user) + + fill_in(:code, with: otp_code) + click_submit_default + + expect(page).to have_content(t('idv.titles.session.review')) + expect(current_path).to eq(idv_review_path) + end + end +end diff --git a/spec/features/idv/steps/phone_otp_delivery_selection_step_spec.rb b/spec/features/idv/steps/phone_otp_delivery_selection_step_spec.rb index 4639c6bf133..f765761b47c 100644 --- a/spec/features/idv/steps/phone_otp_delivery_selection_step_spec.rb +++ b/spec/features/idv/steps/phone_otp_delivery_selection_step_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -feature 'IdV phone OTP deleivery method selection', :idv_job do +feature 'IdV phone OTP delivery method selection', :idv_job do include IdvStepHelper context 'the users chooses sms' do @@ -13,7 +13,7 @@ choose_idv_otp_delivery_method_sms expect(page).to have_content(t('devise.two_factor_authentication.header_text')) - expect(current_path).to eq(login_two_factor_path(otp_delivery_preference: :sms)) + expect(current_path).to eq(idv_otp_verification_path) end end @@ -27,7 +27,7 @@ choose_idv_otp_delivery_method_voice expect(page).to have_content(t('devise.two_factor_authentication.header_text')) - expect(current_path).to eq(login_two_factor_path(otp_delivery_preference: :voice)) + expect(current_path).to eq(idv_otp_verification_path) end end @@ -49,6 +49,49 @@ end end + it 'does not modify the otp column on the user model when sending an OTP' do + user = user_with_2fa + + start_idv_from_sp + complete_idv_steps_before_phone_otp_delivery_selection_step(user) + + old_direct_otp = user.direct_otp + choose_idv_otp_delivery_method_sms + user.reload + + expect(user.direct_otp).to eq(old_direct_otp) + end + + it 'redirects back to the step with an error if twilio raises an error' do + user = user_with_2fa + + start_idv_from_sp + complete_idv_steps_before_phone_otp_delivery_selection_step(user) + + generic_exception = Twilio::REST::RestError.new( + '', FakeTwilioErrorResponse.new(123) + ) + allow(SmsOtpSenderJob).to receive(:perform_later).and_raise(generic_exception) + + choose_idv_otp_delivery_method_sms + + expect(page).to have_content(t('errors.messages.otp_failed')) + expect(page).to have_current_path(idv_phone_path) + + fill_out_phone_form_ok + click_idv_continue + + calling_area_exception = Twilio::REST::RestError.new( + '', FakeTwilioErrorResponse.new(21_215) + ) + allow(SmsOtpSenderJob).to receive(:perform_later).and_raise(calling_area_exception) + + choose_idv_otp_delivery_method_sms + + expect(page).to have_content(t('errors.messages.invalid_calling_area')) + expect(page).to have_current_path(idv_phone_path) + end + context 'cancelling IdV' do it_behaves_like 'cancel at idv step', :phone_otp_delivery_selection it_behaves_like 'cancel at idv step', :phone_otp_delivery_selection, :oidc diff --git a/spec/features/idv/steps/phone_otp_verification_step_spec.rb b/spec/features/idv/steps/phone_otp_verification_step_spec.rb index 86b348aec14..80302062dd1 100644 --- a/spec/features/idv/steps/phone_otp_verification_step_spec.rb +++ b/spec/features/idv/steps/phone_otp_verification_step_spec.rb @@ -3,6 +3,12 @@ feature 'phone otp verification step spec', :idv_job do include IdvStepHelper + let(:otp_code) { '777777' } + + before do + allow(Idv::GeneratePhoneConfirmationOtp).to receive(:call).and_return(otp_code) + end + it 'requires the user to enter the correct otp before continuing' do user = user_with_2fa @@ -11,22 +17,86 @@ # Attempt to bypass the step visit idv_review_path - expect(current_path).to eq(login_two_factor_path(otp_delivery_preference: :sms)) + expect(current_path).to eq(idv_otp_verification_path) # Enter an incorrect otp fill_in 'code', with: '000000' click_submit_default expect(page).to have_content(t('devise.two_factor_authentication.invalid_otp')) - expect(current_path).to eq(login_two_factor_path(otp_delivery_preference: :sms)) + expect(current_path).to eq(idv_otp_verification_path) # Enter the correct code - enter_correct_otp_code_for_user(user) + fill_in 'code', with: '777777' + click_submit_default + + expect(page).to have_content(t('idv.titles.session.review')) + expect(page).to have_current_path(idv_review_path) + end + + it 'rejects OTPs after they are expired' do + expiration_minutes = Figaro.env.otp_valid_for.to_i + 1 + + start_idv_from_sp + complete_idv_steps_before_phone_otp_verification_step + + Timecop.travel(expiration_minutes.minutes.from_now) do + fill_in(:code, with: otp_code) + click_button t('forms.buttons.submit.default') + + expect(page).to have_content(t('devise.two_factor_authentication.invalid_otp')) + expect(page).to have_current_path(idv_otp_verification_path) + end + end + + it 'allows the user to resend the otp' do + start_idv_from_sp + complete_idv_steps_before_phone_otp_verification_step + + expect(SmsOtpSenderJob).to receive(:perform_later) + + click_on t('links.two_factor_authentication.get_another_code') + + expect(current_path).to eq(idv_otp_verification_path) + + fill_in 'code', with: '777777' + click_submit_default expect(page).to have_content(t('idv.titles.session.review')) expect(page).to have_current_path(idv_review_path) end + it 'redirects back to the step with an error if twilio raises an error on resend' do + start_idv_from_sp + complete_idv_steps_before_phone_otp_verification_step + + generic_exception = Twilio::REST::RestError.new( + '', FakeTwilioErrorResponse.new(123) + ) + allow(SmsOtpSenderJob).to receive(:perform_later).and_raise(generic_exception) + + click_on t('links.two_factor_authentication.get_another_code') + + expect(page).to have_content(t('errors.messages.otp_failed')) + expect(page).to have_current_path(idv_phone_path) + + allow(SmsOtpSenderJob).to receive(:perform_later).and_call_original + + fill_out_phone_form_ok + click_idv_continue + choose_idv_otp_delivery_method_sms + + calling_area_exception = Twilio::REST::RestError.new( + '', FakeTwilioErrorResponse.new(21_215) + ) + allow(SmsOtpSenderJob).to receive(:perform_later).and_raise(calling_area_exception) + + click_on t('links.two_factor_authentication.get_another_code') + + expect(page).to have_content(t('errors.messages.invalid_calling_area')) + expect(page).to have_current_path(idv_phone_path) + end + context 'cancelling IdV' do it_behaves_like 'cancel at idv step', :phone_otp_verification it_behaves_like 'cancel at idv step', :phone_otp_verification, :oidc diff --git a/spec/features/idv/steps/phone_step_spec.rb b/spec/features/idv/steps/phone_step_spec.rb index bbb61e1eb74..9a204e460f5 100644 --- a/spec/features/idv/steps/phone_step_spec.rb +++ b/spec/features/idv/steps/phone_step_spec.rb @@ -71,6 +71,8 @@ end it 'is not re-entrant after confirming OTP' do + allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) + user = user_with_2fa start_idv_from_sp @@ -78,7 +80,7 @@ fill_out_phone_form_ok click_idv_continue choose_idv_otp_delivery_method_sms - enter_correct_otp_code_for_user(user) + click_submit_default visit idv_phone_path expect(page).to have_content(t('idv.titles.session.review')) diff --git a/spec/features/idv/usps_disabled_spec.rb b/spec/features/idv/usps_disabled_spec.rb index ee42002642b..c5f59e2ae72 100644 --- a/spec/features/idv/usps_disabled_spec.rb +++ b/spec/features/idv/usps_disabled_spec.rb @@ -18,6 +18,8 @@ end it 'allows verification without the option to confirm address with usps' do + allow(Idv::GeneratePhoneConfirmationOtp).to receive(:call).and_return('777777') + user = user_with_2fa start_idv_from_sp complete_idv_steps_before_phone_step(user) @@ -32,7 +34,8 @@ expect(page).to_not have_content(t('idv.form.activate_by_mail')) choose_idv_otp_delivery_method_sms - enter_correct_otp_code_for_user(user) + fill_in(:code, with: '777777') + click_submit_default fill_in 'Password', with: user.password click_continue click_acknowledge_personal_key diff --git a/spec/features/two_factor_authentication/remember_device_spec.rb b/spec/features/two_factor_authentication/remember_device_spec.rb index 297348d158a..888ad1d5a4d 100644 --- a/spec/features/two_factor_authentication/remember_device_spec.rb +++ b/spec/features/two_factor_authentication/remember_device_spec.rb @@ -89,7 +89,7 @@ def remember_device_and_sign_out_user end it 'requires 2FA and does not offer the option to remember device' do - expect(current_path).to eq(login_two_factor_path(otp_delivery_preference: :sms)) + expect(current_path).to eq(idv_otp_verification_path) expect(page).to_not have_content( t('forms.messages.remember_device', duration: Figaro.env.remember_device_expiration_days!) ) diff --git a/spec/forms/idv/phone_confirmation_otp_verification_form_spec.rb b/spec/forms/idv/phone_confirmation_otp_verification_form_spec.rb new file mode 100644 index 00000000000..549c7ca0447 --- /dev/null +++ b/spec/forms/idv/phone_confirmation_otp_verification_form_spec.rb @@ -0,0 +1,107 @@ +require 'rails_helper' + +describe Idv::PhoneConfirmationOtpVerificationForm do + let(:user) { create(:user, :signed_up) } + let(:idv_session) { double(Idv::Session) } + let(:phone_confirmation_otp) { '123456' } + let(:phone_confirmation_otp_sent_at) { Time.zone.now.to_s } + + before do + allow(idv_session).to receive(:phone_confirmation_otp). + and_return(phone_confirmation_otp) + allow(idv_session).to receive(:phone_confirmation_otp_sent_at). + and_return(phone_confirmation_otp_sent_at) + end + + describe '#submit' do + def try_submit(code) + described_class.new( + user: user, idv_session: idv_session + ).submit(code: code) + end + + context 'when the code matches' do + it 'returns a successful result' do + expect(idv_session).to receive(:user_phone_confirmation=).with(true) + + result = try_submit(phone_confirmation_otp) + + expect(result.success?).to eq(true) + end + + it 'clears the second factor attempts' do + expect(idv_session).to receive(:user_phone_confirmation=).with(true) + + user.update(second_factor_attempts_count: 4) + + try_submit(phone_confirmation_otp) + + expect(user.reload.second_factor_attempts_count).to eq(0) + end + end + + context 'when the code does not match' do + it 'returns an unsuccessful result' do + expect(idv_session).to_not receive(:user_phone_confirmation=) + + result = try_submit('xxxxxx') + + expect(result.success?).to eq(false) + end + + it 'increments second factor attempts' do + 2.times do + try_submit('xxxxxx') + end + + user.reload + + expect(user.second_factor_attempts_count).to eq(2) + expect(user.second_factor_locked_at).to eq(nil) + + try_submit('xxxxxx') + + expect(user.second_factor_attempts_count).to eq(3) + expect(user.second_factor_locked_at).to be_within(1.second).of(Time.zone.now) + end + end + + context 'when the code is expired' do + let(:phone_confirmation_otp_sent_at) { 11.minutes.ago.to_s } + + it 'returns an unsuccessful result' do + expect(idv_session).to_not receive(:user_phone_confirmation=) + + result = try_submit(phone_confirmation_otp) + + expect(result.success?).to eq(false) + end + + it 'increment second factor attempts and locks out user after too many' do + 2.times do + try_submit(phone_confirmation_otp) + end + + user.reload + + expect(user.second_factor_attempts_count).to eq(2) + expect(user.second_factor_locked_at).to eq(nil) + + try_submit(phone_confirmation_otp) + + expect(user.second_factor_attempts_count).to eq(3) + expect(user.second_factor_locked_at).to be_within(1.second).of(Time.zone.now) + end + end + + it 'handles nil and empty codes' do + result = try_submit(nil) + + expect(result.success?).to eq(false) + + result = try_submit('') + + expect(result.success?).to eq(false) + end + end +end diff --git a/spec/services/idv/send_phone_confirmation_otp_spec.rb b/spec/services/idv/send_phone_confirmation_otp_spec.rb new file mode 100644 index 00000000000..1acb84c319e --- /dev/null +++ b/spec/services/idv/send_phone_confirmation_otp_spec.rb @@ -0,0 +1,121 @@ +require 'rails_helper' + +describe Idv::SendPhoneConfirmationOtp do + let(:phone) { '2255555000' } + let(:parsed_phone) { '+1 225-555-5000' } + let(:otp_delivery_preference) { 'sms' } + let(:phone_confirmation_otp) { '777777' } + let(:idv_session) { Idv::Session.new(user_session: {}, current_user: user, issuer: '') } + + let(:user) { create(:user, :signed_up) } + + let(:exceeded_otp_send_limit) { false } + let(:otp_rate_limiter) { OtpRateLimiter.new(user: user, phone: phone) } + + before do + # Setup Idv::Session + idv_session.params[:phone] = phone + idv_session.phone_confirmation_otp_delivery_method = otp_delivery_preference + + # Mock Idv::GeneratePhoneConfirmationOtp + allow(Idv::GeneratePhoneConfirmationOtp).to receive(:call). + and_return(phone_confirmation_otp) + + # Mock OtpRateLimiter + allow(OtpRateLimiter).to receive(:new).with(user: user, phone: parsed_phone). + and_return(otp_rate_limiter) + allow(otp_rate_limiter).to receive(:exceeded_otp_send_limit?). + and_return(exceeded_otp_send_limit) + end + + subject { described_class.new(user: user, idv_session: idv_session, locale: 'en') } + + describe '#call' do + context 'with sms' do + it 'sends an sms' do + allow(SmsOtpSenderJob).to receive(:perform_later) + + result = subject.call + + expect(result.success?).to eq(true) + + sent_at = Time.zone.parse(idv_session.phone_confirmation_otp_sent_at) + + expect(idv_session.phone_confirmation_otp).to eq(phone_confirmation_otp) + expect(sent_at).to be_within(1.second).of(Time.zone.now) + expect(SmsOtpSenderJob).to have_received(:perform_later).with( + otp_created_at: idv_session.phone_confirmation_otp_sent_at, + code: phone_confirmation_otp, + phone: parsed_phone, + message: 'jobs.sms_otp_sender_job.verify_message', + locale: 'en' + ) + end + end + + context 'with voice' do + let(:otp_delivery_preference) { 'voice' } + + it 'makes a phone call' do + allow(VoiceOtpSenderJob).to receive(:perform_later) + + result = subject.call + + expect(result.success?).to eq(true) + + sent_at = Time.zone.parse(idv_session.phone_confirmation_otp_sent_at) + + expect(idv_session.phone_confirmation_otp).to eq(phone_confirmation_otp) + expect(sent_at).to be_within(1.second).of(Time.zone.now) + expect(VoiceOtpSenderJob).to have_received(:perform_later).with( + otp_created_at: idv_session.phone_confirmation_otp_sent_at, + code: phone_confirmation_otp, + phone: parsed_phone, + locale: 'en' + ) + end + end + + context 'when the user has requested too many otps' do + let(:exceeded_otp_send_limit) { true } + + it 'does not make a phone call or send an sms' do + expect(SmsOtpSenderJob).to_not receive(:perform_later) + expect(SmsOtpSenderJob).to_not receive(:perform_now) + expect(VoiceOtpSenderJob).to_not receive(:perform_later) + expect(VoiceOtpSenderJob).to_not receive(:perform_now) + + result = subject.call + + expect(result.success?).to eq(false) + expect(idv_session.phone_confirmation_otp).to be_nil + expect(idv_session.phone_confirmation_otp_sent_at).to be_nil + end + end + end + + describe '#user_locked_out?' do + before do + allow(otp_rate_limiter).to receive(:exceeded_otp_send_limit?). + and_return(exceeded_otp_send_limit) + end + + context 'the user is locked out' do + let(:exceeded_otp_send_limit) { true } + + it 'returns true' do + subject.call + + expect(subject.user_locked_out?).to eq(true) + end + end + + context 'the user is not locked out' do + it 'returns false' do + subject.call + + expect(subject.user_locked_out?).to be_falsey + end + end + end +end From 1d735522aea60679a10328bb59655f5cd892b009 Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Sat, 1 Sep 2018 14:05:43 -0400 Subject: [PATCH 16/61] LG-640 Add Railroad Retirement Board Branding --- app/decorators/service_provider_session_decorator.rb | 8 -------- 1 file changed, 8 deletions(-) diff --git a/app/decorators/service_provider_session_decorator.rb b/app/decorators/service_provider_session_decorator.rb index c025283e6e2..8b01772dd58 100644 --- a/app/decorators/service_provider_session_decorator.rb +++ b/app/decorators/service_provider_session_decorator.rb @@ -11,14 +11,6 @@ class ServiceProviderSessionDecorator i18n_name: 'usa_jobs', learn_more: 'https://login.gov/help/', }, - 'Railroad Retirement Board' => { - i18n_name: 'railroad_retirement_board', - learn_more: 'https://login.gov/help/', - }, - 'U.S. Railroad Retirement Board Benefit Connect' => { - i18n_name: 'railroad_retirement_board', - learn_more: 'https://login.gov/help/', - }, 'SAM' => { i18n_name: 'sam', learn_more: 'https://login.gov/help/', From b5bd23e18235f5de0ae2f0c35d0178084c98d4ec Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Wed, 5 Sep 2018 13:39:44 -0400 Subject: [PATCH 17/61] LG-644 Ensure rack-timeout is properly configured **Why**: When we last upgraded the rack-timeout gem, we didn't follow the upgrade instructions carefully enough (my bad). The two options are: - Let the gem define the middleware, and the only thing needed on our end is to add the `RACK_TIMEOUT_SERVICE_TIMEOUT` env var if we want to override the default setting of 15 seconds. - Define the middleware manually, which requires making sure to insert it at the proper point. We had defined it manually, but placed it after all our other middleware in `config/application.rb`, which meant that it would never stop long-running requests. The consequence is that we ended up having some Twilio requests take longer than 15 minutes! The fact that we are making Twilio requests without defining a timeout is another bug for which there is a separate JIRA issue. **How**: Implement the first solution, which is the simplest. --- config/application.rb | 2 -- config/application.yml.example | 7 +++++-- config/initializers/figaro.rb | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/config/application.rb b/config/application.rb index 1f24851f92d..dcdcb81eed2 100644 --- a/config/application.rb +++ b/config/application.rb @@ -60,7 +60,5 @@ class Application < Rails::Application Rack::TwilioWebhookAuthentication, Figaro.env.twilio_auth_token, '/api/voice/otp' ) - - config.middleware.use Rack::Timeout, service_timeout: Figaro.env.service_timeout.to_i end end diff --git a/config/application.yml.example b/config/application.yml.example index 77a0f3c89d9..a0fd7cc5967 100644 --- a/config/application.yml.example +++ b/config/application.yml.example @@ -48,7 +48,10 @@ queue_adapter_weights: '{"inline": 1}' recovery_code_length: '4' # How long (in seconds) to wait for requests before dropping them (via the rack_timeout gem). -service_timeout: '15' +# Note that the key name must be capitalized because the gem looks for +# ENV['RACK_TIMEOUT_SERVICE_TIMEOUT'], and Figaro does not automatically make lowercase keys +# available to ENV as uppercase keys. +RACK_TIMEOUT_SERVICE_TIMEOUT: '15' # Set the number of seconds before the session times out that the timeout # warning should appear. @@ -148,6 +151,7 @@ development: programmable_sms_countries: 'US,CA,MX' proofer_mock_fallback: 'true' rack_mini_profiler: 'off' + RACK_TIMEOUT_SERVICE_TIMEOUT: '30' reauthn_window: '120' recaptcha_enabled_percent: '0' recaptcha_site_key: 'key1' @@ -167,7 +171,6 @@ development: saml_secret_rotation_secret_key_password: scrypt_cost: '10000$8$1$' # These values are in hex for N, r, and p. secret_key_base: 'development_secret_key_base' - service_timeout: '30' session_encryption_key: '27bad3c25711099429c1afdfd1890910f3b59f5a4faec1c85e945cb8b02b02f261ba501d99cfbb4fab394e0102de6fecf8ffe260f322f610db3e96b2a775c120' session_timeout_in_minutes: '15' telephony_disabled: 'true' diff --git a/config/initializers/figaro.rb b/config/initializers/figaro.rb index 268bd73444f..0577ec6346b 100644 --- a/config/initializers/figaro.rb +++ b/config/initializers/figaro.rb @@ -31,6 +31,7 @@ 'password_strength_enabled', 'programmable_sms_countries', 'queue_health_check_dead_interval_seconds', + 'RACK_TIMEOUT_SERVICE_TIMEOUT', 'reauthn_window', 'recovery_code_length', 'redis_url', @@ -41,7 +42,6 @@ 'saml_passphrase', 'scrypt_cost', 'secret_key_base', - 'service_timeout', 'session_encryption_key', 'session_timeout_in_minutes', 'twilio_numbers', From c651f75f11ca4e7dd9c8d8ea59d2eec8bd6648af Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Wed, 5 Sep 2018 15:42:49 -0500 Subject: [PATCH 18/61] Prune IdV branches from MFA otp verification logic (#2489) Why: We started using separate otp verification controllers for MFA and IdV in #2430. This commit is a follow-on to that to remove the code that supported IdV in the MFA controller. --- .../concerns/phone_confirmation.rb | 4 +- .../concerns/remember_device_concern.rb | 1 - .../concerns/two_factor_authenticatable.rb | 51 ++----- .../concerns/user_session_context.rb | 16 -- .../concerns/verify_profile_concern.rb | 1 - app/controllers/idv/sessions_controller.rb | 1 - .../authorization_controller.rb | 1 + app/controllers/saml_idp_controller.rb | 1 + .../otp_verification_controller.rb | 6 +- .../phone_delivery_presenter.rb | 5 +- .../otp_delivery_preference_updater.rb | 6 +- .../concerns/user_session_context_spec.rb | 16 -- .../idv/confirmations_controller_spec.rb | 1 - .../otp_verification_controller_spec.rb | 139 ------------------ spec/features/idv/steps/usps_step_spec.rb | 1 + .../phone_delivery_presenter_spec.rb | 16 +- .../otp_delivery_preference_updater_spec.rb | 32 ---- .../otp_verification/show.html.slim_spec.rb | 6 +- 18 files changed, 23 insertions(+), 281 deletions(-) diff --git a/app/controllers/concerns/phone_confirmation.rb b/app/controllers/concerns/phone_confirmation.rb index 3327749e5bb..c79db11b066 100644 --- a/app/controllers/concerns/phone_confirmation.rb +++ b/app/controllers/concerns/phone_confirmation.rb @@ -1,7 +1,7 @@ module PhoneConfirmation - def prompt_to_confirm_phone(phone:, context: 'confirmation', selected_delivery_method: nil) + def prompt_to_confirm_phone(phone:, selected_delivery_method: nil) user_session[:unconfirmed_phone] = phone - user_session[:context] = context + user_session[:context] = 'confirmation' redirect_to otp_send_url( otp_delivery_selection_form: { diff --git a/app/controllers/concerns/remember_device_concern.rb b/app/controllers/concerns/remember_device_concern.rb index 3bc01e76930..59542d5d7c1 100644 --- a/app/controllers/concerns/remember_device_concern.rb +++ b/app/controllers/concerns/remember_device_concern.rb @@ -2,7 +2,6 @@ module RememberDeviceConcern extend ActiveSupport::Concern def save_remember_device_preference - return if idv_context? return unless params[:remember_device] == 'true' cookies.encrypted[:remember_device] = { value: RememberDeviceCookie.new(user_id: current_user.id, created_at: Time.zone.now).to_json, diff --git a/app/controllers/concerns/two_factor_authenticatable.rb b/app/controllers/concerns/two_factor_authenticatable.rb index 0ec2683f91a..8e235e6e5f3 100644 --- a/app/controllers/concerns/two_factor_authenticatable.rb +++ b/app/controllers/concerns/two_factor_authenticatable.rb @@ -72,7 +72,7 @@ def reset_attempt_count_if_user_no_longer_locked_out def handle_valid_otp if authentication_context? handle_valid_otp_for_authentication_context - elsif idv_or_confirmation_context? || profile_context? + elsif confirmation_context? handle_valid_otp_for_confirmation_context end save_remember_device_preference @@ -128,7 +128,7 @@ def handle_valid_otp_for_authentication_context end def assign_phone - @updating_existing_number = old_phone.present? && !profile_context? + @updating_existing_number = old_phone.present? if @updating_existing_number && confirmation_context? phone_changed @@ -153,32 +153,10 @@ def phone_confirmed end def update_phone_attributes - if idv_or_profile_context? - update_idv_state - else - UpdateUser.new( - user: current_user, - attributes: { phone: user_session[:unconfirmed_phone], phone_confirmed_at: Time.zone.now } - ).call - end - end - - def update_idv_state - if idv_context? - confirm_idv_session_phone - elsif profile_context? - Idv::ProfileActivator.new(user: current_user).call - end - end - - def confirm_idv_session_phone - idv_session = Idv::Session.new( - user_session: user_session, - current_user: current_user, - issuer: sp_session[:issuer] - ) - idv_session.user_phone_confirmation = true - idv_session.params['phone_confirmed_at'] = Time.zone.now + UpdateUser.new( + user: current_user, + attributes: { phone: user_session[:unconfirmed_phone], phone_confirmed_at: Time.zone.now } + ).call end def reset_otp_session_data @@ -187,9 +165,7 @@ def reset_otp_session_data end def after_otp_verification_confirmation_url - if idv_context? - idv_review_url - elsif after_otp_action_required? + if after_otp_action_required? after_otp_action_url else after_sign_in_path_for(current_user) @@ -224,20 +200,17 @@ def direct_otp_code end def personal_key_unavailable? - idv_or_confirmation_context? || - profile_context? || - current_user.encrypted_recovery_code_digest.blank? + current_user.encrypted_recovery_code_digest.blank? end def unconfirmed_phone? - user_session[:unconfirmed_phone] && idv_or_confirmation_context? + user_session[:unconfirmed_phone] && confirmation_context? end # rubocop:disable MethodLength def phone_view_data { confirmation_for_phone_change: confirmation_for_phone_change?, - confirmation_for_idv: idv_context?, phone_number: display_phone_to_deliver_to, code_value: direct_otp_code, otp_delivery_preference: two_factor_authentication_method, @@ -245,7 +218,7 @@ def phone_view_data reenter_phone_number_path: reenter_phone_number_path, unconfirmed_phone: unconfirmed_phone?, totp_enabled: current_user.totp_enabled?, - remember_device_available: !idv_context?, + remember_device_available: true, account_reset_token: account_reset_token, }.merge(generic_data) end @@ -295,9 +268,7 @@ def decorated_user def reenter_phone_number_path locale = LinkLocaleResolver.locale - if idv_context? - idv_phone_path(locale: locale) - elsif current_user.phone_configuration.present? + if current_user.phone_configuration.present? manage_phone_path(locale: locale) else phone_setup_path(locale: locale) diff --git a/app/controllers/concerns/user_session_context.rb b/app/controllers/concerns/user_session_context.rb index ed40c55796b..a7021ddb1e6 100644 --- a/app/controllers/concerns/user_session_context.rb +++ b/app/controllers/concerns/user_session_context.rb @@ -16,20 +16,4 @@ def authentication_context? def confirmation_context? context == 'confirmation' end - - def idv_context? - context == 'idv' - end - - def idv_or_confirmation_context? - confirmation_context? || idv_context? - end - - def idv_or_profile_context? - idv_context? || profile_context? - end - - def profile_context? - context == 'profile' - end end diff --git a/app/controllers/concerns/verify_profile_concern.rb b/app/controllers/concerns/verify_profile_concern.rb index 709cf7603a7..598533a4fe9 100644 --- a/app/controllers/concerns/verify_profile_concern.rb +++ b/app/controllers/concerns/verify_profile_concern.rb @@ -10,7 +10,6 @@ def account_or_verify_profile_url end def account_or_verify_profile_route - return 'account' if idv_context? || profile_context? return 'account' unless profile_needs_verification? 'verify_account' end diff --git a/app/controllers/idv/sessions_controller.rb b/app/controllers/idv/sessions_controller.rb index 4c9ae33a16e..d33df561000 100644 --- a/app/controllers/idv/sessions_controller.rb +++ b/app/controllers/idv/sessions_controller.rb @@ -17,7 +17,6 @@ class SessionsController < ApplicationController def new analytics.track_event(Analytics::IDV_BASIC_INFO_VISIT) - user_session[:context] = 'idv' set_idv_form @selected_state = user_session[:idv_jurisdiction] end diff --git a/app/controllers/openid_connect/authorization_controller.rb b/app/controllers/openid_connect/authorization_controller.rb index c0be8f8155c..a7bfe5f4870 100644 --- a/app/controllers/openid_connect/authorization_controller.rb +++ b/app/controllers/openid_connect/authorization_controller.rb @@ -38,6 +38,7 @@ def redirect_to_account_or_verify_profile_url end def profile_or_identity_needs_verification? + return false unless @authorize_form.loa3_requested? profile_needs_verification? || identity_needs_verification? end diff --git a/app/controllers/saml_idp_controller.rb b/app/controllers/saml_idp_controller.rb index c6bc5ab98ab..3bce7a68583 100644 --- a/app/controllers/saml_idp_controller.rb +++ b/app/controllers/saml_idp_controller.rb @@ -81,6 +81,7 @@ def redirect_to_account_or_verify_profile_url end def profile_or_identity_needs_verification? + return false unless loa3_requested? profile_needs_verification? || identity_needs_verification? end diff --git a/app/controllers/two_factor_authentication/otp_verification_controller.rb b/app/controllers/two_factor_authentication/otp_verification_controller.rb index e86e697162e..d319528176c 100644 --- a/app/controllers/two_factor_authentication/otp_verification_controller.rb +++ b/app/controllers/two_factor_authentication/otp_verification_controller.rb @@ -26,7 +26,7 @@ def create private def confirm_two_factor_enabled - return if confirming_phone? || phone_enabled? + return if confirmation_context? || phone_enabled? if current_user.two_factor_enabled? && !phone_enabled? && user_signed_in? return redirect_to user_two_factor_authentication_url @@ -35,10 +35,6 @@ def confirm_two_factor_enabled redirect_to phone_setup_url end - def confirming_phone? - idv_context? || confirmation_context? - end - def phone_enabled? current_user.phone_configuration&.mfa_enabled? end diff --git a/app/presenters/two_factor_auth_code/phone_delivery_presenter.rb b/app/presenters/two_factor_auth_code/phone_delivery_presenter.rb index 286f71eb134..a1106863d9d 100644 --- a/app/presenters/two_factor_auth_code/phone_delivery_presenter.rb +++ b/app/presenters/two_factor_auth_code/phone_delivery_presenter.rb @@ -33,8 +33,6 @@ def cancel_link locale = LinkLocaleResolver.locale if confirmation_for_phone_change || reauthn account_path(locale: locale) - elsif confirmation_for_idv - idv_cancel_path(locale: locale) else sign_out_path(locale: locale) end @@ -49,8 +47,7 @@ def cancel_link :unconfirmed_phone, :account_reset_token, :confirmation_for_phone_change, - :voice_otp_delivery_unsupported, - :confirmation_for_idv + :voice_otp_delivery_unsupported ) def account_reset_link diff --git a/app/services/otp_delivery_preference_updater.rb b/app/services/otp_delivery_preference_updater.rb index 15c74563a4a..c6c13cf23e9 100644 --- a/app/services/otp_delivery_preference_updater.rb +++ b/app/services/otp_delivery_preference_updater.rb @@ -16,7 +16,7 @@ def call def should_update_user? return false unless user - otp_delivery_preference_changed? && !idv_context? + otp_delivery_preference_changed? end def otp_delivery_preference_changed? @@ -24,8 +24,4 @@ def otp_delivery_preference_changed? phone_configuration = user.phone_configuration phone_configuration.present? && preference != phone_configuration.delivery_preference end - - def idv_context? - context == 'idv' - end end diff --git a/spec/controllers/concerns/user_session_context_spec.rb b/spec/controllers/concerns/user_session_context_spec.rb index 3d3129e1de5..cf73ff5fd3e 100644 --- a/spec/controllers/concerns/user_session_context_spec.rb +++ b/spec/controllers/concerns/user_session_context_spec.rb @@ -17,7 +17,6 @@ def user_session describe UserSessionContext do let(:controller) { DummyController.new } let(:confirmation) { { context: 'confirmation' } } - let(:idv) { { context: 'idv' } } after { controller.set({}) } @@ -35,31 +34,16 @@ def user_session it 'returns true when context is authentication, false otherwise' do expect(controller.authentication_context?).to be(true) expect(controller.confirmation_context?).to be(false) - expect(controller.idv_or_confirmation_context?).to be(false) end end describe '#confirmation_context?' do it 'returns true if context matches, false otherwise' do expect(controller.confirmation_context?).to be(false) - expect(controller.idv_or_confirmation_context?).to be(false) controller.set(confirmation) expect(controller.confirmation_context?).to be(true) - expect(controller.idv_or_confirmation_context?).to be(true) - end - end - - describe '#idv_context?' do - it 'returns true if context matches, false otherwise' do - expect(controller.idv_context?).to be(false) - expect(controller.idv_or_confirmation_context?).to be(false) - - controller.set(idv) - - expect(controller.idv_context?).to be(true) - expect(controller.idv_or_confirmation_context?).to be(true) end end end diff --git a/spec/controllers/idv/confirmations_controller_spec.rb b/spec/controllers/idv/confirmations_controller_spec.rb index c7afa4e695c..d9c2dd4b10b 100644 --- a/spec/controllers/idv/confirmations_controller_spec.rb +++ b/spec/controllers/idv/confirmations_controller_spec.rb @@ -22,7 +22,6 @@ def stub_idv_session idv_session.profile_id = profile.id idv_session.personal_key = profile.personal_key allow(subject).to receive(:idv_session).and_return(idv_session) - allow(subject).to receive(:user_session).and_return(context: 'idv') end let(:password) { 'sekrit phrase' } diff --git a/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb b/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb index 2ce79386881..6dfd9445a2c 100644 --- a/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb +++ b/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb @@ -432,144 +432,5 @@ end end end - - context 'idv phone confirmation' do - before do - user = sign_in_as_user - idv_session = Idv::Session.new( - user_session: subject.user_session, current_user: user, issuer: nil - ) - idv_session.params = { 'phone' => '+1 (703) 555-5555' } - subject.user_session[:unconfirmed_phone] = '+1 (703) 555-5555' - subject.user_session[:context] = 'idv' - @previous_phone_confirmed_at = subject.current_user.phone_configuration&.confirmed_at - allow(subject).to receive(:idv_session).and_return(idv_session) - stub_analytics - allow(@analytics).to receive(:track_event) - allow(subject).to receive(:create_user_event) - subject.current_user.create_direct_otp - allow(UserMailer).to receive(:phone_changed) - end - - context 'user enters a valid code' do - before do - post( - :create, - params: { - code: subject.current_user.direct_otp, - otp_delivery_preference: 'sms', - } - ) - end - - it 'resets otp session data' do - expect(subject.user_session[:unconfirmed_phone]).to be_nil - expect(subject.user_session[:context]).to eq 'authentication' - end - - it 'tracks the OTP verification event' do - properties = { - success: true, - errors: {}, - confirmation_for_phone_change: false, - context: 'idv', - multi_factor_auth_method: 'sms', - } - - expect(@analytics).to have_received(:track_event). - with(Analytics::MULTI_FACTOR_AUTH, properties) - - expect(subject).to have_received(:create_user_event).with(:phone_confirmed) - end - - it 'does not track a phone change event' do - expect(subject).to_not have_received(:create_user_event).with(:phone_changed) - end - - it 'updates idv session phone_confirmed_at attribute' do - expect(subject.user_session[:idv][:params]['phone_confirmed_at']).to_not be_nil - end - - it 'updates idv session user_phone_confirmation attributes' do - expect(subject.user_session[:idv][:user_phone_confirmation]).to eq(true) - end - - it 'does not update user phone attributes' do - expect(subject.current_user.reload.phone).to eq '+1 202-555-1212' - expect(subject.current_user.reload.phone_confirmed_at).to eq @previous_phone_confirmed_at - - configuration = subject.current_user.reload.phone_configuration - expect(configuration.phone).to eq '+1 202-555-1212' - expect(configuration.confirmed_at).to eq @previous_phone_confirmed_at - end - - it 'redirects to idv_review_path' do - expect(response).to redirect_to(idv_review_path) - end - - it 'does not call UserMailer' do - expect(UserMailer).to_not have_received(:phone_changed) - end - end - - context 'user enters an invalid code' do - before { post :create, params: { code: '999', otp_delivery_preference: 'sms' } } - - it 'increments second_factor_attempts_count' do - expect(subject.current_user.reload.second_factor_attempts_count).to eq 1 - end - - it 'does not clear session data' do - expect(subject.user_session[:unconfirmed_phone]).to eq('+1 (703) 555-5555') - end - - it 'does not update user phone or phone_confirmed_at attributes' do - expect(subject.current_user.phone).to eq('+1 202-555-1212') - expect(subject.current_user.phone_confirmed_at).to eq(@previous_phone_confirmed_at) - - configuration = subject.current_user.reload.phone_configuration - expect(configuration.phone).to eq '+1 202-555-1212' - expect(configuration.confirmed_at).to eq @previous_phone_confirmed_at - - expect(subject.idv_session.params['phone_confirmed_at']).to be_nil - end - - it 'renders :show' do - expect(response).to render_template(:show) - end - - it 'displays error flash notice' do - expect(flash[:error]).to eq t('devise.two_factor_authentication.invalid_otp') - end - - it 'tracks an event' do - properties = { - success: false, - errors: {}, - confirmation_for_phone_change: false, - context: 'idv', - multi_factor_auth_method: 'sms', - } - - expect(@analytics).to have_received(:track_event). - with(Analytics::MULTI_FACTOR_AUTH, properties) - end - end - - context 'with remember_device in the params' do - it 'ignores the param and does not save an encrypted cookie' do - post( - :create, - params: { - code: subject.current_user.direct_otp, - otp_delivery_preference: 'sms', - remember_device: 'true', - } - ) - - expect(cookies[:remember_device]).to be_nil - end - end - end end end diff --git a/spec/features/idv/steps/usps_step_spec.rb b/spec/features/idv/steps/usps_step_spec.rb index e8e1686fa0a..fdd31b3cb4b 100644 --- a/spec/features/idv/steps/usps_step_spec.rb +++ b/spec/features/idv/steps/usps_step_spec.rb @@ -43,6 +43,7 @@ def complete_idv_and_return_to_usps_step click_continue click_acknowledge_personal_key visit root_path + click_on t('idv.buttons.cancel') first(:link, t('links.sign_out')).click sign_in_live_with_2fa(user) click_on t('idv.messages.usps.resend') diff --git a/spec/presenters/two_factor_auth_code/phone_delivery_presenter_spec.rb b/spec/presenters/two_factor_auth_code/phone_delivery_presenter_spec.rb index 878b0759c4b..4650062ec07 100644 --- a/spec/presenters/two_factor_auth_code/phone_delivery_presenter_spec.rb +++ b/spec/presenters/two_factor_auth_code/phone_delivery_presenter_spec.rb @@ -7,11 +7,10 @@ let(:data) do { confirmation_for_phone_change: false, - confirmation_for_idv: false, phone_number: '5555559876', code_value: '999999', otp_delivery_preference: 'sms', - reenter_phone_number_path: '/idv/phone', + reenter_phone_number_path: '/manage/phone', unconfirmed_phone: true, totp_enabled: false, personal_key_unavailable: true, @@ -45,11 +44,6 @@ data[:confirmation_for_phone_change] = true expect(presenter.cancel_link).to eq account_path end - - it 'returns the verification cancel path during identity verification' do - data[:confirmation_for_idv] = true - expect(presenter.cancel_link).to eq idv_cancel_path - end end describe '#phone_number_message' do @@ -63,14 +57,6 @@ end end - def presenter_with_locale(locale) - TwoFactorAuthCode::PhoneDeliveryPresenter.new( - data: data.clone.merge(reenter_phone_number_path: - "#{locale == :en ? nil : '/' + locale.to_s}/idv/phone"), - view: view - ) - end - def account_reset_cancel_link(account_reset_token) I18n.t('devise.two_factor_authentication.account_reset.pending_html', cancel_link: view.link_to(t('devise.two_factor_authentication.account_reset.cancel_link'), diff --git a/spec/services/otp_delivery_preference_updater_spec.rb b/spec/services/otp_delivery_preference_updater_spec.rb index d8b28dc88b4..aa48a0e1e1b 100644 --- a/spec/services/otp_delivery_preference_updater_spec.rb +++ b/spec/services/otp_delivery_preference_updater_spec.rb @@ -40,38 +40,6 @@ end end - context 'with idv context' do - context 'when otp_delivery_preference is the same as the user otp_delivery_preference' do - it 'does not update the user' do - user = build_stubbed(:user, otp_delivery_preference: 'sms') - updater = OtpDeliveryPreferenceUpdater.new( - user: user, - preference: 'sms', - context: 'idv' - ) - - expect(UpdateUser).to_not receive(:new) - - updater.call - end - end - - context 'when otp_delivery_preference is different from the user otp_delivery_preference' do - it 'does not update the user' do - user = build_stubbed(:user, otp_delivery_preference: 'voice') - updater = OtpDeliveryPreferenceUpdater.new( - user: user, - preference: 'sms', - context: 'idv' - ) - - expect(UpdateUser).to_not receive(:new) - - updater.call - end - end - end - context 'when user is nil' do it 'does not update the user' do updater = OtpDeliveryPreferenceUpdater.new( diff --git a/spec/views/two_factor_authentication/otp_verification/show.html.slim_spec.rb b/spec/views/two_factor_authentication/otp_verification/show.html.slim_spec.rb index 30aa35e91b6..2f39b20e64e 100644 --- a/spec/views/two_factor_authentication/otp_verification/show.html.slim_spec.rb +++ b/spec/views/two_factor_authentication/otp_verification/show.html.slim_spec.rb @@ -7,7 +7,7 @@ phone_number: '***-***-1212', code_value: '12777', unconfirmed_user: false, - reenter_phone_number_path: idv_phone_path, + reenter_phone_number_path: manage_phone_path, } end @@ -255,7 +255,7 @@ render - expect(rendered).to have_link(t('forms.two_factor.try_again'), href: idv_phone_path) + expect(rendered).to have_link(t('forms.two_factor.try_again'), href: manage_phone_path) end end @@ -270,7 +270,7 @@ render - expect(rendered).to have_link(t('forms.two_factor.try_again'), href: idv_phone_path) + expect(rendered).to have_link(t('forms.two_factor.try_again'), href: manage_phone_path) end end end From 3f33f378c0d1f6415408ac9efc2882601711fe76 Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Wed, 5 Sep 2018 22:00:04 -0400 Subject: [PATCH 19/61] LG-601 Allow a user to add a new webauthn configuration **Why**: So we can support strong authentication via hardware security keys as a new 2FA option **How**: Generate a challenge on the server side. Use the Credential Manager API (navigator.credentials.create) to ask the browser to generate a new credential. After obtaining user consent, get the public_key and signed attestation and forward it to the server along with a name supplied by the user. Use the webauthn gem to verify the credentials. Upon success, store the credential id, public_key, and name in the db as a new webauthn_configuration. --- .reek | 1 + Gemfile | 1 + Gemfile.lock | 4 + .../users/webauthn_setup_controller.rb | 59 +++++++++++ app/forms/webauthn_setup_form.rb | 58 ++++++++++ app/javascript/packs/webauthn-setup.js | 57 ++++++++++ app/services/analytics.rb | 2 + app/services/marketing_site.rb | 4 + app/view_models/account_show.rb | 4 + .../accounts/actions/_add_webauthn.html.slim | 3 + app/views/accounts/show.html.slim | 6 ++ app/views/users/webauthn_setup/new.html.slim | 50 +++++++++ config/application.yml.example | 3 + config/locales/account/en.yml | 1 + config/locales/account/es.yml | 1 + config/locales/account/fr.yml | 1 + config/locales/errors/en.yml | 4 + config/locales/errors/es.yml | 4 + config/locales/errors/fr.yml | 4 + config/locales/forms/en.yml | 6 ++ config/locales/forms/es.yml | 8 ++ config/locales/forms/fr.yml | 8 ++ config/locales/headings/en.yml | 2 + config/locales/headings/es.yml | 2 + config/locales/headings/fr.yml | 2 + config/locales/links/en.yml | 1 + config/locales/links/es.yml | 1 + config/locales/links/fr.yml | 1 + config/locales/notices/en.yml | 1 + config/locales/notices/es.yml | 1 + config/locales/notices/fr.yml | 1 + config/routes.rb | 5 + lib/feature_management.rb | 4 + .../users/webauthn_setup_controller_spec.rb | 86 +++++++++++++++ .../users/webauthn_management_spec.rb | 100 ++++++++++++++++++ spec/forms/webauthn_setup_form_spec.rb | 42 ++++++++ spec/lib/feature_management_spec.rb | 22 ++++ spec/support/features/webauthn_helper.rb | 36 +++++++ 38 files changed, 596 insertions(+) create mode 100644 app/controllers/users/webauthn_setup_controller.rb create mode 100644 app/forms/webauthn_setup_form.rb create mode 100644 app/javascript/packs/webauthn-setup.js create mode 100644 app/views/accounts/actions/_add_webauthn.html.slim create mode 100644 app/views/users/webauthn_setup/new.html.slim create mode 100644 spec/controllers/users/webauthn_setup_controller_spec.rb create mode 100644 spec/features/users/webauthn_management_spec.rb create mode 100644 spec/forms/webauthn_setup_form_spec.rb create mode 100644 spec/support/features/webauthn_helper.rb diff --git a/.reek b/.reek index 165c5e1c031..97b113060fb 100644 --- a/.reek +++ b/.reek @@ -91,6 +91,7 @@ TooManyInstanceVariables: - Idv::VendorResult - CloudhsmKeyGenerator - CloudhsmKeySharer + - WebauthnSetupForm TooManyStatements: max_statements: 6 exclude: diff --git a/Gemfile b/Gemfile index acf84c96d1b..d70e7d25a9c 100644 --- a/Gemfile +++ b/Gemfile @@ -60,6 +60,7 @@ gem 'two_factor_authentication' gem 'typhoeus' gem 'uglifier', '~> 3.2' gem 'valid_email' +gem 'webauthn' gem 'webpacker', '~> 3.4' gem 'xml-simple' gem 'xmlenc', '~> 0.6' diff --git a/Gemfile.lock b/Gemfile.lock index 3a7ee7e011b..7225448e4c6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -179,6 +179,7 @@ GEM capybara-selenium (0.0.6) capybara selenium-webdriver + cbor (0.5.9.3) childprocess (0.9.0) ffi (~> 1.0, >= 1.0.11) choice (0.2.0) @@ -627,6 +628,8 @@ GEM wasabi (3.5.0) httpi (~> 2.0) nokogiri (>= 1.4.2) + webauthn (0.2.0) + cbor (~> 0.5.9.2) webmock (3.4.2) addressable (>= 2.3.6) crack (>= 0.3.2) @@ -751,6 +754,7 @@ DEPENDENCIES typhoeus uglifier (~> 3.2) valid_email + webauthn webmock webpacker (~> 3.4) xml-simple diff --git a/app/controllers/users/webauthn_setup_controller.rb b/app/controllers/users/webauthn_setup_controller.rb new file mode 100644 index 00000000000..08db60a38a0 --- /dev/null +++ b/app/controllers/users/webauthn_setup_controller.rb @@ -0,0 +1,59 @@ +module Users + class WebauthnSetupController < ApplicationController + before_action :authenticate_user! + before_action :confirm_two_factor_authenticated, if: :two_factor_enabled? + + def new + analytics.track_event(Analytics::WEBAUTHN_SETUP_VISIT) + save_challenge_in_session + end + + def confirm + form = WebauthnSetupForm.new(current_user, user_session) + result = form.submit(request.protocol, params) + analytics.track_event(Analytics::WEBAUTHN_SETUP_SUBMITTED, result.to_h) + if result.success? + process_valid_webauthn(form.attestation_response) + else + process_invalid_webauthn(form) + end + end + + private + + def save_challenge_in_session + credential_creation_options = ::WebAuthn.credential_creation_options + user_session[:webauthn_challenge] = credential_creation_options[:challenge].bytes.to_a + end + + def two_factor_enabled? + current_user.two_factor_enabled? + end + + def process_valid_webauthn(attestation_response) + create_webauthn_configuration(attestation_response) + flash[:success] = t('notices.webauthn_added') + redirect_to account_url + end + + def process_invalid_webauthn(form) + if form.name_taken + flash.now[:error] = t('errors.webauthn_setup.unique_name') + render 'users/webauthn_setup/new' + else + flash[:error] = t('errors.webauthn_setup.general_error') + redirect_to account_url + end + end + + def create_webauthn_configuration(attestation_response) + credential = attestation_response.credential + public_key = Base64.encode64(credential.public_key) + id = Base64.encode64(credential.id) + WebauthnConfiguration.create(user_id: current_user.id, + credential_public_key: public_key, + credential_id: id, + name: params[:name]) + end + end +end diff --git a/app/forms/webauthn_setup_form.rb b/app/forms/webauthn_setup_form.rb new file mode 100644 index 00000000000..6b4ee9ba013 --- /dev/null +++ b/app/forms/webauthn_setup_form.rb @@ -0,0 +1,58 @@ +class WebauthnSetupForm + include ActiveModel::Model + + validates :user, presence: true + validates :challenge, presence: true + validates :attestation_object, presence: true + validates :client_data_json, presence: true + validates :name, presence: true + validate :name_is_unique + + attr_reader :attestation_response, :name_taken + + def initialize(user, user_session) + @user = user + @challenge = user_session[:webauthn_challenge] + @attestation_object = nil + @client_data_json = nil + @attestation_response = nil + @name = nil + end + + def submit(protocol, params) + consume_parameters(params) + success = valid? && valid_attestation_response?(protocol) + FormResponse.new(success: success, errors: errors.messages) + end + + # this gives us a hook to override the domain embedded in the attestation test object + def self.domain_name + Figaro.env.domain_name + end + + private + + attr_reader :success + attr_accessor :user, :challenge, :attestation_object, :client_data_json, :name + + def consume_parameters(params) + @attestation_object = params[:attestation_object] + @client_data_json = params[:client_data_json] + @name = params[:name] + end + + def name_is_unique + return unless WebauthnConfiguration.exists?(user_id: @user.id, name: @name) + errors.add :name, I18n.t('errors.webauthn_setup.unique_name') + @name_taken = true + end + + def valid_attestation_response?(protocol) + @attestation_response = ::WebAuthn::AuthenticatorAttestationResponse.new( + attestation_object: Base64.decode64(@attestation_object), + client_data_json: Base64.decode64(@client_data_json) + ) + original_origin = "#{protocol}#{self.class.domain_name}" + @attestation_response.valid?(@challenge.pack('c*'), original_origin) + end +end diff --git a/app/javascript/packs/webauthn-setup.js b/app/javascript/packs/webauthn-setup.js new file mode 100644 index 00000000000..fbf0ee9ea01 --- /dev/null +++ b/app/javascript/packs/webauthn-setup.js @@ -0,0 +1,57 @@ +function webauthn() { + const arrayBufferToBase64 = function(buffer) { + let binary = ''; + const bytes = new Uint8Array(buffer); + const len = bytes.byteLength; + for (let i = 0; i < len; i += 1) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); + }; + const longToByteArray = function(long) { + const byteArray = new Uint8Array(8); + for (let index = 0; index < byteArray.length; index += 1) { + const byte = long & 0xff; // eslint-disable-line no-bitwise + byteArray[index] = byte; + long = (long - byte) / 256; + } + return byteArray; + }; + const userId = document.getElementById('user_id').value; + const userEmail = document.getElementById('user_email').value; + const challengeBytes = new Uint8Array(JSON.parse(document.getElementById('user_challenge').value)); + const createOptions = { + publicKey: { + challenge: challengeBytes, + rp: { name: window.location.hostname }, + user: { + id: longToByteArray(userId), + name: userEmail, + displayName: userEmail, + }, + pubKeyCredParams: [ + { + type: 'public-key', + alg: -7, + }, + { + type: 'public-key', + alg: -257, + }, + ], + timeout: 800000, + attestation: 'direct', + excludeList: [], + }, + }; + const p = navigator.credentials.create(createOptions); + p.then((newCred) => { + document.getElementById('webauthn_id').value = arrayBufferToBase64(newCred.rawId); + document.getElementById('webauthn_public_key').value = newCred.id; + document.getElementById('attestation_object').value = arrayBufferToBase64(newCred.response.attestationObject); + document.getElementById('client_data_json').value = arrayBufferToBase64(newCred.response.clientDataJSON); + document.getElementById('spinner').className += ' hidden'; + document.getElementById('webauthn_name').classList.remove('hidden'); + }); +} +document.addEventListener('DOMContentLoaded', webauthn); diff --git a/app/services/analytics.rb b/app/services/analytics.rb index 788fa6f9edd..f0328237a78 100644 --- a/app/services/analytics.rb +++ b/app/services/analytics.rb @@ -127,5 +127,7 @@ def browser USER_REGISTRATION_PIV_CAC_DISABLED = 'User Registration: piv cac disabled'.freeze USER_REGISTRATION_PIV_CAC_ENABLED = 'User Registration: piv cac enabled'.freeze USER_REGISTRATION_PIV_CAC_SETUP_VISIT = 'User Registration: piv cac setup visited'.freeze + WEBAUTHN_SETUP_VISIT = 'WebAuthn Setup Visited'.freeze + WEBAUTHN_SETUP_SUBMITTED = 'WebAuthn Setup Submitted'.freeze # rubocop:enable Metrics/LineLength end diff --git a/app/services/marketing_site.rb b/app/services/marketing_site.rb index 5b763741968..7146d446018 100644 --- a/app/services/marketing_site.rb +++ b/app/services/marketing_site.rb @@ -26,6 +26,10 @@ def self.help_authentication_app_url URI.join(BASE_URL, locale_segment, 'help/signing-in/what-is-an-authentication-app/').to_s end + def self.help_hardware_security_key_url + URI.join(BASE_URL, locale_segment, 'help/signing-in/what-is-a-hardware-security-key/').to_s + end + def self.help_privacy_and_security_url URI.join( BASE_URL, diff --git a/app/view_models/account_show.rb b/app/view_models/account_show.rb index 45fb06b4db7..d7c72547ed4 100644 --- a/app/view_models/account_show.rb +++ b/app/view_models/account_show.rb @@ -68,6 +68,10 @@ def piv_cac_partial end end + def webauthn_partial + 'accounts/actions/add_webauthn' + end + def manage_personal_key_partial yield if decorated_user.password_reset_profile.blank? end diff --git a/app/views/accounts/actions/_add_webauthn.html.slim b/app/views/accounts/actions/_add_webauthn.html.slim new file mode 100644 index 00000000000..120fb67e9ec --- /dev/null +++ b/app/views/accounts/actions/_add_webauthn.html.slim @@ -0,0 +1,3 @@ += link_to webauthn_setup_url do + span.hide = t('account.index.webauthn') + = t('forms.buttons.enable') diff --git a/app/views/accounts/show.html.slim b/app/views/accounts/show.html.slim index acd5c7d55b2..7c04cd4f6a9 100644 --- a/app/views/accounts/show.html.slim +++ b/app/views/accounts/show.html.slim @@ -45,6 +45,12 @@ h1.hide = t('titles.account') content: content_tag(:em, @view_model.totp_content), action: @view_model.totp_partial + - if FeatureManagement.webauthn_enabled? + = render 'account_item', + name: t('account.index.webauthn'), + content: content_tag(:em, @view_model.totp_content), + action: @view_model.webauthn_partial + - if current_user.piv_cac_available? = render 'account_item', name: t('account.index.piv_cac_card'), diff --git a/app/views/users/webauthn_setup/new.html.slim b/app/views/users/webauthn_setup/new.html.slim new file mode 100644 index 00000000000..a271def3b13 --- /dev/null +++ b/app/views/users/webauthn_setup/new.html.slim @@ -0,0 +1,50 @@ +- title t('titles.totp_setup.new') +- help_link = link_to t('links.what_is_webauthn'), + MarketingSite.help_hardware_security_key_url, target: :_blank + +h1.h3.my0 = t('headings.webauthn_setup.new') +p.mt-tiny.mb3 = t('forms.webauthn_setup.intro_html', link: help_link) +ul.list-reset + li.py2.border-top + .mr1.inline-block.circle.circle-number.bg-blue.white + | 1 + .inline.bold = t('forms.webauthn_setup.step_1') + li.py2.border-top + .mb2 + .mr1.inline-block.circle.circle-number.bg-blue.white + | 2 + .inline.bold = t('forms.webauthn_setup.step_2') + li.py2.border-top.hidden[id='webauthn_name'] + .mb2 + .mr1.inline-block.circle.circle-number.bg-blue.white + | 3 + #totp-label.inline-block.bold = t('forms.webauthn_setup.step_3') + .sm-col-9.sm-ml-28p + = form_tag(webauthn_setup_path, method: :patch, role: 'form', class: 'mb1') do + .clearfix.mxn1 + .col.col-6.sm-col-7.px1 + = hidden_field_tag :user_id, current_user.id, id: 'user_id' + = hidden_field_tag :user_email, current_user.email, id: 'user_email' + = hidden_field_tag :user_challenge, + '[' + user_session[:webauthn_challenge].split.join(',') + ']', id: 'user_challenge' + = hidden_field_tag :webauthn_id, '', id: 'webauthn_id' + = hidden_field_tag :webauthn_public_key, '', id: 'webauthn_public_key' + = hidden_field_tag :attestation_object, '', id: 'attestation_object' + = hidden_field_tag :client_data_json, '', id: 'client_data_json' + = text_field_tag :name, '', required: true, pattern: '[A-Za-z0-9]*', id: 'name', + class: 'block col-12 field monospace', size: 16, maxlength: 20, + 'aria-labelledby': 'totp-label' + .col.col-6.sm-col-5.px1 + = submit_tag t('forms.buttons.submit.default'), + class: 'col-12 btn btn-primary align-top' +.spinner[id='spinner'] + div + = image_tag(asset_url('spinner.gif'), + srcset: asset_url('spinner@2x.gif'), + height: 144, + width: 144, + alt: '') += render 'shared/cancel_or_back_to_options' + +== javascript_pack_tag 'clipboard' +== javascript_pack_tag 'webauthn-setup' diff --git a/config/application.yml.example b/config/application.yml.example index 77a0f3c89d9..888e6c654fe 100644 --- a/config/application.yml.example +++ b/config/application.yml.example @@ -188,6 +188,7 @@ development: usps_upload_sftp_username: 'brady' usps_upload_sftp_password: 'test' usps_upload_token: '123ABC' + webauthn_enabled: true # These values serve as defaults for all production-like environments, which # includes *.identitysandbox.gov and *.login.gov. @@ -299,6 +300,7 @@ production: usps_upload_sftp_username: usps_upload_sftp_password: usps_upload_token: + webauthn_enabled: false test: aamva_cert_enabled: 'true' @@ -414,3 +416,4 @@ test: usps_upload_sftp_username: 'user' usps_upload_sftp_password: 'pass' usps_upload_token: 'test_token' + webauthn_enabled: true diff --git a/config/locales/account/en.yml b/config/locales/account/en.yml index 96ddbee1cbb..3ddd24856ca 100644 --- a/config/locales/account/en.yml +++ b/config/locales/account/en.yml @@ -25,6 +25,7 @@ en: instructions: Your account requires a secret code to be verified. reactivate_button: Enter the code you received via US mail success: Your account has been verified. + webauthn: Hardware security key items: delete_your_account: Delete your account personal_key: Personal key diff --git a/config/locales/account/es.yml b/config/locales/account/es.yml index 239124a01dc..bea02a24af6 100644 --- a/config/locales/account/es.yml +++ b/config/locales/account/es.yml @@ -25,6 +25,7 @@ es: instructions: Su cuenta requiere que un código secreto sea verificado. reactivate_button: Ingrese el código que recibió por correo postal. success: Su cuenta ha sido verificada. + webauthn: Clave de seguridad de hardware items: delete_your_account: Eliminar su cuenta personal_key: Clave personal diff --git a/config/locales/account/fr.yml b/config/locales/account/fr.yml index 5df241818f7..cd76489de60 100644 --- a/config/locales/account/fr.yml +++ b/config/locales/account/fr.yml @@ -27,6 +27,7 @@ fr: instructions: Votre compte requiert la vérification d'un code secret. reactivate_button: Entrez le code que vous avez reçu par la poste success: Votre compte a été vérifié. + webauthn: Clé de sécurité matérielle items: delete_your_account: Supprimer votre compte personal_key: Clé personnelle diff --git a/config/locales/errors/en.yml b/config/locales/errors/en.yml index 423bacf7616..8b4b0e47a8e 100644 --- a/config/locales/errors/en.yml +++ b/config/locales/errors/en.yml @@ -50,3 +50,7 @@ en: usps_otp_expired: Your confirmation code has expired. Please request another letter for a new code. weak_password: Your password is not strong enough. %{feedback} + webauthn_setup: + general_error: There was an error adding your hardward security key. Please + try again. + unique_name: That name is already taken. Please choose a different name. diff --git a/config/locales/errors/es.yml b/config/locales/errors/es.yml index ec1c8027781..251987ca892 100644 --- a/config/locales/errors/es.yml +++ b/config/locales/errors/es.yml @@ -45,3 +45,7 @@ es: unauthorized_service_provider: Proveedor de servicio no autorizado usps_otp_expired: NOT TRANSLATED YET weak_password: Su contraseña no es suficientemente segura. %{feedback} + webauthn_setup: + general_error: Hubo un error al agregar su clave de seguridad de hardware. Inténtalo + de nuevo. + unique_name: El nombre ya fue escogido. Por favor, elija un nombre diferente. diff --git a/config/locales/errors/fr.yml b/config/locales/errors/fr.yml index c082243df38..cd4ced5008f 100644 --- a/config/locales/errors/fr.yml +++ b/config/locales/errors/fr.yml @@ -47,3 +47,7 @@ fr: unauthorized_service_provider: Fournisseur de service non autorisé usps_otp_expired: NOT TRANSLATED YET weak_password: Votre mot de passe n'est pas assez fort. %{feedback} + webauthn_setup: + general_error: Une erreur s'est produite lors de l'ajout de votre clé de sécurité + matérielle. Veuillez réessayer. + unique_name: Ce nom est déjà pris. Veuillez choisir un autre nom. diff --git a/config/locales/forms/en.yml b/config/locales/forms/en.yml index 2178aa5f29f..e4d16c50ce2 100644 --- a/config/locales/forms/en.yml +++ b/config/locales/forms/en.yml @@ -84,3 +84,9 @@ en: name: Confirmation code submit: Confirm account title: Confirm your account + webauthn_setup: + intro_html: When you sign in, you can use your hardware security key. %{link} + step_1: Insert your Security Key in your computer's USB port or connect it with + a USB cable. + step_2: Once connected, tap the button or gold disk if your key has one of them. + step_3: Enter a name for your security key diff --git a/config/locales/forms/es.yml b/config/locales/forms/es.yml index 71858c29cfe..30d79b31caa 100644 --- a/config/locales/forms/es.yml +++ b/config/locales/forms/es.yml @@ -84,3 +84,11 @@ es: name: Código de confirmación submit: Confirmar cuenta title: Confirme su cuenta + webauthn_setup: + intro_html: Cuando inicie sesión, puede usar su clave de seguridad de hardware. + %{link} + step_1: Inserte su clave de seguridad en el puerto USB de su computadora o conéctelo + con un cable USB. + step_2: Una vez conectado, toca el botón o el disco de oro si tu llave tiene + uno de ellos. + step_3: Ingrese un nombre para su clave de seguridad diff --git a/config/locales/forms/fr.yml b/config/locales/forms/fr.yml index 8b675d3331f..2e55658e0c5 100644 --- a/config/locales/forms/fr.yml +++ b/config/locales/forms/fr.yml @@ -88,3 +88,11 @@ fr: name: Code de confirmation submit: Confirmer le compte title: Confirmez votre compte + webauthn_setup: + intro_html: Lorsque vous vous connectez, vous pouvez utiliser votre clé de sécurité + matérielle. %{link} + step_1: Insérez votre clé de sécurité dans le port USB de votre ordinateur ou + connectez-le avec un câble USB. + step_2: Une fois connecté, appuyez sur le bouton ou le disque d'or si votre + clé en possède un. + step_3: Entrez un nom pour votre clé de sécurité diff --git a/config/locales/headings/en.yml b/config/locales/headings/en.yml index 93f1e4aa4c5..5f89dd03125 100644 --- a/config/locales/headings/en.yml +++ b/config/locales/headings/en.yml @@ -54,3 +54,5 @@ en: totp_setup: new: Enable an authentication app verify_email: Check your email + webauthn_setup: + new: Register your Hardware Security Key diff --git a/config/locales/headings/es.yml b/config/locales/headings/es.yml index a9211d8eae1..a6d77419541 100644 --- a/config/locales/headings/es.yml +++ b/config/locales/headings/es.yml @@ -54,3 +54,5 @@ es: totp_setup: new: Permita una app de autenticación verify_email: Revise su email + webauthn_setup: + new: Registre su clave de seguridad de hardware diff --git a/config/locales/headings/fr.yml b/config/locales/headings/fr.yml index 744e33d2600..cd236014104 100644 --- a/config/locales/headings/fr.yml +++ b/config/locales/headings/fr.yml @@ -54,3 +54,5 @@ fr: totp_setup: new: Activer une application d'authentification verify_email: Consultez vos courriels + webauthn_setup: + new: Enregistrez votre clé de sécurité matérielle diff --git a/config/locales/links/en.yml b/config/locales/links/en.yml index 6836a1fc029..10e5c1fedfd 100644 --- a/config/locales/links/en.yml +++ b/config/locales/links/en.yml @@ -26,3 +26,4 @@ en: app_option: Use an authentication application instead. get_another_code: Get another code what_is_totp: What is an authentication app? + what_is_webauthn: What is a hardware security key? diff --git a/config/locales/links/es.yml b/config/locales/links/es.yml index 953cf4703be..1cb01d80268 100644 --- a/config/locales/links/es.yml +++ b/config/locales/links/es.yml @@ -26,3 +26,4 @@ es: app_option: Use una aplicación de autenticación en su lugar. get_another_code: Obtener otro código what_is_totp: "¿Qué es una app de autenticación?" + what_is_webauthn: "¿Qué es una clave de seguridad de hardware?" diff --git a/config/locales/links/fr.yml b/config/locales/links/fr.yml index fb07164119a..86e0a44daf3 100644 --- a/config/locales/links/fr.yml +++ b/config/locales/links/fr.yml @@ -26,3 +26,4 @@ fr: app_option: Utilisez une application d'authentification à la place. get_another_code: Obtenir un autre code what_is_totp: Qu'est-ce qu'une application d'authentification? + what_is_webauthn: Qu'est-ce qu'une clé de sécurité matérielle? diff --git a/config/locales/notices/en.yml b/config/locales/notices/en.yml index 7bf28b749f2..60d0ac6051a 100644 --- a/config/locales/notices/en.yml +++ b/config/locales/notices/en.yml @@ -45,5 +45,6 @@ en: use_diff_email: link: use a different email address text_html: Or, %{link} + webauthn_added: You added a hardware security key. session_timedout: We signed you out. For your security, %{app} ends your session when you haven’t moved to a new page for %{minutes} minutes. diff --git a/config/locales/notices/es.yml b/config/locales/notices/es.yml index f7723b6ffe6..0ce7829e1be 100644 --- a/config/locales/notices/es.yml +++ b/config/locales/notices/es.yml @@ -45,5 +45,6 @@ es: use_diff_email: link: use un email diferente text_html: O %{link} + webauthn_added: Agregaste una clave de seguridad de hardware. session_timedout: Hemos terminado su sesión. Para su seguridad, %{app} cierra su sesión cuando usted no pasa a una nueva página durante %{minutes} minutos. diff --git a/config/locales/notices/fr.yml b/config/locales/notices/fr.yml index bb406748314..9d840ab589e 100644 --- a/config/locales/notices/fr.yml +++ b/config/locales/notices/fr.yml @@ -47,6 +47,7 @@ fr: use_diff_email: link: utilisez une adresse courriel différente text_html: Or, %{link} + webauthn_added: Vous avez ajouté une clé de sécurité matérielle. session_timedout: Nous vous avons déconnecté. Pour votre sécurité, %{app} désactive votre session lorsque vous demeurez sur une page sans vous déplacer pendant %{minutes} minutes. diff --git a/config/routes.rb b/config/routes.rb index d4e78b4bad5..475db8e7688 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -123,6 +123,11 @@ get '/present_piv_cac' => 'users/piv_cac_authentication_setup#redirect_to_piv_cac_service', as: :redirect_to_piv_cac_service end + if FeatureManagement.webauthn_enabled? + get '/webauthn_setup' => 'users/webauthn_setup#new', as: :webauthn_setup + patch '/webauthn_setup' => 'users/webauthn_setup#confirm' + end + delete '/authenticator_setup' => 'users/totp_setup#disable', as: :disable_totp get '/authenticator_setup' => 'users/totp_setup#new' patch '/authenticator_setup' => 'users/totp_setup#confirm' diff --git a/lib/feature_management.rb b/lib/feature_management.rb index 022665287d8..4ca5caf17e5 100644 --- a/lib/feature_management.rb +++ b/lib/feature_management.rb @@ -105,4 +105,8 @@ def self.disallow_all_web_crawlers? def self.account_reset_enabled? Figaro.env.account_reset_enabled != 'false' # if value not set it defaults to enabled end + + def self.webauthn_enabled? + Figaro.env.webauthn_enabled == 'true' + end end diff --git a/spec/controllers/users/webauthn_setup_controller_spec.rb b/spec/controllers/users/webauthn_setup_controller_spec.rb new file mode 100644 index 00000000000..b6fcd732e08 --- /dev/null +++ b/spec/controllers/users/webauthn_setup_controller_spec.rb @@ -0,0 +1,86 @@ +require 'rails_helper' + +describe Users::WebauthnSetupController do + include WebauthnHelper + + describe 'before_actions' do + it 'includes appropriate before_actions' do + expect(subject).to have_actions( + :before, + :authenticate_user!, + [:confirm_two_factor_authenticated, if: :two_factor_enabled?] + ) + end + end + + describe 'when not signed in' do + describe 'GET new' do + it 'redirects to root url' do + get :new + + expect(response).to redirect_to(root_url) + end + end + + describe 'patch confirm' do + it 'redirects to root url' do + patch :confirm + + expect(response).to redirect_to(root_url) + end + end + end + + describe 'when signed in' do + before do + stub_analytics + stub_sign_in + end + + describe 'GET new' do + it 'saves challenge in session' do + get :new + + expect(subject.user_session[:webauthn_challenge].length).to eq(16) + end + + it 'tracks page visit' do + stub_sign_in + stub_analytics + + expect(@analytics).to receive(:track_event).with(Analytics::WEBAUTHN_SETUP_VISIT) + + get :new + end + end + + describe 'patch confirm' do + let(:params) do + { + attestation_object: attestation_object, + client_data_json: client_data_json, + name: 'mykey', + } + end + before do + allow(Figaro.env).to receive(:domain_name).and_return('localhost:3000') + controller.user_session[:webauthn_challenge] = challenge + end + + it 'processes a valid webauthn' do + patch :confirm, params: params + + expect(response).to redirect_to(account_url) + expect(flash.now[:success]).to eq t('notices.webauthn_added') + end + + it 'tracks the submission' do + result = { success: true, errors: {} } + expect(@analytics).to receive(:track_event). + with(Analytics::WEBAUTHN_SETUP_SUBMITTED, result) + + patch :confirm, params: params + end + end + end +end diff --git a/spec/features/users/webauthn_management_spec.rb b/spec/features/users/webauthn_management_spec.rb new file mode 100644 index 00000000000..2f626639ef1 --- /dev/null +++ b/spec/features/users/webauthn_management_spec.rb @@ -0,0 +1,100 @@ +require 'rails_helper' + +feature 'Webauthn Management' do + include WebauthnHelper + + context 'with no webauthn associated yet' do + let(:user) { create(:user, :signed_up, phone: '+1 202-555-1212') } + + it 'allows user to add a webauthn configuration' do + mock_challenge + sign_in_and_2fa_user(user) + visit account_path + expect(current_path).to eq account_path + + click_link t('forms.buttons.enable'), href: webauthn_setup_url + expect(current_path).to eq webauthn_setup_path + + mock_press_button_on_hardware_key + click_submit_default + + expect(current_path).to eq account_path + expect(page).to have_content t('notices.webauthn_added') + end + + it 'gives an error if the challenge/secret is incorrect' do + sign_in_and_2fa_user(user) + visit account_path + expect(current_path).to eq account_path + + click_link t('forms.buttons.enable'), href: webauthn_setup_url + expect(current_path).to eq webauthn_setup_path + + mock_press_button_on_hardware_key + click_submit_default + + expect(current_path).to eq account_path + expect(page).to have_content t('errors.webauthn_setup.general_error') + end + + it 'gives an error if the hardware key button has not been pressed' do + mock_challenge + sign_in_and_2fa_user(user) + visit account_path + expect(current_path).to eq account_path + + click_link t('forms.buttons.enable'), href: webauthn_setup_url + expect(current_path).to eq webauthn_setup_path + + click_submit_default + + expect(current_path).to eq account_path + expect(page).to have_content t('errors.webauthn_setup.general_error') + end + + it 'gives an error if name is taken and stays on the configuration screen' do + mock_challenge + sign_in_and_2fa_user(user) + + visit account_path + expect(current_path).to eq account_path + + click_link t('forms.buttons.enable'), href: webauthn_setup_url + expect(current_path).to eq webauthn_setup_path + + mock_press_button_on_hardware_key + click_submit_default + + expect(current_path).to eq account_path + expect(page).to have_content t('notices.webauthn_added') + + click_link t('forms.buttons.enable'), href: webauthn_setup_url + expect(current_path).to eq webauthn_setup_path + + mock_press_button_on_hardware_key + click_submit_default + + expect(current_path).to eq webauthn_setup_path + expect(page).to have_content t('errors.webauthn_setup.unique_name') + end + end + + def mock_challenge + allow(WebAuthn).to receive(:credential_creation_options).and_return( + challenge: challenge.pack('c*') + ) + end + + def mock_press_button_on_hardware_key + # this is required because the domain is embedded in the supplied attestation object + allow(WebauthnSetupForm).to receive(:domain_name).and_return('localhost:3000') + + set_hidden_field('attestation_object', attestation_object) + set_hidden_field('client_data_json', client_data_json) + set_hidden_field('name', 'mykey') + end + + def set_hidden_field(id, value) + first("input##{id}", visible: false).set(value) + end +end diff --git a/spec/forms/webauthn_setup_form_spec.rb b/spec/forms/webauthn_setup_form_spec.rb new file mode 100644 index 00000000000..7eda6bd7158 --- /dev/null +++ b/spec/forms/webauthn_setup_form_spec.rb @@ -0,0 +1,42 @@ +require 'rails_helper' + +describe WebauthnSetupForm do + include WebauthnHelper + + let(:user) { create(:user) } + let(:user_session) { { webauthn_challenge: challenge } } + let(:subject) { WebauthnSetupForm.new(user, user_session) } + + describe '#submit' do + context 'when the input is valid' do + it 'returns FormResponse with success: true' do + allow(Figaro.env).to receive(:domain_name).and_return('localhost:3000') + result = instance_double(FormResponse) + params = { + attestation_object: attestation_object, + client_data_json: client_data_json, + name: 'mykey', + } + + expect(FormResponse).to receive(:new). + with(success: true, errors: {}).and_return(result) + expect(subject.submit(protocol, params)).to eq result + end + end + + context 'when the input is invalid' do + it 'returns FormResponse with success: false' do + result = instance_double(FormResponse) + params = { + attestation_object: attestation_object, + client_data_json: client_data_json, + name: 'mykey', + } + + expect(FormResponse).to receive(:new). + with(success: false, errors: {}).and_return(result) + expect(subject.submit(protocol, params)).to eq result + end + end + end +end diff --git a/spec/lib/feature_management_spec.rb b/spec/lib/feature_management_spec.rb index cd2b68e5120..a9e61d67ba6 100644 --- a/spec/lib/feature_management_spec.rb +++ b/spec/lib/feature_management_spec.rb @@ -439,4 +439,26 @@ end end end + + describe '#webauthn_enabled?' do + context 'when enabled' do + before do + allow(Figaro.env).to receive(:webauthn_enabled).and_return('true') + end + + it 'enables the feature' do + expect(FeatureManagement.webauthn_enabled?).to eq(true) + end + end + + context 'when disabled' do + before do + allow(Figaro.env).to receive(:webauthn_enabled).and_return('false') + end + + it 'disables the feature' do + expect(FeatureManagement.webauthn_enabled?).to eq(false) + end + end + end end diff --git a/spec/support/features/webauthn_helper.rb b/spec/support/features/webauthn_helper.rb new file mode 100644 index 00000000000..e079499f2b4 --- /dev/null +++ b/spec/support/features/webauthn_helper.rb @@ -0,0 +1,36 @@ +module WebauthnHelper + def protocol + 'http://' + end + + def attestation_object + <<~HEREDOC + o2NmbXRoZmlkby11MmZnYXR0U3RtdKJjc2lnWEcwRQIhALPWZKH5+O5MbcTX/si5CWbYExXTgRGmZ3BYDHEQ0zM2AiBLZ + rHCEXeifub4u0QT2CsIzNF0JfZ42BjI7SLzd33FXGN4NWOBWQLCMIICvjCCAaagAwIBAgIEdIb9wjANBgkqhkiG9w0BAQ + sFADAuMSwwKgYDVQQDEyNZdWJpY28gVTJGIFJvb3QgQ0EgU2VyaWFsIDQ1NzIwMDYzMTAgFw0xNDA4MDEwMDAwMDBaGA8 + yMDUwMDkwNDAwMDAwMFowbzELMAkGA1UEBhMCU0UxEjAQBgNVBAoMCVl1YmljbyBBQjEiMCAGA1UECwwZQXV0aGVudGlj + YXRvciBBdHRlc3RhdGlvbjEoMCYGA1UEAwwfWXViaWNvIFUyRiBFRSBTZXJpYWwgMTk1NTAwMzg0MjBZMBMGByqGSM49A + gEGCCqGSM49AwEHA0IABJVd8633JH0xde/9nMTzGk6HjrrhgQlWYVD7OIsuX2Unv1dAmqWBpQ0KxS8YRFwKE1SKE1PIpO + WacE5SO8BN6+2jbDBqMCIGCSsGAQQBgsQKAgQVMS4zLjYuMS40LjEuNDE0ODIuMS4xMBMGCysGAQQBguUcAgEBBAQDAgU + gMCEGCysGAQQBguUcAQEEBBIEEPigEfOMCk0VgAYXER+e3H0wDAYDVR0TAQH/BAIwADANBgkqhkiG9w0BAQsFAAOCAQEA + MVxIgOaaUn44Zom9af0KqG9J655OhUVBVW+q0As6AIod3AH5bHb2aDYakeIyyBCnnGMHTJtuekbrHbXYXERIn4aKdkPSK + lyGLsA/A+WEi+OAfXrNVfjhrh7iE6xzq0sg4/vVJoywe4eAJx0fS+Dl3axzTTpYl71Nc7p/NX6iCMmdik0pAuYJegBcTc + kE3AoYEg4K99AM/JaaKIblsbFh8+3LxnemeNf7UwOczaGGvjS6UzGVI0Odf9lKcPIwYhuTxM5CaNMXTZQ7xq4/yTfC3kP + WtE4hFT34UJJflZBiLrxG4OsYxkHw/n5vKgmpspB3GfYuYTWhkDKiE8CYtyg87mhhdXRoRGF0YVjESZYN5YgOjGh0NBcP + ZHZgW4/krrmihjLHmVzzuoMdl2NBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQKqDS1W7h4/KNbFPClTaqeglJdkHUe6OWQIZo + 5iJsTY+Aomll+hR+iMpbRxiKuuK3pYDcJ0dg3Gk2/zXB+4o+LalAQIDJiABIVggH/apoWRf+cr+ViGgqizMcQFz3WTsQA + Q+bgj5ZDl+d1giWCA+Q7Uff+TEiSLXuT/OtsPil4gRy1ITS4tv8m6n1JLYlw== + HEREDOC + end + + def client_data_json + <<~HEREDOC + eyJjaGFsbGVuZ2UiOiJncjEycndSVVVIWnFvNkZFSV9ZbEFnIiwib3JpZ2luIjoiaHR0cDovL2xvY2FsaG9zdDozMDAwI + iwidHlwZSI6IndlYmF1dGhuLmNyZWF0ZSJ9 + HEREDOC + end + + def challenge + [130, 189, 118, 175, 4, 84, 80, 118, 106, 163, 161, 68, 35, 246, 37, 2] + end +end From e0bfffb88dab44a7fc9df6f7762bea34e20af38f Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Wed, 5 Sep 2018 12:49:36 -0400 Subject: [PATCH 20/61] LG-646 Add CBP I-94 SP **Why**: The SP has completed integration testing and is ready to be promoted to production **How**: Update the service_providers.yml file. --- config/agencies.yml | 8 ++++---- config/service_providers.yml | 12 ++++++++++++ spec/services/agency_seeder_spec.rb | 4 ++-- spec/services/link_agency_identities_spec.rb | 4 ++-- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/config/agencies.yml b/config/agencies.yml index 50edd768f5d..869284b78b4 100644 --- a/config/agencies.yml +++ b/config/agencies.yml @@ -1,6 +1,6 @@ test: 1: - name: 'CBP' + name: 'DHS' 2: name: 'OPM' 3: @@ -12,7 +12,7 @@ test: 6: name: 'DOT' 7: - name: 'DHS' + name: 'USSS' 8: name: 'DOD' 9: @@ -24,7 +24,7 @@ test: development: 1: - name: 'CBP' + name: 'DHS' 2: name: 'OPM' 3: @@ -48,7 +48,7 @@ development: production: 1: - name: 'CBP' + name: 'DHS' 2: name: 'OPM' 3: diff --git a/config/service_providers.yml b/config/service_providers.yml index 915a326fa9b..04957f5900c 100644 --- a/config/service_providers.yml +++ b/config/service_providers.yml @@ -779,3 +779,15 @@ production: - ssn - phone restrict_to_deploy_env: 'prod' + + # CBP I-94 + 'urn:gov:dhs.cbp.pspd.i94:openidconnect:prod:app': + agency_id: 1 + uuid_priority: 30 + friendly_name: 'CBP I-94' + agency: 'DHS' + logo: 'cbp.png' + redirect_uris: + - 'gov.dhs.cbp.pspd.i94.user.prod://result' + - 'gov.dhs.cbp.pspd.i94.user.prod://result/logout' + restrict_to_deploy_env: 'prod' diff --git a/spec/services/agency_seeder_spec.rb b/spec/services/agency_seeder_spec.rb index 280592200a9..ff5a3780a8b 100644 --- a/spec/services/agency_seeder_spec.rb +++ b/spec/services/agency_seeder_spec.rb @@ -16,7 +16,7 @@ it 'inserts agencies in the proper order from agencies.yml' do run - expect(Agency.find_by(id: 1).name).to eq('CBP') + expect(Agency.find_by(id: 1).name).to eq('DHS') expect(Agency.find_by(id: 2).name).to eq('OPM') expect(Agency.find_by(id: 3).name).to eq('EOP') end @@ -29,7 +29,7 @@ it 'updates the attributes based on the current value of the yml file' do expect(Agency.find_by(id: 1).name).to eq('FOO') run - expect(Agency.find_by(id: 1).name).to eq('CBP') + expect(Agency.find_by(id: 1).name).to eq('DHS') end end diff --git a/spec/services/link_agency_identities_spec.rb b/spec/services/link_agency_identities_spec.rb index 0567bc90c76..9bbdf500291 100644 --- a/spec/services/link_agency_identities_spec.rb +++ b/spec/services/link_agency_identities_spec.rb @@ -45,7 +45,7 @@ create_identity(user, 'urn:gov:gsa:openidconnect:test', 'UUID2') LinkAgencyIdentities.new.link report = LinkAgencyIdentities.report - expect(report[0]['name']).to eq('CBP') + expect(report[0]['name']).to eq('DHS') expect(report[0]['old_uuid']).to eq('UUID2') expect(report[0]['new_uuid']).to eq('UUID1') expect(report.cmd_tuples).to eq(1) @@ -56,7 +56,7 @@ create_identity(user, 'http://localhost:3000', 'UUID1') LinkAgencyIdentities.new.link report = LinkAgencyIdentities.report - expect(report[0]['name']).to eq('CBP') + expect(report[0]['name']).to eq('DHS') expect(report[0]['old_uuid']).to eq('UUID2') expect(report[0]['new_uuid']).to eq('UUID1') expect(report.cmd_tuples).to eq(1) From c18c1db5c410ef83a7c1914d557836b19e94cc47 Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Thu, 6 Sep 2018 13:03:22 -0400 Subject: [PATCH 21/61] Removed algorithm --- app/javascript/packs/webauthn-setup.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/javascript/packs/webauthn-setup.js b/app/javascript/packs/webauthn-setup.js index fbf0ee9ea01..509e4e1e92b 100644 --- a/app/javascript/packs/webauthn-setup.js +++ b/app/javascript/packs/webauthn-setup.js @@ -34,10 +34,6 @@ function webauthn() { type: 'public-key', alg: -7, }, - { - type: 'public-key', - alg: -257, - }, ], timeout: 800000, attestation: 'direct', From 778aed3c73cdb9eb4dd328fe15ed6c482f153040 Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Thu, 6 Sep 2018 11:25:21 -0400 Subject: [PATCH 22/61] LG-643 Add timeout to Twilio API calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Why**: It is a best practice — some might say a requirement — to add a timeout to all network requests. Without a timeout, some requests could tie up an app server for literally more than 15 minutes, as reported by New Relic. **How**: - Define an `http_client` with a configurable timeout option and modify the Twilio REST client to use this `http_client` - Rescue the `Faraday::TimeoutError` in the Twilio service class, and raise a custom Twilio error that the 2FA controller can process to display a helpful message to the user and make it easier for them to try again. **How to test locally*: - Run `bundle open twilio-ruby` to open the gem in your text editor - In `lib/twilio-ruby/rest/api.rb` change the value of `@base_url` to `'https://httpstat.us'`, and `@host` to `'httpstat.us'` - In `lib/twilio-ruby/rest/api/v2010.rb`, change the value of `@version` to an empty string - In `lib/twilio-ruby/rest/api/v2010/account/message.rb`, on line 27, change the value of `@uri` to `"/200"`. On line 120, replace the `payload` with this: ```ruby payload = @version.create( 'GET', @uri, data: data, params: { sleep: '10000' } ) ``` - Run `bundle list twilio-ruby` to get the path to the gem - Copy the path of the gem and update the Gemfile to point to your local gem: `gem 'twilio-ruby', path: [path_to_gem]` - Run `bundle update twilio-ruby` - In your local `application.yml`, set `telephony_disabled` to `'false'` - Run `make run` - Create an account and use your real phone number for 2FA (to minimize the chances of spamming someone else's phone). You should see a flash message saying the server took too long after you enter your phone number. --- app/errors/twilio_errors.rb | 1 + app/services/twilio_service.rb | 20 ++++++++++---- config/application.yml.example | 1 + config/initializers/figaro.rb | 1 + config/locales/errors/en.yml | 1 + config/locales/errors/es.yml | 1 + config/locales/errors/fr.yml | 1 + spec/services/twilio_service_spec.rb | 39 ++++++++++++++++++++++++++-- spec/support/fake_sms.rb | 2 +- spec/support/fake_voice_call.rb | 2 +- 10 files changed, 60 insertions(+), 9 deletions(-) diff --git a/app/errors/twilio_errors.rb b/app/errors/twilio_errors.rb index e2f3d37f763..731552437ef 100644 --- a/app/errors/twilio_errors.rb +++ b/app/errors/twilio_errors.rb @@ -4,6 +4,7 @@ module TwilioErrors 21_211 => I18n.t('errors.messages.invalid_phone_number'), 21_215 => I18n.t('errors.messages.invalid_calling_area'), 21_614 => I18n.t('errors.messages.invalid_sms_number'), + 4_815_162_342 => I18n.t('errors.messages.twilio_timeout'), }.freeze VERIFY_ERRORS = { diff --git a/app/services/twilio_service.rb b/app/services/twilio_service.rb index fb866c21b61..1e72e04d662 100644 --- a/app/services/twilio_service.rb +++ b/app/services/twilio_service.rb @@ -7,6 +7,7 @@ class Utils end def initialize + @http_client = Twilio::HTTP::Client.new(timeout: Figaro.env.twilio_timeout.to_i) @client = if FeatureManagement.telephony_disabled? NullTwilioClient.new else @@ -41,13 +42,10 @@ def from_number private - attr_reader :client + attr_reader :client, :http_client def twilio_client - telephony_service.new( - TWILIO_SID, - TWILIO_AUTH_TOKEN - ) + telephony_service.new(TWILIO_SID, TWILIO_AUTH_TOKEN, nil, nil, @http_client) end def random_phone_number @@ -59,6 +57,8 @@ def sanitize_errors rescue Twilio::REST::RestError => error sanitize_phone_number(error.message) raise + rescue Faraday::TimeoutError + raise Twilio::REST::RestError.new('timeout', TwilioTimeoutResponse.new) end DIGITS_TO_PRESERVE = 5 @@ -72,5 +72,15 @@ def sanitize_phone_number(str) end end end + + class TwilioTimeoutResponse + def status_code + 4_815_162_342 + end + + def body + {} + end + end end end diff --git a/config/application.yml.example b/config/application.yml.example index 77a0f3c89d9..cdfa2f6fb81 100644 --- a/config/application.yml.example +++ b/config/application.yml.example @@ -67,6 +67,7 @@ use_dashboard_service_providers: 'false' dashboard_url: 'https://dashboard.demo.login.gov' valid_authn_contexts: '["http://idmanagement.gov/ns/assurance/loa/1", "http://idmanagement.gov/ns/assurance/loa/3"]' +twilio_timeout: '5' usps_upload_sftp_timeout: '5' development: diff --git a/config/initializers/figaro.rb b/config/initializers/figaro.rb index 268bd73444f..06de0362e43 100644 --- a/config/initializers/figaro.rb +++ b/config/initializers/figaro.rb @@ -49,6 +49,7 @@ 'twilio_auth_token', 'twilio_record_voice', 'twilio_messaging_service_sid', + 'twilio_timeout', 'use_kms', 'valid_authn_contexts' ) diff --git a/config/locales/errors/en.yml b/config/locales/errors/en.yml index 423bacf7616..45509a05bc3 100644 --- a/config/locales/errors/en.yml +++ b/config/locales/errors/en.yml @@ -44,6 +44,7 @@ en: phone_unsupported: Sorry, we are unable to send SMS at this time. Please try the phone call option below, or use your personal key. twilio_inbound_sms_invalid: The inbound Twilio SMS message failed validation. + twilio_timeout: The server took too long to respond. Please try again. unauthorized_authn_context: Unauthorized authentication context unauthorized_nameid_format: Unauthorized nameID format unauthorized_service_provider: Unauthorized Service Provider diff --git a/config/locales/errors/es.yml b/config/locales/errors/es.yml index ec1c8027781..62205656ae9 100644 --- a/config/locales/errors/es.yml +++ b/config/locales/errors/es.yml @@ -40,6 +40,7 @@ es: phone_unsupported: Lo sentimos, no podemos enviar SMS en este momento. Pruebe la opción de llamada telefónica a continuación o use su clave personal. twilio_inbound_sms_invalid: El mensaje de Twilio SMS de entrada falló la validación. + twilio_timeout: NOT TRANSLATED YET unauthorized_authn_context: Contexto de autenticación no autorizado unauthorized_nameid_format: NOT TRANSLATED YET unauthorized_service_provider: Proveedor de servicio no autorizado diff --git a/config/locales/errors/fr.yml b/config/locales/errors/fr.yml index c082243df38..bab66df7b11 100644 --- a/config/locales/errors/fr.yml +++ b/config/locales/errors/fr.yml @@ -42,6 +42,7 @@ fr: le moment. S'il vous plaît essayez l'option d'appel téléphonique ci-dessous, ou utilisez votre clé personnelle. twilio_inbound_sms_invalid: Le message SMS Twilio entrant a échoué à la validation. + twilio_timeout: NOT TRANSLATED YET unauthorized_authn_context: Contexte d'authentification non autorisé unauthorized_nameid_format: NOT TRANSLATED YET unauthorized_service_provider: Fournisseur de service non autorisé diff --git a/spec/services/twilio_service_spec.rb b/spec/services/twilio_service_spec.rb index 36a577875c0..d8cc7409821 100644 --- a/spec/services/twilio_service_spec.rb +++ b/spec/services/twilio_service_spec.rb @@ -40,10 +40,15 @@ TwilioService::Utils.telephony_service = Twilio::REST::Client end - it 'uses a real Twilio client' do + it 'uses a real Twilio client with timeout' do + allow(Figaro.env).to receive(:twilio_timeout).and_return('1') client = instance_double(Twilio::REST::Client) + twilio_http_client = instance_double(Twilio::HTTP::Client) + expect(Twilio::HTTP::Client).to receive(:new).with(timeout: 1).and_return(twilio_http_client) expect(Twilio::REST::Client). - to receive(:new).with(/sid(1|2)/, /token(1|2)/).and_return(client) + to receive(:new). + with(/sid(1|2)/, /token(1|2)/, nil, nil, twilio_http_client). + and_return(client) http_client = Struct.new(:adapter) expect(client).to receive(:http_client).and_return(http_client) expect(http_client).to receive(:adapter=).with(:typhoeus) @@ -95,6 +100,21 @@ expect { service.place_call(to: '+123456789012', url: 'https://twimlet.com') }. to raise_error(Twilio::REST::RestError, sanitized_message) end + + it 'rescues timeout errors and raises a custom Twilio error' do + TwilioService::Utils.telephony_service = FakeVoiceCall + error_code = 4_815_162_342 + status_code = 4_815_162_342 + + message = "[HTTP #{status_code}] #{error_code} : timeout\n\n" + service = TwilioService::Utils.new + + expect(service.send(:client).calls).to receive(:create). + and_raise(Faraday::TimeoutError) + + expect { service.place_call(to: '+123456789012', url: 'https://twimlet.com') }. + to raise_error(Twilio::REST::RestError, message) + end end describe '#send_sms' do @@ -137,5 +157,20 @@ expect { service.send_sms(to: '+1 (888) 555-5555', body: 'test') }. to raise_error(Twilio::REST::RestError, sanitized_message) end + + it 'rescues timeout errors and raises a custom Twilio error' do + TwilioService::Utils.telephony_service = FakeSms + error_code = 4_815_162_342 + status_code = 4_815_162_342 + + message = "[HTTP #{status_code}] #{error_code} : timeout\n\n" + service = TwilioService::Utils.new + + expect(service.send(:client).messages).to receive(:create). + and_raise(Faraday::TimeoutError) + + expect { service.send_sms(to: '+123456789012', body: 'test') }. + to raise_error(Twilio::REST::RestError, message) + end end end diff --git a/spec/support/fake_sms.rb b/spec/support/fake_sms.rb index 1e4e3c62862..f884cf2e64d 100644 --- a/spec/support/fake_sms.rb +++ b/spec/support/fake_sms.rb @@ -5,7 +5,7 @@ class FakeSms cattr_accessor :messages self.messages = [] - def initialize(_account_sid, _auth_token); end + def initialize(_username, _password, _account_sid, _region, _http_client); end def messages self diff --git a/spec/support/fake_voice_call.rb b/spec/support/fake_voice_call.rb index 4a5512a3e0b..f5afeb89093 100644 --- a/spec/support/fake_voice_call.rb +++ b/spec/support/fake_voice_call.rb @@ -4,7 +4,7 @@ class FakeVoiceCall cattr_accessor :calls self.calls = [] - def initialize(_account_sid, _auth_token); end + def initialize(_username, _password, _account_sid, _region, _http_client); end def calls self From 5f76c2320b3485b2b5bf5fe4c27602987f5d3187 Mon Sep 17 00:00:00 2001 From: John Donmoyer Date: Thu, 6 Sep 2018 14:08:49 -0500 Subject: [PATCH 23/61] LG-569 adjusting visual styles of 2FA options (#2480) **Why**: To visually improve the screen --- app/assets/stylesheets/components/_btn.scss | 8 +++----- app/javascript/app/radio-btn.js | 6 +++--- app/views/account_recovery_setup/index.html.slim | 4 ++-- .../two_factor_authentication/options/index.html.slim | 4 ++-- .../users/two_factor_authentication_setup/index.html.slim | 8 ++++---- config/locales/devise/en.yml | 8 ++++---- config/locales/devise/es.yml | 8 ++++---- config/locales/devise/fr.yml | 6 +++--- 8 files changed, 25 insertions(+), 27 deletions(-) diff --git a/app/assets/stylesheets/components/_btn.scss b/app/assets/stylesheets/components/_btn.scss index c0599bdfdc3..a08edb375bf 100644 --- a/app/assets/stylesheets/components/_btn.scss +++ b/app/assets/stylesheets/components/_btn.scss @@ -65,11 +65,9 @@ display: inline-block; padding: $space-1 $space-2; - // &.is-focused { - // border-color: $field-focus-color; - // box-shadow: 0 0 0 2px rgba($field-focus-color, .5); - // outline: none; - // } + &.is-focused { + border-color: $field-focus-color; + } } .btn-disabled { diff --git a/app/javascript/app/radio-btn.js b/app/javascript/app/radio-btn.js index 452825fdaf0..1d1b9fa1146 100644 --- a/app/javascript/app/radio-btn.js +++ b/app/javascript/app/radio-btn.js @@ -4,7 +4,7 @@ function clearHighlight(name) { const radioGroup = document.querySelectorAll(`input[name='${name}']`); Array.prototype.forEach.call(radioGroup, (radio) => { - radio.parentNode.parentNode.classList.remove('bg-light-blue'); + radio.parentNode.parentNode.classList.remove('bg-lightest-blue'); }); } @@ -16,11 +16,11 @@ function highlightRadioBtn() { const label = radio.parentNode.parentNode; const name = radio.getAttribute('name'); - if (radio.checked) label.classList.add('bg-light-blue'); + if (radio.checked) label.classList.add('bg-lightest-blue'); radio.addEventListener('change', function() { clearHighlight(name); - if (radio.checked) label.classList.add('bg-light-blue'); + if (radio.checked) label.classList.add('bg-lightest-blue'); }); radio.addEventListener('focus', function() { diff --git a/app/views/account_recovery_setup/index.html.slim b/app/views/account_recovery_setup/index.html.slim index d0dc36506b2..4fb2892b005 100644 --- a/app/views/account_recovery_setup/index.html.slim +++ b/app/views/account_recovery_setup/index.html.slim @@ -9,9 +9,9 @@ p.mt-tiny.mb3 = @presenter.info url: two_factor_options_path) do |f| .mb3 fieldset.m0.p0.border-none. - legend.mb1.h4.serif.bold = @presenter.label + legend.mb2.serif.bold = @presenter.label - @presenter.options.each do |option| - label.btn-border.col-12.mb1 for="two_factor_options_form_selection_#{option.type}" + label.btn-border.col-12.mb2 for="two_factor_options_form_selection_#{option.type}" .radio = radio_button_tag('two_factor_options_form[selection]', option.type, diff --git a/app/views/two_factor_authentication/options/index.html.slim b/app/views/two_factor_authentication/options/index.html.slim index a035884faae..2a10779a082 100644 --- a/app/views/two_factor_authentication/options/index.html.slim +++ b/app/views/two_factor_authentication/options/index.html.slim @@ -9,9 +9,9 @@ p.mt-tiny.mb3 = @presenter.info url: login_two_factor_options_path) do |f| .mb3 fieldset.m0.p0.border-none. - legend.mb1.h4.serif.bold = @presenter.label + legend.mb2.serif.bold = @presenter.label - @presenter.options.each do |option| - label.btn-border.col-12.mb1 for="two_factor_options_form_selection_#{option.type}" + label.btn-border.col-12.mb2 for="two_factor_options_form_selection_#{option.type}" .radio = radio_button_tag('two_factor_options_form[selection]', option.type, diff --git a/app/views/users/two_factor_authentication_setup/index.html.slim b/app/views/users/two_factor_authentication_setup/index.html.slim index 75ebf46d66f..e2a3a16605f 100644 --- a/app/views/users/two_factor_authentication_setup/index.html.slim +++ b/app/views/users/two_factor_authentication_setup/index.html.slim @@ -9,18 +9,18 @@ p.mt-tiny.mb3 = @presenter.info url: two_factor_options_path) do |f| .mb3 fieldset.m0.p0.border-none. - legend.mb1.h4.serif.bold = @presenter.label + legend.mb2.serif.bold = @presenter.label - @presenter.options.each do |option| - label.btn-border.col-12.mb1 for="two_factor_options_form_selection_#{option.type}" + label.btn-border.col-12.mb2 for="two_factor_options_form_selection_#{option.type}" .radio = radio_button_tag('two_factor_options_form[selection]', option.type, @two_factor_options_form.selected?(option.type)) span.indicator.mt-tiny span.blue.bold.fs-20p = option.label - .regular.gray-dark.fs-10p.mb-tiny = option.info + .regular.gray-dark.fs-10p.mt0.mb-tiny = option.info div - = f.button :submit, t('forms.buttons.continue'), class: 'sm-col-6 col-12 btn-wide mb3' + = f.button :submit, t('forms.buttons.continue'), class: 'sm-col-6 col-12 btn-wide mb1' = render 'shared/cancel', link: destroy_user_session_path diff --git a/config/locales/devise/en.yml b/config/locales/devise/en.yml index 6deb87ec655..2ed967d24f9 100644 --- a/config/locales/devise/en.yml +++ b/config/locales/devise/en.yml @@ -129,10 +129,10 @@ en: two_factor_choice_options: auth_app: Authentication application auth_app_info: Set up an authentication application to get your security code - without providing a phone number. + without providing a phone number piv_cac: Government employees - piv_cac_info: Use your PIV/CAC card to secure your account. + piv_cac_info: Use your PIV/CAC card to secure your account sms: Text message / SMS - sms_info: Get your security code via text message / SMS. + sms_info: Get your security code via text message / SMS voice: Phone call - voice_info: Get your security code via phone call. + voice_info: Get your security code via phone call diff --git a/config/locales/devise/es.yml b/config/locales/devise/es.yml index f20a4377120..005161c8b43 100644 --- a/config/locales/devise/es.yml +++ b/config/locales/devise/es.yml @@ -128,10 +128,10 @@ es: two_factor_choice_options: auth_app: Aplicación de autenticación auth_app_info: Configure una aplicación de autenticación para obtener su código - de seguridad sin proporcionar un número de teléfono. + de seguridad sin proporcionar un número de teléfono piv_cac: Empleados del Gobierno - piv_cac_info: Use su tarjeta PIV / CAC para asegurar su cuenta. + piv_cac_info: Use su tarjeta PIV / CAC para asegurar su cuenta sms: Mensaje de texto / SMS - sms_info: Obtenga su código de seguridad a través de mensajes de texto / SMS. + sms_info: Obtenga su código de seguridad a través de mensajes de texto / SMS voice: Llamada telefónica - voice_info: Obtenga su código de seguridad a través de una llamada telefónica. + voice_info: Obtenga su código de seguridad a través de una llamada telefónica diff --git a/config/locales/devise/fr.yml b/config/locales/devise/fr.yml index 8c9695baae5..6e186b1d2eb 100644 --- a/config/locales/devise/fr.yml +++ b/config/locales/devise/fr.yml @@ -137,10 +137,10 @@ fr: two_factor_choice_options: auth_app: Application d'authentification auth_app_info: Configurez une application d'authentification pour obtenir - votre code de sécurité sans fournir de numéro de téléphone. + votre code de sécurité sans fournir de numéro de téléphone piv_cac: Employés du gouvernement - piv_cac_info: Utilisez votre carte PIV / CAC pour sécuriser votre compte. + piv_cac_info: Utilisez votre carte PIV / CAC pour sécuriser votre compte sms: SMS sms_info: Obtenez votre code de sécurité par SMS voice: Appel téléphonique - voice_info: Obtenez votre code de sécurité par appel téléphonique. + voice_info: Obtenez votre code de sécurité par appel téléphonique From da6908f231195a92a616b29db75b9a396af01782 Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Fri, 7 Sep 2018 02:14:45 -0400 Subject: [PATCH 24/61] LG-652 Add HUD to the service providers in production **Why**: HUD has completed integration testing and is ready to be promoted to production **How**: Update service_providers.yml. Add cert and logo. --- app/assets/images/sp-logos/hud.png | Bin 0 -> 4162 bytes certs/sp/hud_prod.crt | 26 ++++++++++++++++++++++++++ config/agencies.yml | 6 ++++++ config/service_providers.yml | 15 +++++++++++++++ 4 files changed, 47 insertions(+) create mode 100644 app/assets/images/sp-logos/hud.png create mode 100644 certs/sp/hud_prod.crt diff --git a/app/assets/images/sp-logos/hud.png b/app/assets/images/sp-logos/hud.png new file mode 100644 index 0000000000000000000000000000000000000000..c0d45fd3d27de27912714a8e450e75bed8fcc887 GIT binary patch literal 4162 zcmc&%nt{N8(IDYQh%iK2 zQYJ7EF89Oz8}9jVp7Z?9InS5#d^t(RMo$=Md1wIu0D~S-+Y|sG0o~Nb)MPg~(7KN6 z2FRT?4K)FPhG8X&LKi7;qOl8DFTd_<)dwf?=j@*k;c#ceLH?U3Yy zw)c)O>kDgJd;iq>Tl&2(f}u$RdPZiJ976I3Cg%T~owJlXdPJ2qt&Z1qjD@Aa^9EOS za>n!>Qa?5hbZ*XlzpQy97c+25k9gr?zc4G`Xs#W$5_q6!1>l)a@NxC_25Yop^Dfj0{^` z-BgD8$G%6rD{l3~6sb2yCgwKP#3-Qj4bXP{5mza zTGKx49A2E5i?l-LcMZ(ome)h$D~lV@y<>B!C2iBY+j%`}i1Gc?3nKCAva)5c8Z|nG z|JAv)Q&`=7r^u?U=gauoKCQI<_4V~F{z~$jgGB|H8kybD|7QS{DfFFhOxC5Rt!W;- z_^a^3dU2SecOsf8x-vuTSb~+o69a5f0{OgR;nmbm0hPg9w|>-b?c}n1H?%;##x8f2 z@V8RkVQZ{}$x@lnhTQ6Ot9zMC`3eeP;8?>p^$D>bn*~^{pj7HfTnDX$1Ih4!8E+o* zUixP>koT=qx=n@y1|5q0t1+@?-{*ijA$R%7qe7W1H_?mO)Cx!Mq!z2;DMfs%vm59$}B)nRXe z8aSP6oK)K#E={u>47o*=9(o8`9IHxn+7apoN6r1(T+Zax6=JF*uHg1SoW2DIR7N(3 zFZU&ab(QB9^c}#ULEgFYw79gh>a{)P!ovU^vOA34|Lx-r-5Q&Dl*S2pHABKc2>R>q z3~T3rkOyP8a@$nt3KRXzlnbBb_-+CrJe&L-0(&LbF9|{cd}uSV&2($6-?D zt*g<8$Q3INT}sU_k)qv`; z!vfE4P*_EWHH9MB)stBg1hLZgu`DtT0*9!9QmJ8S87+OIWhxV;&lmrz^#US_eb;8I zV`%CU5ja5m)FI&2H}{0V2rYw^cQ5P-d*WNhl8!axa)xBvh~vW;0jbetWaDQ$&$6nK zrg8?DRU1z-55mrC!l=1dG9|4Qjv;;<)iR*+9&kn>t` zc(&JiM@sV!a#0^PPY#oI!p$tQ!`eY{GS80^|NyyL^t(I_B0uR!%Z@)cEj!2M?Z1BS@56&w}+AD z+p_~%$oT?4$lF>B4AH_|Xn?pk%0l-*ExB$q!)`CP;<3hT36<8dSu{H4_V+O{o3;v@FEA47x5Fhv=)tdiV3Rwm>^=6xcAO zs7>VdIZd6eGMW^Aa)m{ldX{@zzin&XDA0B3%p{eGj|a3r)i@n`n-TKGL*MO!#=CD%$V zW9tOTaOl@kLqHmX?y%p!2gvxq^Jf+`mVCeN1Yg^NUE4VlLcYbI*|w!PD}baWYOkB+ zuRQbziMyTFR0rR$HPOVKmFp0t7{vb1&%epGA3~~=+3)xU?*uo#1@C6yztJDlS5h9R zD~HHk8VstQJNX=HV^D`qVF71cwi8Tm!%n{s15CL%Qx)uRzUX`eDCHtA(L>42y`IkMTc@`@$q1l?kpcvk@ z7)|T%;o-z)TXct;JI|^(j0Txz_&2c_p>$+^^yPl+hg+I1;a`RHcc<`#sgD6lr#slk z5_MA02j@j$S45Uf+}!DEzT&;>qoI%lM{!r|Ii#z+^`!DshuW)S8&eQz|Ec(s=eHSz z;Qe$BpQ1yMrL2PKep+(yTzEk#Y>6XCs4sMP5viDn62~7vA-uQM<#gCjV=Wep3$(br5-c7~Iv(}V!O-e9 z#-s;o7o94N-mU~4rnv&ZWgWPz^IEE(hn9G>!@W?|Ja~CmJ}IV$Uy45GycQ8{Eb}V3 zWxlGJvO}9MOX1!Ld9%b8w_KdG9@Pc?90}|`7|HBM58Qn0WA&ueU-%95p$S@YLvLkN z)(WDEq@$a17NY=4FhZ^T8W1dMpx~=761VjVUAHe?Jddm^iFVIaAg}9erQn3Z1aRsg zF%zd#QX*?!pV}zgsS_Zm?>7Hf98QB->8r6S_Zd^&A5CQM7^E)k_&~m;^fMzSc}lC& zhh3_Y{KXM!)(pe+jpN#}w#0aFhQ>W<;Lu-k{(ZBPuz+svdMZ!0ByCcXphMCVwH=|l z_HuFwwst|rN78rt#)?h$lEmO(ecw>CYJo6edNv4KRknbQg+jg%{bQ-09|XRNRfKj- z99eG=+j*35nlw>F6xD_K05Kic1am`rT-Jy=u{3ILzdZc&RHF*|?`H>bHYeNPkJ;eI z3{*o5_(KysXNB^l@YXa-hv-|gf~0ECTBGOwm(O2^i}u_CJqJ_2ygwTb-2I0=jmjCz zsZ|j&yHTCZf8N$x3s_snv!W&lu5T%Q~4N$X)$fBpUiVd~(i-^{*UZ-!_<9v1k{lwwGonSJMo-qY>*1NAfxY=a) zW*gB;?5CC63T5~Xg3#HWzH`AO+I-Xi=(njlr&>S+K0X{Yf(X0RId$4O;=4HMS@yb` z4K>aS9}#<)Ts<3BctIb2@U~@w8s43lba(*0echz$NTT`N-FhV>h6M9wu%yKH+C)~K zIR+_~+~@Xzh7RkT8@i()h67AUkyk2Uz1(|N3_fYz;|v!KRjS_8+}tk$>+g=OV0kc0 z5XBeM6c;c5!-QJ}Y{?F>$u_rGbj`HeZ8u?{8d*wVLG% zt~-BSfh;)X{YidYz#HC-+#)Z`(q0g(QYplyuXeGReC}o2Xqja+vhhT7if16uGq4d# z;ST2@tcUe2d*wJzFePW}r*EEIbaWJzS>HCiy5B``ay%qNCfvR{4h=i2I=Tc|Jz_fX zkgpo|u;@uiADEti#!0heasvyD5H2oVYvgkFaRp+-dQs|Fn@(G78f2>V?JCytMK-wv zip{fgEHk2|kG7Z6{l+dkBv;i{WVnh2hxyGFf(uGMvT$W#5f!0WtbV(Mj{nQ2&9nvu zsFGm5ky3!J*y3nzS?c-s|qBkait{2kTSayW!%_Q zESC!pyEjCPlmYeKk^G9l6FoSqN>YyQPAVeDnqyix;a2!^VCKX==L3MSdV043JKw)G z&1)n3G_c`u3R(A&B_120-1BYD;`^EE#sKh^VKhnR=;vc54Q5V_nDvgQ0m_MJs;*p?~ zH(FZvL?+h|hji;hUi7M&03NQUh;SH1RC{}sglbI};#07JJ%a(J2VhQ;_O2z7>Ofy5 z-zh*G-oiIk;=no0bUx$+v3j`61p7#vs0`dsR^qHGb7%> Date: Fri, 7 Sep 2018 11:00:59 -0400 Subject: [PATCH 25/61] [LG-501] Only write to phone configuration table (#2478) * [LG-501] Only write to phone configuration table **Why**: Before we can support multiple phones, we have to stop writing to the `user.phone` and related columns. **How**: Go through the code and make sure we never write/update the `user.phone` and `user.phone_confirmed_at` columns. For now, we leave some of the `user.otp_delivery_preference` updates as they are since they look like they are made without a phone number. --- .../two_factor_authentication_controller.rb | 10 ++-- .../user_encrypted_attribute_overrides.rb | 3 +- app/models/user.rb | 2 +- app/services/pii/cacher.rb | 5 +- .../populate_phone_configurations_table.rb | 41 --------------- app/services/update_user.rb | 16 ++---- lib/tasks/create_test_accounts.rb | 4 +- lib/tasks/dev.rake | 16 ++---- lib/tasks/migrate_phone_configurations.rake | 7 --- .../account_recovery_setup_controller_spec.rb | 2 +- spec/controllers/idv/phone_controller_spec.rb | 32 ++++++++---- .../otp_verification_controller_spec.rb | 6 +-- ...rsonal_key_verification_controller_spec.rb | 4 +- .../piv_cac_verification_controller_spec.rb | 2 +- .../users/phones_controller_spec.rb | 18 +++---- ...ac_authentication_setup_controller_spec.rb | 4 +- .../users/totp_setup_controller_spec.rb | 2 +- ...o_factor_authentication_controller_spec.rb | 2 +- spec/factories/phone_configurations.rb | 7 +-- spec/factories/users.rb | 51 ++++++++++++------- .../features/accessibility/user_pages_spec.rb | 4 +- .../sign_in/two_factor_options_spec.rb | 6 ++- .../change_factor_spec.rb | 10 ++-- .../two_factor_authentication/sign_in_spec.rb | 17 ++++--- .../features/users/piv_cac_management_spec.rb | 11 ++-- spec/features/users/sign_in_spec.rb | 19 ++++--- .../visitors/phone_confirmation_spec.rb | 2 - spec/forms/idv/phone_form_spec.rb | 4 +- spec/forms/user_phone_form_spec.rb | 8 ++- spec/lib/tasks/rotate_rake_spec.rb | 14 ++--- spec/models/phone_configuration_spec.rb | 17 ++++++- spec/models/user_spec.rb | 13 +---- spec/requests/edit_user_spec.rb | 2 +- .../requests/openid_connect_authorize_spec.rb | 2 +- spec/services/account_reset/cancel_spec.rb | 7 ++- .../key_rotator/attribute_encryption_spec.rb | 9 ++-- spec/services/pii/cacher_spec.rb | 4 +- ...opulate_phone_configurations_table_spec.rb | 48 ----------------- spec/services/remember_device_cookie_spec.rb | 4 +- spec/services/update_user_spec.rb | 4 +- spec/support/features/session_helper.rb | 4 +- spec/support/shared_examples/sign_in.rb | 4 +- .../shared_examples_for_phone_validation.rb | 5 +- 43 files changed, 187 insertions(+), 265 deletions(-) delete mode 100644 app/services/populate_phone_configurations_table.rb delete mode 100644 lib/tasks/migrate_phone_configurations.rake delete mode 100644 spec/services/populate_phone_configurations_table_spec.rb diff --git a/app/controllers/users/two_factor_authentication_controller.rb b/app/controllers/users/two_factor_authentication_controller.rb index e4f285aa902..92337b8001b 100644 --- a/app/controllers/users/two_factor_authentication_controller.rb +++ b/app/controllers/users/two_factor_authentication_controller.rb @@ -45,7 +45,6 @@ def phone_configuration end def validate_otp_delivery_preference_and_send_code - delivery_preference = phone_configuration.delivery_preference result = otp_delivery_selection_form.submit(otp_delivery_preference: delivery_preference) analytics.track_event(Analytics::OTP_DELIVERY_SELECTION, result.to_h) @@ -57,6 +56,10 @@ def validate_otp_delivery_preference_and_send_code end end + def delivery_preference + phone_configuration&.delivery_preference || current_user.otp_delivery_preference + end + def update_otp_delivery_preference_if_needed OtpDeliveryPreferenceUpdater.new( user: current_user, @@ -67,8 +70,7 @@ def update_otp_delivery_preference_if_needed def handle_invalid_otp_delivery_preference(result) flash[:error] = result.errors[:phone].first - preference = current_user.phone_configuration.delivery_preference - redirect_to login_two_factor_url(otp_delivery_preference: preference) + redirect_to login_two_factor_url(otp_delivery_preference: delivery_preference) end def invalid_phone_number(exception, action:) @@ -179,7 +181,7 @@ def delivery_params end def phone_to_deliver_to - return current_user.phone_configuration.phone if authentication_context? + return phone_configuration&.phone if authentication_context? user_session[:unconfirmed_phone] end diff --git a/app/models/concerns/user_encrypted_attribute_overrides.rb b/app/models/concerns/user_encrypted_attribute_overrides.rb index ae5670e1115..4abaa71b73c 100644 --- a/app/models/concerns/user_encrypted_attribute_overrides.rb +++ b/app/models/concerns/user_encrypted_attribute_overrides.rb @@ -15,7 +15,8 @@ def find_with_email(email) email = email.downcase.strip email_fingerprint = create_fingerprint(email) - find_by(email_fingerprint: email_fingerprint) + resource = find_by(email_fingerprint: email_fingerprint) + resource if resource&.email == email end def create_fingerprint(email) diff --git a/app/models/user.rb b/app/models/user.rb index f2cca7486c9..653fcf16aa5 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -3,6 +3,7 @@ class User < ApplicationRecord self.ignored_columns = %w[ encrypted_password password_salt password_cost encryption_key recovery_code recovery_cost recovery_salt + encrypted_phone phone_confirmed_at ] include NonNullUuid @@ -22,7 +23,6 @@ class User < ApplicationRecord include EncryptableAttribute - encrypted_attribute(name: :phone) encrypted_attribute(name: :otp_secret_key) encrypted_attribute_without_setter(name: :email) diff --git a/app/services/pii/cacher.rb b/app/services/pii/cacher.rb index a16c548ec61..629a74912c9 100644 --- a/app/services/pii/cacher.rb +++ b/app/services/pii/cacher.rb @@ -38,6 +38,9 @@ def rotate_fingerprints(profile) def rotate_encrypted_attributes KeyRotator::AttributeEncryption.new(user).rotate + phone_configuration = user.phone_configuration + return if phone_configuration.blank? + KeyRotator::AttributeEncryption.new(phone_configuration).rotate end def stale_fingerprints?(profile) @@ -49,7 +52,7 @@ def stale_email_fingerprint? end def stale_attributes? - user.stale_encrypted_phone? || user.stale_encrypted_email? || + user.phone_configuration&.stale_encrypted_phone? || user.stale_encrypted_email? || user.stale_encrypted_otp_secret_key? end diff --git a/app/services/populate_phone_configurations_table.rb b/app/services/populate_phone_configurations_table.rb deleted file mode 100644 index a1c123da5ec..00000000000 --- a/app/services/populate_phone_configurations_table.rb +++ /dev/null @@ -1,41 +0,0 @@ -class PopulatePhoneConfigurationsTable - def initialize - @count = 0 - @total = 0 - end - - # :reek:DuplicateMethodCall - def call - # we don't have a uniqueness constraint in the database to let us blindly insert - # everything in a single SQL statement. So we have to load by batches and copy - # over. Much slower, but doesn't duplicate information. - User.in_batches(of: 1000) do |relation| - sleep(1) - process_batch(relation) - Rails.logger.info "#{@count} / #{@total}" - end - Rails.logger.info "Processed #{@count} user phone configurations" - end - - private - - # :reek:FeatureEnvy - def process_batch(relation) - User.transaction do - relation.each do |user| - @total += 1 - next if user.phone_configuration.present? || user.encrypted_phone.blank? - user.create_phone_configuration(phone_info_for_user(user)) - @count += 1 - end - end - end - - def phone_info_for_user(user) - { - encrypted_phone: user.encrypted_phone, - confirmed_at: user.phone_confirmed_at, - delivery_preference: user.otp_delivery_preference, - } - end -end diff --git a/app/services/update_user.rb b/app/services/update_user.rb index 1cc07776765..950de065250 100644 --- a/app/services/update_user.rb +++ b/app/services/update_user.rb @@ -5,7 +5,7 @@ def initialize(user:, attributes:) end def call - result = user.update!(attributes) + result = user.update!(attributes.except(:phone, :phone_confirmed_at)) manage_phone_configuration result end @@ -23,13 +23,7 @@ def manage_phone_configuration end def update_phone_configuration - configuration = user.phone_configuration - if phone_attributes[:phone].present? - configuration.update!(phone_attributes) - else - configuration.destroy - user.reload - end + user.phone_configuration.update!(phone_attributes) end def create_phone_configuration @@ -39,10 +33,10 @@ def create_phone_configuration def phone_attributes @phone_attributes ||= { - phone: attribute(:phone), - confirmed_at: attribute(:phone_confirmed_at), + phone: attributes[:phone], + confirmed_at: attributes[:phone_confirmed_at], delivery_preference: attribute(:otp_delivery_preference), - } + }.delete_if { |_, value| value.nil? } end # This returns the named attribute if it's included in the changes, even if diff --git a/lib/tasks/create_test_accounts.rb b/lib/tasks/create_test_accounts.rb index 91ae6df50c6..3dd22c658e3 100644 --- a/lib/tasks/create_test_accounts.rb +++ b/lib/tasks/create_test_accounts.rb @@ -15,12 +15,10 @@ def create_account(email: 'joe.smith@email.com', password: 'salty pickles', mfa_ user = User.create!(email: email) user.skip_confirmation! user.reset_password(password, password) - user.phone = mfa_phone || phone - user.phone_confirmed_at = Time.zone.now user.save! user.create_phone_configuration( phone: mfa_phone || phone, - confirmed_at: user.phone_confirmed_at, + confirmed_at: Time.zone.now, delivery_preference: user.otp_delivery_preference ) Event.create(user_id: user.id, event_type: :account_created) diff --git a/lib/tasks/dev.rake b/lib/tasks/dev.rake index a1c212a71f2..bff3595d76c 100644 --- a/lib/tasks/dev.rake +++ b/lib/tasks/dev.rake @@ -88,9 +88,11 @@ namespace :dev do user.encrypted_email = args[:ee].encrypted user.skip_confirmation! user.reset_password(args[:pw], args[:pw]) - user.phone = format('+1 (415) 555-%04d', args[:num]) - user.phone_confirmed_at = Time.zone.now - create_phone_configuration_for(user) + user.create_phone_configuration( + delivery_preference: user.otp_delivery_preference, + phone: format('+1 (415) 555-%04d', args[:num]), + confirmed_at: Time.zone.now + ) Event.create(user_id: user.id, event_type: :account_created) end @@ -105,12 +107,4 @@ namespace :dev do def fingerprint(email) Pii::Fingerprinter.fingerprint(email) end - - def create_phone_configuration_for(user) - user.create_phone_configuration( - phone: user.phone, - confirmed_at: user.phone_confirmed_at, - delivery_preference: user.otp_delivery_preference - ) - end end diff --git a/lib/tasks/migrate_phone_configurations.rake b/lib/tasks/migrate_phone_configurations.rake deleted file mode 100644 index f857e0e96f2..00000000000 --- a/lib/tasks/migrate_phone_configurations.rake +++ /dev/null @@ -1,7 +0,0 @@ -namespace :adhoc do - desc 'Copy phone configurations to the new table' - task populate_phone_configurations: :environment do - Rails.logger = Logger.new(STDOUT) - PopulatePhoneConfigurationsTable.new.call - end -end diff --git a/spec/controllers/account_recovery_setup_controller_spec.rb b/spec/controllers/account_recovery_setup_controller_spec.rb index 4c77947abf2..2064124b04b 100644 --- a/spec/controllers/account_recovery_setup_controller_spec.rb +++ b/spec/controllers/account_recovery_setup_controller_spec.rb @@ -24,7 +24,7 @@ context 'user is piv_cac enabled but not phone enabled' do it 'redirects to account_url' do - user = build(:user, :signed_up, :with_piv_or_cac, phone: nil) + user = build(:user, :signed_up, :with_piv_or_cac, with: { mfa_enabled: false }) stub_sign_in(user) get :index diff --git a/spec/controllers/idv/phone_controller_spec.rb b/spec/controllers/idv/phone_controller_spec.rb index c7ded6be30b..674c40d0ba0 100644 --- a/spec/controllers/idv/phone_controller_spec.rb +++ b/spec/controllers/idv/phone_controller_spec.rb @@ -20,7 +20,10 @@ end describe '#new' do - let(:user) { build(:user, phone: good_phone, phone_confirmed_at: Time.zone.now) } + let(:user) do + build(:user, :with_phone, + with: { phone: good_phone, confirmed_at: Time.zone.now }) + end before do stub_verify_steps_one_and_two(user) @@ -64,7 +67,7 @@ describe '#create' do context 'when form is invalid' do before do - user = build(:user, phone: '+1 (415) 555-0130') + user = build(:user, :with_phone, with: { phone: '+1 (415) 555-0130' }) stub_verify_steps_one_and_two(user) stub_analytics allow(@analytics).to receive(:track_event) @@ -105,7 +108,7 @@ end it 'tracks event with valid phone' do - user = build(:user, phone: good_phone, phone_confirmed_at: Time.zone.now) + user = build(:user, :with_phone, with: { phone: good_phone, confirmed_at: Time.zone.now }) stub_verify_steps_one_and_two(user) put :create, params: { idv_phone_form: { phone: good_phone } } @@ -124,7 +127,9 @@ context 'when same as user phone' do it 'redirects to result page and sets phone_confirmed_at' do - user = build(:user, phone: good_phone, phone_confirmed_at: Time.zone.now) + user = build(:user, :with_phone, with: { + phone: good_phone, confirmed_at: Time.zone.now + }) stub_verify_steps_one_and_two(user) put :create, params: { idv_phone_form: { phone: good_phone } } @@ -141,7 +146,9 @@ context 'when different from user phone' do it 'redirects to otp page and does not set phone_confirmed_at' do - user = build(:user, phone: '+1 (415) 555-0130', phone_confirmed_at: Time.zone.now) + user = build(:user, :with_phone, with: { + phone: '+1 (415) 555-0130', confirmed_at: Time.zone.now + }) stub_verify_steps_one_and_two(user) put :create, params: { idv_phone_form: { phone: good_phone } } @@ -159,7 +166,9 @@ end describe '#show' do - let(:user) { build(:user, phone: good_phone, phone_confirmed_at: Time.zone.now) } + let(:user) do + build(:user, :with_phone, with: { phone: good_phone, confirmed_at: Time.zone.now }) + end let(:params) { { phone: good_phone } } before do @@ -232,7 +241,9 @@ end let(:params) { { phone: bad_phone } } - let(:user) { build(:user, phone: bad_phone, phone_confirmed_at: Time.zone.now) } + let(:user) do + build(:user, :with_phone, with: { phone: bad_phone, confirmed_at: Time.zone.now }) + end it 'tracks event with invalid phone' do stub_analytics @@ -257,7 +268,9 @@ context 'attempt window has expired, previous attempts == max-1' do let(:two_days_ago) { Time.zone.now - 2.days } - let(:user) { build(:user, phone: good_phone, phone_confirmed_at: Time.zone.now) } + let(:user) do + build(:user, :with_phone, with: { phone: good_phone, confirmed_at: Time.zone.now }) + end before do user.idv_attempts = max_attempts - 1 @@ -275,7 +288,8 @@ end it 'passes the normalized phone to the background job' do - user = build(:user, phone: good_phone, phone_confirmed_at: Time.zone.now) + user = build(:user, :with_phone, with: { phone: good_phone, confirmed_at: Time.zone.now }) + stub_verify_steps_one_and_two(user) subject.params = { phone: normalized_phone } diff --git a/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb b/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb index 6dfd9445a2c..d6f131a0f1d 100644 --- a/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb +++ b/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb @@ -33,7 +33,7 @@ end it 'tracks the page visit and context' do - user = build_stubbed(:user, phone: '+1 (703) 555-0100') + user = build_stubbed(:user, :with_phone, with: { phone: '+1 (703) 555-0100' }) stub_sign_in_before_2fa(user) stub_analytics @@ -322,8 +322,6 @@ end it 'does not update user phone or phone_confirmed_at attributes' do - expect(subject.current_user.phone).to eq('+1 202-555-1212') - expect(subject.current_user.phone_confirmed_at).to eq(@previous_phone_confirmed_at) expect(subject.current_user.phone_configuration.phone).to eq('+1 202-555-1212') expect( subject.current_user.phone_configuration.confirmed_at @@ -355,8 +353,6 @@ context 'when user does not have an existing phone number' do before do - subject.current_user.phone = nil - subject.current_user.phone_confirmed_at = nil subject.current_user.phone_configuration.destroy subject.current_user.phone_configuration = nil subject.current_user.create_direct_otp diff --git a/spec/controllers/two_factor_authentication/personal_key_verification_controller_spec.rb b/spec/controllers/two_factor_authentication/personal_key_verification_controller_spec.rb index 9d1de9b92c0..84ee076916e 100644 --- a/spec/controllers/two_factor_authentication/personal_key_verification_controller_spec.rb +++ b/spec/controllers/two_factor_authentication/personal_key_verification_controller_spec.rb @@ -80,7 +80,7 @@ let(:payload) { { personal_key_form: personal_key } } before do - stub_sign_in_before_2fa(build(:user, phone: '+1 (703) 555-1212')) + stub_sign_in_before_2fa(build(:user, :with_phone, with: { phone: '+1 (703) 555-1212' })) form = instance_double(PersonalKeyForm) response = FormResponse.new( success: false, errors: {}, extra: { multi_factor_auth_method: 'personal key' } @@ -100,7 +100,7 @@ context 'when the user enters an invalid personal key' do before do - stub_sign_in_before_2fa(build(:user, phone: '+1 (703) 555-1212')) + stub_sign_in_before_2fa(build(:user, :with_phone, with: { phone: '+1 (703) 555-1212' })) form = instance_double(PersonalKeyForm) response = FormResponse.new( success: false, errors: {}, extra: { multi_factor_auth_method: 'personal key' } diff --git a/spec/controllers/two_factor_authentication/piv_cac_verification_controller_spec.rb b/spec/controllers/two_factor_authentication/piv_cac_verification_controller_spec.rb index bc0f98abbfc..3f7dbc95674 100644 --- a/spec/controllers/two_factor_authentication/piv_cac_verification_controller_spec.rb +++ b/spec/controllers/two_factor_authentication/piv_cac_verification_controller_spec.rb @@ -3,7 +3,7 @@ describe TwoFactorAuthentication::PivCacVerificationController do let(:user) do create(:user, :signed_up, :with_piv_or_cac, - phone: '+1 (703) 555-0000') + with: { phone: '+1 (703) 555-0000' }) end let(:nonce) { 'once' } diff --git a/spec/controllers/users/phones_controller_spec.rb b/spec/controllers/users/phones_controller_spec.rb index 16dc088fd7a..de01fb134b4 100644 --- a/spec/controllers/users/phones_controller_spec.rb +++ b/spec/controllers/users/phones_controller_spec.rb @@ -5,8 +5,8 @@ include Features::LocalizationHelper describe '#phone' do - let(:user) { create(:user, :signed_up, phone: '+1 (202) 555-1234') } - let(:second_user) { create(:user, :signed_up, phone: '+1 (202) 555-5678') } + let(:user) { create(:user, :signed_up, with: { phone: '+1 (202) 555-1234' }) } + let(:second_user) { create(:user, :signed_up, with: { phone: '+1 (202) 555-5678' }) } let(:new_phone) { '202-555-4321' } context 'user changes phone' do @@ -25,7 +25,6 @@ it 'lets user know they need to confirm their new phone' do expect(flash[:notice]).to eq t('devise.registrations.phone_update_needs_confirmation') - expect(user.reload.phone).to_not eq '+1 202-555-4321' expect(user.reload.phone_configuration.phone).to_not eq '+1 202-555-4321' expect(@analytics).to have_received(:track_event). with(Analytics::PHONE_CHANGE_REQUESTED) @@ -48,7 +47,6 @@ otp_delivery_preference: 'sms' }, } - expect(user.reload.phone).to be_present expect(user.reload.phone_configuration.phone).to be_present expect(response).to render_template(:edit) end @@ -62,7 +60,7 @@ allow(@analytics).to receive(:track_event) put :update, params: { - user_phone_form: { phone: second_user.phone, + user_phone_form: { phone: second_user.phone_configuration.phone, international_code: 'US', otp_delivery_preference: 'sms' }, } @@ -70,8 +68,9 @@ it 'processes successfully and informs user' do expect(flash[:notice]).to eq t('devise.registrations.phone_update_needs_confirmation') - expect(user.reload.phone).to_not eq second_user.phone - expect(user.reload.phone_configuration.phone).to_not eq second_user.phone + expect(user.phone_configuration.reload.phone).to_not eq( + second_user.phone_configuration.phone + ) expect(@analytics).to have_received(:track_event). with(Analytics::PHONE_CHANGE_REQUESTED) expect(response).to redirect_to( @@ -86,7 +85,7 @@ context 'user updates with invalid phone' do it 'does not change the user phone number' do invalid_phone = '123' - user = build(:user, phone: '123-123-1234') + user = build(:user, :with_phone, with: { phone: '123-123-1234' }) stub_sign_in(user) put :update, params: { @@ -95,7 +94,6 @@ otp_delivery_preference: 'sms' }, } - expect(user.phone).not_to eq invalid_phone expect(user.phone_configuration.phone).not_to eq invalid_phone expect(response).to render_template(:edit) end @@ -106,7 +104,7 @@ stub_sign_in(user) put :update, params: { - user_phone_form: { phone: user.phone, + user_phone_form: { phone: user.phone_configuration.phone, international_code: 'US', otp_delivery_preference: 'sms' }, } diff --git a/spec/controllers/users/piv_cac_authentication_setup_controller_spec.rb b/spec/controllers/users/piv_cac_authentication_setup_controller_spec.rb index bd282961ad3..ab7ef139bfe 100644 --- a/spec/controllers/users/piv_cac_authentication_setup_controller_spec.rb +++ b/spec/controllers/users/piv_cac_authentication_setup_controller_spec.rb @@ -32,7 +32,7 @@ describe 'when signing in' do before(:each) { stub_sign_in_before_2fa(user) } let(:user) do - create(:user, :signed_up, :with_piv_or_cac, phone: '+1 (703) 555-0000') + create(:user, :signed_up, :with_piv_or_cac, with: { phone: '+1 (703) 555-0000' }) end describe 'GET index' do @@ -55,7 +55,7 @@ context 'without associated piv/cac' do let(:user) do - create(:user, :signed_up, phone: '+1 (703) 555-0000') + create(:user, :signed_up, with: { phone: '+1 (703) 555-0000' }) end before(:each) do diff --git a/spec/controllers/users/totp_setup_controller_spec.rb b/spec/controllers/users/totp_setup_controller_spec.rb index d564f335671..3414c76b037 100644 --- a/spec/controllers/users/totp_setup_controller_spec.rb +++ b/spec/controllers/users/totp_setup_controller_spec.rb @@ -15,7 +15,7 @@ context 'user is setting up authenticator app after account creation' do before do stub_analytics - user = build(:user, phone: '703-555-1212') + user = build(:user, :with_phone, with: { phone: '703-555-1212' }) stub_sign_in(user) allow(@analytics).to receive(:track_event) get :new diff --git a/spec/controllers/users/two_factor_authentication_controller_spec.rb b/spec/controllers/users/two_factor_authentication_controller_spec.rb index 522182857bb..3fcb40b8a8b 100644 --- a/spec/controllers/users/two_factor_authentication_controller_spec.rb +++ b/spec/controllers/users/two_factor_authentication_controller_spec.rb @@ -101,7 +101,7 @@ def index context 'when the user has already set up 2FA' do it 'sends OTP via otp_delivery_preference and prompts for OTP' do - stub_sign_in_before_2fa(build(:user, phone: '+1 (703) 555-1212')) + stub_sign_in_before_2fa(build(:user, :with_phone, with: { phone: '+1 (703) 555-1212' })) get :show diff --git a/spec/factories/phone_configurations.rb b/spec/factories/phone_configurations.rb index 9c64b38e7f4..cca856f2000 100644 --- a/spec/factories/phone_configurations.rb +++ b/spec/factories/phone_configurations.rb @@ -2,8 +2,9 @@ Faker::Config.locale = :en factory :phone_configuration do - confirmed_at Time.zone.now - phone '+1 202-555-1212' - user + confirmed_at { Time.zone.now } + phone { '+1 202-555-1212' } + mfa_enabled { true } + association :user end end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 0bc4c575dbd..783fa67ed25 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -2,33 +2,46 @@ Faker::Config.locale = :en factory :user do + transient do + with { {} } + end + confirmed_at Time.zone.now email { Faker::Internet.safe_email } password '!1a Z@6s' * 16 # Maximum length password. - after :build do |user| - if user.phone - user.build_phone_configuration( - phone: user.phone, - confirmed_at: user.phone_confirmed_at, - delivery_preference: user.otp_delivery_preference - ) + trait :with_phone do + after(:build) do |user, evaluator| + if user.phone_configuration.nil? + user.phone_configuration = build( + :phone_configuration, + { user: user, delivery_preference: user.otp_delivery_preference }.merge( + evaluator.with.slice(:phone, :confirmed_at, :delivery_preference, :mfa_enabled) + ) + ) + end end - end - after :stub do |user| - if user.phone - user.phone_configuration = build_stubbed(:phone_configuration, - user: user, - phone: user.phone, - confirmed_at: user.phone_confirmed_at, - delivery_preference: user.otp_delivery_preference) + after(:create) do |user, evaluator| + if user.phone_configuration.nil? + create(:phone_configuration, + { user: user, delivery_preference: user.otp_delivery_preference }.merge( + evaluator.with.slice(:phone, :confirmed_at, :delivery_preference, :mfa_enabled) + )) + user.reload + end end - end - trait :with_phone do - phone '+1 202-555-1212' - phone_confirmed_at Time.zone.now + after(:stub) do |user, evaluator| + if user.phone_configuration.nil? + user.phone_configuration = build_stubbed( + :phone_configuration, + { user: user, delivery_preference: user.otp_delivery_preference }.merge( + evaluator.with.slice(:phone, :confirmed_at, :delivery_preference, :mfa_enabled) + ) + ) + end + end end trait :with_piv_or_cac do diff --git a/spec/features/accessibility/user_pages_spec.rb b/spec/features/accessibility/user_pages_spec.rb index 9db6fc614b6..fb2d88d423c 100644 --- a/spec/features/accessibility/user_pages_spec.rb +++ b/spec/features/accessibility/user_pages_spec.rb @@ -53,7 +53,7 @@ describe 'SMS' do scenario 'enter 2fa phone OTP code page' do - user = create(:user, phone: '+1 (202) 555-1212') + user = create(:user, :with_phone, with: { phone: '+1 (202) 555-1212' }) sign_in_before_2fa(user) visit login_two_factor_path(otp_delivery_preference: 'sms') @@ -64,7 +64,7 @@ describe 'Voice' do scenario 'enter 2fa phone OTP code page' do - user = create(:user, phone: '+1 (202) 555-1212') + user = create(:user, :with_phone, with: { phone: '+1 (202) 555-1212' }) sign_in_before_2fa(user) visit login_two_factor_path(otp_delivery_preference: 'voice') diff --git a/spec/features/sign_in/two_factor_options_spec.rb b/spec/features/sign_in/two_factor_options_spec.rb index c1b49981453..38828d07ca5 100644 --- a/spec/features/sign_in/two_factor_options_spec.rb +++ b/spec/features/sign_in/two_factor_options_spec.rb @@ -43,7 +43,8 @@ context 'when the user only has SMS configured with a number that we cannot call' do it 'only displays SMS and Personal key' do - user = create(:user, :signed_up, otp_delivery_preference: 'sms', phone: '+12423270143') + user = create(:user, :signed_up, + otp_delivery_preference: 'sms', with: { phone: '+12423270143' }) sign_in_user(user) click_link t('two_factor_authentication.login_options_link_text') @@ -63,7 +64,8 @@ context "the user's otp_delivery_preference is voice but number is unsupported" do it 'only displays SMS and Personal key' do - user = create(:user, :signed_up, otp_delivery_preference: 'voice', phone: '+12423270143') + user = create(:user, :signed_up, + otp_delivery_preference: 'voice', with: { phone: '+12423270143' }) sign_in_user(user) click_link t('two_factor_authentication.login_options_link_text') diff --git a/spec/features/two_factor_authentication/change_factor_spec.rb b/spec/features/two_factor_authentication/change_factor_spec.rb index 06a53905f73..1de6a318652 100644 --- a/spec/features/two_factor_authentication/change_factor_spec.rb +++ b/spec/features/two_factor_authentication/change_factor_spec.rb @@ -20,7 +20,7 @@ mailer = instance_double(ActionMailer::MessageDelivery, deliver_later: true) allow(UserMailer).to receive(:phone_changed).with(user).and_return(mailer) - @previous_phone_confirmed_at = user.reload.phone_confirmed_at + @previous_phone_confirmed_at = user.phone_configuration.reload.confirmed_at new_phone = '+1 703-555-0100' visit manage_phone_path @@ -39,8 +39,7 @@ enter_incorrect_otp_code expect(page).to have_content t('devise.two_factor_authentication.invalid_otp') - expect(user.reload.phone).to_not eq new_phone - expect(user.reload.phone_configuration.phone).to_not eq new_phone + expect(user.phone_configuration.reload.phone).to_not eq new_phone expect(page).to have_link t('forms.two_factor.try_again'), href: manage_phone_path submit_correct_otp @@ -49,9 +48,8 @@ expect(UserMailer).to have_received(:phone_changed).with(user) expect(mailer).to have_received(:deliver_later) expect(page).to have_content new_phone - expect(user.reload.phone_confirmed_at).to_not eq(@previous_phone_confirmed_at) expect( - user.reload.phone_configuration.confirmed_at + user.phone_configuration.reload.confirmed_at ).to_not eq(@previous_phone_confirmed_at) visit login_two_factor_path(otp_delivery_preference: 'sms') @@ -188,7 +186,7 @@ PhoneVerification.adapter = FakeAdapter allow(FakeAdapter).to receive(:post).and_return(FakeAdapter::ErrorResponse.new) - user = create(:user, :signed_up, phone: '+17035551212') + user = create(:user, :signed_up, with: { phone: '+17035551212' }) visit new_user_session_path sign_in_live_with_2fa(user) visit manage_phone_path diff --git a/spec/features/two_factor_authentication/sign_in_spec.rb b/spec/features/two_factor_authentication/sign_in_spec.rb index a6eaaa5b6a8..885a4ed263e 100644 --- a/spec/features/two_factor_authentication/sign_in_spec.rb +++ b/spec/features/two_factor_authentication/sign_in_spec.rb @@ -34,7 +34,6 @@ expect(page).to_not have_content invalid_phone_message expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'sms') - expect(user.reload.phone).to_not eq '+1 (703) 555-1212' expect(user.reload.phone_configuration).to be_nil expect(user.sms?).to eq true end @@ -242,7 +241,7 @@ def submit_prefilled_otp_code scenario 'the user cannot change delivery method if phone is unsupported' do unsupported_phone = '+1 (242) 327-0143' - user = create(:user, :signed_up, phone: unsupported_phone) + user = create(:user, :signed_up, with: { phone: unsupported_phone }) sign_in_before_2fa(user) expect(page).to_not have_link t('links.two_factor_authentication.voice') @@ -374,8 +373,8 @@ def submit_prefilled_otp_code context '2 users with same phone number request OTP too many times within findtime' do it 'locks both users out' do allow(Figaro.env).to receive(:otp_delivery_blocklist_maxretry).and_return('3') - first_user = create(:user, :signed_up, phone: '+1 703-555-1212') - second_user = create(:user, :signed_up, phone: '+1 703-555-1212') + first_user = create(:user, :signed_up, with: { phone: '+1 703-555-1212' }) + second_user = create(:user, :signed_up, with: { phone: '+1 703-555-1212' }) max_attempts = Figaro.env.otp_delivery_blocklist_maxretry.to_i sign_in_before_2fa(first_user) @@ -410,7 +409,7 @@ def submit_prefilled_otp_code context 'When setting up 2FA for the first time' do it 'enforces rate limiting only for current phone' do - second_user = create(:user, :signed_up, phone: '202-555-1212') + second_user = create(:user, :signed_up, with: { phone: '202-555-1212' }) sign_in_before_2fa max_attempts = Figaro.env.otp_delivery_blocklist_maxretry.to_i @@ -552,7 +551,7 @@ def submit_prefilled_otp_code PhoneVerification.adapter = FakeAdapter allow(SmsOtpSenderJob).to receive(:perform_later) - user = create(:user, :signed_up, phone: '+212 661-289324') + user = create(:user, :signed_up, with: { phone: '+212 661-289324' }) sign_in_user(user) expect(SmsOtpSenderJob).to have_received(:perform_later).with( @@ -574,7 +573,7 @@ def submit_prefilled_otp_code PhoneVerification.adapter = FakeAdapter allow(FakeAdapter).to receive(:post).and_return(FakeAdapter::ErrorResponse.new) - user = create(:user, :signed_up, phone: '+212 661-289324') + user = create(:user, :signed_up, with: { phone: '+212 661-289324' }) sign_in_user(user) expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'sms') @@ -593,7 +592,9 @@ def submit_prefilled_otp_code PhoneVerification.adapter = FakeAdapter allow(FakeAdapter).to receive(:post).and_return(FakeAdapter::ErrorResponse.new) - user = create(:user, :signed_up, phone: '+17035551212', otp_delivery_preference: 'voice') + user = create(:user, :signed_up, + otp_delivery_preference: 'voice', + with: { phone: '+17035551212', delivery_preference: 'voice' }) sign_in_user(user) expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'voice') diff --git a/spec/features/users/piv_cac_management_spec.rb b/spec/features/users/piv_cac_management_spec.rb index 1d39546ab11..941add76a96 100644 --- a/spec/features/users/piv_cac_management_spec.rb +++ b/spec/features/users/piv_cac_management_spec.rb @@ -13,7 +13,7 @@ def find_form(page, attributes) context 'with no piv/cac associated yet' do let(:uuid) { SecureRandom.uuid } - let(:user) { create(:user, :signed_up, phone: '+1 202-555-1212') } + let(:user) { create(:user, :signed_up, :with_phone, with: { phone: '+1 202-555-1212' }) } context 'with a service provider allowed to use piv/cac' do let(:identity_with_sp) do @@ -106,9 +106,10 @@ def find_form(page, attributes) allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) stub_piv_cac_service - user.update(phone: nil, otp_secret_key: 'secret') + user.update(otp_secret_key: 'secret') user.phone_configuration.destroy - user.phone_configuration = nil + user.reload + expect(user.phone_configuration).to be_nil sign_in_and_2fa_user(user) visit account_path click_link t('forms.buttons.enable'), href: setup_piv_cac_url @@ -156,7 +157,7 @@ def find_form(page, attributes) scenario "doesn't allow unassociation of a piv/cac" do stub_piv_cac_service - user = create(:user, :signed_up, phone: '+1 202-555-1212') + user = create(:user, :signed_up, :with_phone, with: { phone: '+1 202-555-1212' }) sign_in_and_2fa_user(user) visit account_path form = find_form(page, action: disable_piv_cac_url) @@ -167,7 +168,7 @@ def find_form(page, attributes) context 'with a piv/cac associated and no identities allowing piv/cac' do let(:user) do - create(:user, :signed_up, :with_piv_or_cac, phone: '+1 202-555-1212') + create(:user, :signed_up, :with_piv_or_cac, :with_phone, with: { phone: '+1 202-555-1212' }) end scenario "doesn't allow association of another piv/cac with the account" do diff --git a/spec/features/users/sign_in_spec.rb b/spec/features/users/sign_in_spec.rb index 04f72777622..3e27ae5d831 100644 --- a/spec/features/users/sign_in_spec.rb +++ b/spec/features/users/sign_in_spec.rb @@ -255,19 +255,20 @@ expect { signin(email, password) }. to raise_error Encryption::EncryptionError, 'unable to decrypt attribute with any key' - user = User.find_with_email(email) + user = user.reload expect(user.encrypted_email).to eq encrypted_email end end context 'KMS is on and user enters incorrect password' do it 'redirects to root_path with user-friendly error message, not a 500 error' do + user = create(:user) + email = user.email allow(FeatureManagement).to receive(:use_kms?).and_return(true) stub_aws_kms_client_invalid_ciphertext allow(SessionEncryptorErrorHandler).to receive(:call) - user = create(:user) - signin(user.email, 'invalid') + signin(email, 'invalid') link_url = new_user_password_url @@ -320,7 +321,8 @@ it 'falls back to SMS with an error message' do allow(SmsOtpSenderJob).to receive(:perform_later) allow(VoiceOtpSenderJob).to receive(:perform_later) - user = create(:user, :signed_up, phone: '+1 441-295-9644', otp_delivery_preference: 'voice') + user = create(:user, :signed_up, + otp_delivery_preference: 'voice', with: { phone: '+1 441-295-9644' }) signin(user.email, user.password) expect(VoiceOtpSenderJob).to_not have_received(:perform_later) @@ -339,7 +341,8 @@ it 'displays an error message but does not send an SMS' do allow(SmsOtpSenderJob).to receive(:perform_later) allow(VoiceOtpSenderJob).to receive(:perform_later) - user = create(:user, :signed_up, phone: '+91 1234567890', otp_delivery_preference: 'sms') + user = create(:user, :signed_up, + otp_delivery_preference: 'sms', with: { phone: '+91 1234567890' }) signin(user.email, user.password) visit login_two_factor_path(otp_delivery_preference: 'voice', reauthn: false) @@ -359,7 +362,8 @@ it 'displays an error message but does not send an SMS' do allow(SmsOtpSenderJob).to receive(:perform_later) allow(VoiceOtpSenderJob).to receive(:perform_later) - user = create(:user, :signed_up, phone: '+91 1234567890', otp_delivery_preference: 'sms') + user = create(:user, :signed_up, + otp_delivery_preference: 'sms', with: { phone: '+91 1234567890' }) signin(user.email, user.password) visit otp_send_path( otp_delivery_selection_form: { otp_delivery_preference: 'voice', resend: true } @@ -381,7 +385,8 @@ it 'displays an error message but does not send an SMS' do allow(SmsOtpSenderJob).to receive(:perform_later) allow(VoiceOtpSenderJob).to receive(:perform_later) - user = create(:user, :signed_up, phone: '+91 1234567890', otp_delivery_preference: 'voice') + user = create(:user, :signed_up, + otp_delivery_preference: 'voice', with: { phone: '+91 1234567890' }) signin(user.email, user.password) visit otp_send_path( otp_delivery_selection_form: { otp_delivery_preference: 'voice', resend: true } diff --git a/spec/features/visitors/phone_confirmation_spec.rb b/spec/features/visitors/phone_confirmation_spec.rb index de472a7d4c0..f47f577aa3c 100644 --- a/spec/features/visitors/phone_confirmation_spec.rb +++ b/spec/features/visitors/phone_confirmation_spec.rb @@ -22,7 +22,6 @@ it 'updates phone_confirmed_at and redirects to acknowledge personal key' do click_button t('forms.buttons.submit.default') - expect(@user.reload.phone_confirmed_at).to be_present expect(@user.reload.phone_configuration.confirmed_at).to be_present expect(current_path).to eq sign_up_personal_key_path @@ -76,7 +75,6 @@ fill_in 'code', with: 'foobar' click_submit_default - expect(@user.reload.phone_confirmed_at).to be_nil expect(@user.reload.phone_configuration).to be_nil expect(page).to have_content t('devise.two_factor_authentication.invalid_otp') expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'sms') diff --git a/spec/forms/idv/phone_form_spec.rb b/spec/forms/idv/phone_form_spec.rb index 00412d34256..27997f499f1 100644 --- a/spec/forms/idv/phone_form_spec.rb +++ b/spec/forms/idv/phone_form_spec.rb @@ -52,14 +52,14 @@ end it 'uses the user phone number as the initial phone value' do - user = build_stubbed(:user, :signed_up, phone: '7035551234') + user = build_stubbed(:user, :signed_up, with: { phone: '7035551234' }) subject = Idv::PhoneForm.new({}, user) expect(subject.phone).to eq('+1 703-555-1234') end it 'does not use an international number as the initial phone value' do - user = build_stubbed(:user, :signed_up, phone: '+81 54 354 3643') + user = build_stubbed(:user, :signed_up, with: { phone: '+81 54 354 3643' }) subject = Idv::PhoneForm.new({}, user) expect(subject.phone).to eq(nil) diff --git a/spec/forms/user_phone_form_spec.rb b/spec/forms/user_phone_form_spec.rb index 157ee5b7dc1..e3cbbebae82 100644 --- a/spec/forms/user_phone_form_spec.rb +++ b/spec/forms/user_phone_form_spec.rb @@ -17,8 +17,8 @@ it 'loads initial values from the user object' do user = build_stubbed( - :user, - phone: '+1 (703) 500-5000', + :user, :with_phone, + with: { phone: '+1 (703) 500-5000' }, otp_delivery_preference: 'voice' ) subject = UserPhoneForm.new(user) @@ -29,7 +29,7 @@ end it 'infers the international code from the user phone number' do - user = build_stubbed(:user, phone: '+81 744 21 1234') + user = build_stubbed(:user, :with_phone, with: { phone: '+81 744 21 1234' }) subject = UserPhoneForm.new(user) expect(subject.international_code).to eq('JP') @@ -78,7 +78,6 @@ subject.submit(params) user.reload - expect(user.phone).to_not eq('+1 504 444 1643') expect(user.phone_configuration).to be_nil end @@ -221,7 +220,6 @@ context 'when a user has no phone' do it 'returns true' do user.phone_configuration.destroy - user.update!(phone: nil) user.reload params[:phone] = '+1 504 444 1643' diff --git a/spec/lib/tasks/rotate_rake_spec.rb b/spec/lib/tasks/rotate_rake_spec.rb index 505df392fe6..99e74498dd1 100644 --- a/spec/lib/tasks/rotate_rake_spec.rb +++ b/spec/lib/tasks/rotate_rake_spec.rb @@ -2,7 +2,7 @@ require 'rake' describe 'rotate' do - let(:user) { create(:user, phone: '703-555-5555') } + let(:user) { create(:user, :with_phone, with: { phone: '703-555-5555' }) } before do Rake.application.rake_require('lib/tasks/rotate', [Rails.root.to_s]) Rake::Task.define_task(:environment) @@ -15,10 +15,9 @@ describe 'attribute_encryption_key' do it 'runs successfully' do old_email = user.email - old_phone = user.phone + old_phone = user.phone_configuration.phone old_encrypted_email = user.encrypted_email - old_encrypted_phone = user.encrypted_phone - old_encrypted_configuration_phone = user.phone_configuration.encrypted_phone + old_encrypted_phone = user.phone_configuration.encrypted_phone rotate_attribute_encryption_key @@ -26,15 +25,10 @@ user.reload user.phone_configuration.reload - expect(user.phone).to eq old_phone expect(user.phone_configuration.phone).to eq old_phone expect(user.email).to eq old_email expect(user.encrypted_email).to_not eq old_encrypted_email - expect(user.encrypted_phone).to_not eq old_encrypted_phone - expect(user.phone_configuration.encrypted_phone).to_not eq old_encrypted_configuration_phone - expect(user.phone_configuration.phone).to eq user.phone - # this double checks that we're not using the same IV for both - expect(user.phone_configuration.encrypted_phone).to_not eq user.encrypted_phone + expect(user.phone_configuration.encrypted_phone).to_not eq old_encrypted_phone end it 'does not raise an exception when encrypting/decrypting a user' do diff --git a/spec/models/phone_configuration_spec.rb b/spec/models/phone_configuration_spec.rb index 4bef0f7e17f..32b37b31acd 100644 --- a/spec/models/phone_configuration_spec.rb +++ b/spec/models/phone_configuration_spec.rb @@ -12,8 +12,23 @@ let(:phone_configuration) { create(:phone_configuration, phone: phone) } describe 'creation' do - it 'stores an encrypted form of the password' do + it 'stores an encrypted form of the phone number' do expect(phone_configuration.encrypted_phone).to_not be_blank end end + + describe 'encrypted attributes' do + it 'decrypts phone' do + expect(phone_configuration.phone).to eq phone + end + + context 'with unnormalized phone' do + let(:phone) { ' 555 555 5555 ' } + let(:normalized_phone) { '555 555 5555' } + + it 'normalizes phone' do + expect(phone_configuration.phone).to eq normalized_phone + end + end + end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 7adf3b2b53b..c767d7fea98 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -376,20 +376,11 @@ expect(user.email).to eq 'foo@example.org' end - - it 'normalizes phone' do - user = create(:user, phone: ' 555 555 5555 ') - - expect(user.phone).to eq '555 555 5555' - expect(user.phone_configuration.phone).to eq '555 555 5555' - end end - it 'decrypts phone and otp_secret_key' do - user = create(:user, phone: '+1 (202) 555-1212', otp_secret_key: 'abc123') + it 'decrypts otp_secret_key' do + user = create(:user, otp_secret_key: 'abc123') - expect(user.phone).to eq '+1 (202) 555-1212' - expect(user.phone_configuration.phone).to eq '+1 (202) 555-1212' expect(user.otp_secret_key).to eq 'abc123' end end diff --git a/spec/requests/edit_user_spec.rb b/spec/requests/edit_user_spec.rb index 4a015d25343..057c8ed7894 100644 --- a/spec/requests/edit_user_spec.rb +++ b/spec/requests/edit_user_spec.rb @@ -4,7 +4,7 @@ include Features::MailerHelper include Features::ActiveJobHelper - let(:user) { create(:user, :signed_up, phone: '+1 (202) 555-1213') } + let(:user) { create(:user, :signed_up, with: { phone: '+1 (202) 555-1213' }) } def user_session session['warden.user.user.session'] diff --git a/spec/requests/openid_connect_authorize_spec.rb b/spec/requests/openid_connect_authorize_spec.rb index 8b5dcb1e1a0..efd42de8c34 100644 --- a/spec/requests/openid_connect_authorize_spec.rb +++ b/spec/requests/openid_connect_authorize_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' describe 'user signs in partially and visits openid_connect/authorize' do - let(:user) { create(:user, :signed_up, phone: '+1 (202) 555-1213') } + let(:user) { create(:user, :signed_up, with: { phone: '+1 (202) 555-1213' }) } it 'prompts the user to 2FA' do openid_test('select_account') diff --git a/spec/services/account_reset/cancel_spec.rb b/spec/services/account_reset/cancel_spec.rb index 377acedfc91..5fe0e876281 100644 --- a/spec/services/account_reset/cancel_spec.rb +++ b/spec/services/account_reset/cancel_spec.rb @@ -21,6 +21,10 @@ context 'when the token is valid' do context 'when the user has a phone enabled for SMS' do + before(:each) do + user.phone_configuration.update!(delivery_preference: :sms) + end + it 'notifies the user via SMS of the account reset cancellation' do token = create_account_reset_request_for(user) allow(SmsAccountResetCancellationNotifierJob).to receive(:perform_now) @@ -36,7 +40,6 @@ it 'does not notify the user via SMS' do token = create_account_reset_request_for(user) allow(SmsAccountResetCancellationNotifierJob).to receive(:perform_now) - user.update!(phone: nil) user.phone_configuration.destroy! user.reload @@ -84,7 +87,7 @@ context 'when the user does not have a phone enabled for SMS' do it 'does not notify the user via SMS' do allow(SmsAccountResetCancellationNotifierJob).to receive(:perform_now) - user.update!(phone: nil) + user.phone_configuration.update!(mfa_enabled: false) AccountReset::Cancel.new('foo').call diff --git a/spec/services/key_rotator/attribute_encryption_spec.rb b/spec/services/key_rotator/attribute_encryption_spec.rb index 7e1e49c8a12..a863b46bf1e 100644 --- a/spec/services/key_rotator/attribute_encryption_spec.rb +++ b/spec/services/key_rotator/attribute_encryption_spec.rb @@ -2,25 +2,22 @@ describe KeyRotator::AttributeEncryption do describe '#rotate' do + let(:rotator) { described_class.new(user) } + let(:user) { create(:user) } + it 're-encrypts email and phone' do - user = create(:user, phone: '213-555-5555') - rotator = described_class.new(user) old_encrypted_email = user.encrypted_email - old_encrypted_phone = user.encrypted_phone rotate_attribute_encryption_key rotator.rotate expect(user.encrypted_email).to_not eq old_encrypted_email - expect(user.encrypted_phone).to_not eq old_encrypted_phone end it 'does not change the `updated_at` timestamp' do - user = create(:user) old_updated_timestamp = user.updated_at rotate_attribute_encryption_key - rotator = described_class.new(user) rotator.rotate expect(user.updated_at).to eq old_updated_timestamp diff --git a/spec/services/pii/cacher_spec.rb b/spec/services/pii/cacher_spec.rb index bf5c62c1507..22a64d1fa7f 100644 --- a/spec/services/pii/cacher_spec.rb +++ b/spec/services/pii/cacher_spec.rb @@ -38,7 +38,7 @@ old_ssn_signature = profile.ssn_signature old_email_fingerprint = user.email_fingerprint old_encrypted_email = user.encrypted_email - old_encrypted_phone = user.encrypted_phone + old_encrypted_phone = user.phone_configuration.encrypted_phone old_encrypted_otp_secret_key = user.encrypted_otp_secret_key rotate_all_keys @@ -55,7 +55,7 @@ expect(user.email_fingerprint).to_not eq old_email_fingerprint expect(user.encrypted_email).to_not eq old_encrypted_email expect(profile.ssn_signature).to_not eq old_ssn_signature - expect(user.encrypted_phone).to_not eq old_encrypted_phone + expect(user.phone_configuration.encrypted_phone).to_not eq old_encrypted_phone expect(user.encrypted_otp_secret_key).to_not eq old_encrypted_otp_secret_key end diff --git a/spec/services/populate_phone_configurations_table_spec.rb b/spec/services/populate_phone_configurations_table_spec.rb deleted file mode 100644 index 19bd60bbffd..00000000000 --- a/spec/services/populate_phone_configurations_table_spec.rb +++ /dev/null @@ -1,48 +0,0 @@ -require 'rails_helper' - -describe PopulatePhoneConfigurationsTable do - let(:subject) { described_class.new } - - describe '#call' do - context 'a user with no phone' do - let!(:user) { create(:user) } - - it 'migrates nothing' do - subject.call - expect(user.reload.phone_configuration).to be_nil - end - end - - context 'a user with a phone' do - let!(:user) { create(:user, :with_phone) } - - context 'and no phone_configuration entry' do - before(:each) do - user.phone_configuration.delete - user.reload - end - - it 'migrates without decrypting and re-encrypting' do - expect(EncryptedAttribute).to_not receive(:new) - subject.call - end - - it 'migrates the phone' do - subject.call - configuration = user.reload.phone_configuration - expect(configuration.phone).to eq user.phone - expect(configuration.confirmed_at).to eq user.phone_confirmed_at - expect(configuration.delivery_preference).to eq user.otp_delivery_preference - end - end - - context 'and an existing phone_configuration entry' do - it 'adds no new rows' do - expect(PhoneConfiguration.where(user_id: user.id).count).to eq 1 - subject.call - expect(PhoneConfiguration.where(user_id: user.id).count).to eq 1 - end - end - end - end -end diff --git a/spec/services/remember_device_cookie_spec.rb b/spec/services/remember_device_cookie_spec.rb index 98bc69a4c02..d076b462fd7 100644 --- a/spec/services/remember_device_cookie_spec.rb +++ b/spec/services/remember_device_cookie_spec.rb @@ -2,7 +2,7 @@ describe RememberDeviceCookie do let(:phone_confirmed_at) { 90.days.ago } - let(:user) { create(:user, :with_phone, phone_confirmed_at: phone_confirmed_at) } + let(:user) { create(:user, :with_phone, with: { confirmed_at: phone_confirmed_at }) } let(:created_at) { Time.zone.now } subject { described_class.new(user_id: user.id, created_at: created_at) } @@ -74,7 +74,7 @@ context 'when the token does not refer to the current user' do it 'returns false' do - other_user = create(:user, phone_confirmed_at: 90.days.ago) + other_user = create(:user, :with_phone, with: { confirmed_at: 90.days.ago }) expect(subject.valid_for_user?(other_user)).to eq(false) end diff --git a/spec/services/update_user_spec.rb b/spec/services/update_user_spec.rb index 80153d22192..9a480bfb358 100644 --- a/spec/services/update_user_spec.rb +++ b/spec/services/update_user_spec.rb @@ -31,11 +31,11 @@ expect(phone_configuration.phone).to eq '+1 222 333-4444' end - it 'deletes the phone configuration' do + it 'does not delete the phone configuration' do attributes = { phone: nil } updater = UpdateUser.new(user: user, attributes: attributes) updater.call - expect(user.reload.phone_configuration).to be_nil + expect(user.reload.phone_configuration).to_not be_nil end end diff --git a/spec/support/features/session_helper.rb b/spec/support/features/session_helper.rb index 6decb223a87..03074679eec 100644 --- a/spec/support/features/session_helper.rb +++ b/spec/support/features/session_helper.rb @@ -110,12 +110,12 @@ def sign_in_and_2fa_user(user = user_with_2fa) end def user_with_2fa - create(:user, :signed_up, phone: '+1 202-555-1212', password: VALID_PASSWORD) + create(:user, :signed_up, with: { phone: '+1 202-555-1212' }, password: VALID_PASSWORD) end def user_with_piv_cac create(:user, :signed_up, :with_piv_or_cac, - phone: '+1 (703) 555-0000', + with: { phone: '+1 (703) 555-0000' }, password: VALID_PASSWORD) end diff --git a/spec/support/shared_examples/sign_in.rb b/spec/support/shared_examples/sign_in.rb index 9c0353c20af..751a42b03bf 100644 --- a/spec/support/shared_examples/sign_in.rb +++ b/spec/support/shared_examples/sign_in.rb @@ -174,7 +174,9 @@ it 'does not allow bypassing setting up backup phone' do stub_piv_cac_service - user = create(:user, :signed_up, :with_piv_or_cac, phone: nil) + user = create(:user, :signed_up, :with_piv_or_cac) + user.phone_configuration.destroy + user.reload visit_idp_from_sp_with_loa1(sp) click_link t('links.sign_in') fill_in_credentials_and_submit(user.email, user.password) diff --git a/spec/support/shared_examples_for_phone_validation.rb b/spec/support/shared_examples_for_phone_validation.rb index cf2c732b093..c876e6d1c91 100644 --- a/spec/support/shared_examples_for_phone_validation.rb +++ b/spec/support/shared_examples_for_phone_validation.rb @@ -13,10 +13,10 @@ describe 'phone uniqueness' do context 'when phone is already taken' do it 'is valid' do - second_user = build_stubbed(:user, :signed_up, phone: '+1 (202) 555-1213') + second_user = build_stubbed(:user, :signed_up, with: { phone: '+1 (202) 555-1213' }) allow(User).to receive(:exists?).with(email: 'new@gmail.com').and_return(false) allow(User).to receive(:exists?).with( - phone: second_user.phone_configuration.phone + phone_configuration: { phone: second_user.phone_configuration.phone } ).and_return(true) params[:phone] = second_user.phone_configuration.phone @@ -37,7 +37,6 @@ context 'when phone is same as current user' do it 'is valid' do - user.phone = '+1 (703) 500-5000' user.phone_configuration.phone = '+1 (703) 500-5000' params[:phone] = user.phone_configuration.phone result = subject.submit(params) From 8486a065ef10e6330fca2d62a0ff0de31e9a1b50 Mon Sep 17 00:00:00 2001 From: James Smith Date: Fri, 7 Sep 2018 11:25:07 -0400 Subject: [PATCH 26/61] Fix tests using users with phones **Why**: As part of the move to multiple phones for a user, we changed how we build out users in factories when they also have a phone configured. This causes some PRs to break master if they didn't rebase. **How**: Fix failing tests. --- spec/features/users/webauthn_management_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/features/users/webauthn_management_spec.rb b/spec/features/users/webauthn_management_spec.rb index 2f626639ef1..e4d23460bc0 100644 --- a/spec/features/users/webauthn_management_spec.rb +++ b/spec/features/users/webauthn_management_spec.rb @@ -4,7 +4,7 @@ include WebauthnHelper context 'with no webauthn associated yet' do - let(:user) { create(:user, :signed_up, phone: '+1 202-555-1212') } + let(:user) { create(:user, :signed_up, with: { phone: '+1 202-555-1212' }) } it 'allows user to add a webauthn configuration' do mock_challenge From b2edb1a98dd97d99a83f8c07bb231f6db1281f72 Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Fri, 7 Sep 2018 13:26:07 -0400 Subject: [PATCH 27/61] Revert find_with_email changes **Why**: These changes were made as part of #2478 in order to make the test in `spec/features/users/sign_in_spec.rb:244` pass, but they masked a bug in `Users::SessionsController`. Moreover, they made the heavily used `find_with_email` method almost 4 times slower. The goal of this particular test is to make sure that the app fails loudly if we mess up the list of keys during key rotation. Preferably, we want to fail as early as possible. Here is the flow that explains why this test was passing before the changes in #2478: 1. User signs in with email and password 2. `Users::SessionsController#create` is called 3. `track_authentication_attempt(auth_params[:email])` is called 4. `user_signed_in_and_not_locked_out?(user)` is called 5. `return false unless current_user` is called 6. Devise calls `current_user`, then Warden calls `UpdateUser` in `config/initializers/session_limitable.rb:9`, which raises `Encryption::EncryptionError` because it is trying to decrypt the `phone` encryptable attribute. With the changes in #2478, `UpdateUser` no longer tries to decrypt the phone, which is a good thing. The flow then continues as follows: 7. `User#need_two_factor_authentication?` is called 8. `two_factor_enabled?` is called and returns true right after it sees that `phone_configuration&.mfa_enabled?` is true. Note that so far, no encrypted attributes have been accessed. 9. After a few more calls, `cache_active_profile` is reached and `cacher.save(auth_params[:password], profile)` is called 10. Within `Pii::Cacher#save`, `stale_attributes?` is called. 11. `user.phone_configuration&.stale_encrypted_phone?` is called, which which raises `Encryption::EncryptionError`. However, `Users::SessionsController#cache_active_profile` rescues this error, and then `profile.deactivate(:encryption_error)` is called, which raises an error because `profile` is `nil`. The reason why we never saw this bug in `SessionsController` is because we haven't had problems rotating keys, and because so far, up until the changes in #2478, `Encryption::EncryptionError` was raised before `cache_active_profile` was reached. For example, before we introduced the PhoneConfiguration table, the error was raised early via `two_factor_enabled?`, which accessed the `phone` encryptable attribute. Similarly, if you change the spec to use a user `:with_authentication_app` instead of `:signed_up`, the test will pass because `two_factor_enabled?` will call `totp_enabled?`, which will try to decrypt the `otp_secret_key`. To make the test pass with an MFA-enabled user, we can wrap the 2 lines (in `cache_active_profile`) after the rescue in an `if profile` block, which we should do anyways. This will then allow the user to sign in, and will then raise the `Encryption::EncryptionError` on the 2FA page when it tries to decrypt the phone number. Ideally, we want to fail as early as possible, but with the current design of `cache_active_profile`, that's not possible because it is coupling actions that only apply to verified users with actions that apply to all users when keys are rotated. In a follow-up PR, I will attempt to extract the key rotation so that decryption attempts fail early and are not rescued. --- app/controllers/users/sessions_controller.rb | 6 ++++-- app/models/concerns/user_encrypted_attribute_overrides.rb | 3 +-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index b48093f87c0..54cfaddb588 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -130,8 +130,10 @@ def cache_active_profile begin cacher.save(auth_params[:password], profile) rescue Encryption::EncryptionError => err - profile.deactivate(:encryption_error) - analytics.track_event(Analytics::PROFILE_ENCRYPTION_INVALID, error: err.message) + if profile + profile.deactivate(:encryption_error) + analytics.track_event(Analytics::PROFILE_ENCRYPTION_INVALID, error: err.message) + end end end diff --git a/app/models/concerns/user_encrypted_attribute_overrides.rb b/app/models/concerns/user_encrypted_attribute_overrides.rb index 4abaa71b73c..ae5670e1115 100644 --- a/app/models/concerns/user_encrypted_attribute_overrides.rb +++ b/app/models/concerns/user_encrypted_attribute_overrides.rb @@ -15,8 +15,7 @@ def find_with_email(email) email = email.downcase.strip email_fingerprint = create_fingerprint(email) - resource = find_by(email_fingerprint: email_fingerprint) - resource if resource&.email == email + find_by(email_fingerprint: email_fingerprint) end def create_fingerprint(email) From 212377d580a5fea0e82f5a7c33d21e6474a6d8cb Mon Sep 17 00:00:00 2001 From: John Donmoyer Date: Fri, 7 Sep 2018 14:13:57 -0500 Subject: [PATCH 28/61] LG-572 Visual design tweaks on /verify/session (#2453) * verify/session design tweaks * making lines shorter * removing experimental config simpleform config change * making link open in new window * adding french translateion * update spanish help link --- app/views/idv/sessions/new.html.slim | 32 ++++++++++++++++------------ config/locales/links/en.yml | 2 +- config/locales/links/es.yml | 2 +- config/locales/links/fr.yml | 2 +- 4 files changed, 21 insertions(+), 17 deletions(-) diff --git a/app/views/idv/sessions/new.html.slim b/app/views/idv/sessions/new.html.slim index 1471f12cca6..174f18a49cf 100644 --- a/app/views/idv/sessions/new.html.slim +++ b/app/views/idv/sessions/new.html.slim @@ -3,7 +3,8 @@ h1.h3 = t('idv.titles.sessions') p = link_to t('links.access_help'), - 'https://login.gov/help/privacy-and-security/how-does-logingov-protect-my-data/' + 'https://login.gov/help/privacy-and-security/how-does-logingov-protect-my-data/', + target: :_blank = simple_form_for(@idv_form, url: idv_session_path, html: { autocomplete: 'off', method: :put, role: 'form' }) do |f| @@ -33,19 +34,22 @@ p = link_to t('links.access_help'), p = t('idv.messages.sessions.id_information_message') fieldset.m0.p0.border-none - = f.label :state_id_type, label: t('idv.form.state_id_type_label'), class: 'bold', - id: 'profile_state_id_type_label', required: true - - state_id_types.each do |state_id_type| - = f.label 'profile[state_id_type]', class: 'block mb1', - for: "profile_state_id_type_#{state_id_type[1]}" - .radio - = radio_button_tag 'profile[state_id_type]', state_id_type[1], - state_id_type[1] == 'drivers_license', - 'aria-labelledby': 'profile_state_id_type_label' - span.indicator - .block = state_id_type[0] - = f.input :state_id_number, label: t('idv.form.state_id'), required: true - = f.input :address1, label: t('idv.form.address1'), required: true + .mb1 + = f.label :state_id_type, label: t('idv.form.state_id_type_label'), class: 'bold', + id: 'profile_state_id_type_label', required: true + - state_id_types.each do |state_id_type| + = f.label 'profile[state_id_type]', class: 'block mb1', + for: "profile_state_id_type_#{state_id_type[1]}" + .radio + = radio_button_tag 'profile[state_id_type]', state_id_type[1], + state_id_type[1] == 'drivers_license', + 'aria-labelledby': 'profile_state_id_type_label' + span.indicator + .block = state_id_type[0] + = f.input :state_id_number, label: t('idv.form.state_id'), input_html: { class: 'sm-col-8' }, + required: true + = f.input :address1, label: t('idv.form.address1'), wrapper_html: { class: 'mb1' }, + required: true = f.input :address2, label: t('idv.form.address2') = f.input :city, label: t('idv.form.city'), required: true diff --git a/config/locales/links/en.yml b/config/locales/links/en.yml index 10e5c1fedfd..4d334021e03 100644 --- a/config/locales/links/en.yml +++ b/config/locales/links/en.yml @@ -1,7 +1,7 @@ --- en: links: - access_help: Protect your data. + access_help: How login.gov protects your data. account: reactivate: with_key: I have my key diff --git a/config/locales/links/es.yml b/config/locales/links/es.yml index 1cb01d80268..6bb67395759 100644 --- a/config/locales/links/es.yml +++ b/config/locales/links/es.yml @@ -1,7 +1,7 @@ --- es: links: - access_help: Protege tus datos. + access_help: Cómo login.gov protege sus datos. account: reactivate: with_key: Tengo mi clave diff --git a/config/locales/links/fr.yml b/config/locales/links/fr.yml index 86e0a44daf3..274cd199b08 100644 --- a/config/locales/links/fr.yml +++ b/config/locales/links/fr.yml @@ -1,7 +1,7 @@ --- fr: links: - access_help: Protégez vos données. + access_help: Comment login.gov protège vos données. account: reactivate: with_key: J'ai ma clé From 6a9486be518d4a79c262520cebbb788b3baceeb5 Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Thu, 6 Sep 2018 22:20:33 -0400 Subject: [PATCH 29/61] Update aws-sdk-kms from 1.7.0 to 1.9.0 --- Gemfile.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 7225448e4c6..9a5f406ecd1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -125,14 +125,14 @@ GEM arel (8.0.0) ast (2.4.0) aws-eventstream (1.0.1) - aws-partitions (1.97.0) - aws-sdk-core (3.24.0) + aws-partitions (1.103.0) + aws-sdk-core (3.27.0) aws-eventstream (~> 1.0) aws-partitions (~> 1.0) aws-sigv4 (~> 1.0) jmespath (~> 1.0) - aws-sdk-kms (1.7.0) - aws-sdk-core (~> 3) + aws-sdk-kms (1.9.0) + aws-sdk-core (~> 3, >= 3.26.0) aws-sigv4 (~> 1.0) aws-sdk-s3 (1.17.0) aws-sdk-core (~> 3, >= 3.21.2) @@ -766,4 +766,4 @@ RUBY VERSION ruby 2.5.1p57 BUNDLED WITH - 1.16.3 + 1.16.4 From 33471190cf03aad743e3c16426999c1af73004ee Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Thu, 6 Sep 2018 22:20:45 -0400 Subject: [PATCH 30/61] Update aws-sdk-ses from 1.8.0 to 1.10.0 --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 9a5f406ecd1..466aab77a2e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -138,8 +138,8 @@ GEM aws-sdk-core (~> 3, >= 3.21.2) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.0) - aws-sdk-ses (1.8.0) - aws-sdk-core (~> 3) + aws-sdk-ses (1.10.0) + aws-sdk-core (~> 3, >= 3.26.0) aws-sigv4 (~> 1.0) aws-sigv4 (1.0.3) axe-matchers (1.3.4) From 59cb884241ee8e4ca1a52770ab7a186a075cb17d Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Thu, 6 Sep 2018 22:20:54 -0400 Subject: [PATCH 31/61] Update better_errors from 2.4.0 to 2.5.0 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 466aab77a2e..48934a50c85 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -152,7 +152,7 @@ GEM base32-crockford (0.1.0) bcrypt (3.1.12) benchmark-ips (2.7.2) - better_errors (2.4.0) + better_errors (2.5.0) coderay (>= 1.0.0) erubi (>= 1.0.0) rack (>= 0.9.0) From 7d4d3dfeda24239d5fbe77d1eab7602302cacb61 Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Thu, 6 Sep 2018 22:21:03 -0400 Subject: [PATCH 32/61] Update bullet from 5.7.5 to 5.7.6 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 48934a50c85..68c88cb53ff 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -162,7 +162,7 @@ GEM brakeman (4.3.1) browser (2.5.3) builder (3.2.3) - bullet (5.7.5) + bullet (5.7.6) activesupport (>= 3.0.0) uniform_notifier (~> 1.11.0) bummr (0.3.2) From 1b6176d57efa41feb80e3a0ea638e501cfde1ff0 Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Thu, 6 Sep 2018 22:21:14 -0400 Subject: [PATCH 33/61] Update devise from 4.4.3 to 4.5.0 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 68c88cb53ff..d0e93f3058a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -219,7 +219,7 @@ GEM descendants_tracker (0.0.4) thread_safe (~> 0.3, >= 0.3.1) device_detector (1.0.1) - devise (4.4.3) + devise (4.5.0) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 4.1.0, < 6.0) From 2277ce5ebab4006d109a5b1aafbe4716074e8f9b Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Thu, 6 Sep 2018 22:53:45 -0400 Subject: [PATCH 34/61] LG-465 Remove no longer needed Devise code **Why**: We added this code as part of LG-439 to fix a 500 error, but Devise version 4.5.0 includes the fix. --- .reek | 2 -- app/controllers/users/sessions_controller.rb | 7 ------- 2 files changed, 9 deletions(-) diff --git a/.reek b/.reek index 97b113060fb..3371a700ea8 100644 --- a/.reek +++ b/.reek @@ -46,7 +46,6 @@ FeatureEnvy: - Utf8Sanitizer#event_attributes - Utf8Sanitizer#remote_ip - TwoFactorAuthenticationController#capture_analytics_for_exception - - Users::SessionsController#configure_permitted_parameters - UspsConfirmationExporter#make_entry_row InstanceVariableAssumption: exclude: @@ -58,7 +57,6 @@ ManualDispatch: exclude: - EncryptedSidekiqRedis#respond_to_missing? - CloudhsmKeyGenerator#initialize_settings - - Users::SessionsController#configure_permitted_parameters NestedIterators: exclude: - UserFlowExporter#self.massage_html diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index b48093f87c0..c5d7acab681 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -10,7 +10,6 @@ class SessionsController < Devise::SessionsController before_action :store_sp_metadata_in_session, only: [:new] before_action :check_user_needs_redirect, only: [:new] before_action :apply_secure_headers_override, only: [:new] - before_action :configure_permitted_parameters, only: [:new] def new analytics.track_event( @@ -51,12 +50,6 @@ def timeout private - def configure_permitted_parameters - devise_parameter_sanitizer.permit(:sign_in) do |user_params| - user_params.permit(:email) if user_params.respond_to?(:permit) - end - end - def redirect_to_signin controller_info = 'users/sessions#create' analytics.track_event(Analytics::INVALID_AUTHENTICITY_TOKEN, controller: controller_info) From d70d5f8d2f8818bd641ad311b99d8dc2ea77ab57 Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Thu, 6 Sep 2018 22:21:24 -0400 Subject: [PATCH 35/61] Update factory_bot_rails from 4.10.0 to 4.11.0 This version generates deprecation warnings for the new dynamic style of defining factory attributes. The warnings were suppressed by following the instructions in the warnings: running `gem install rubocop-rspec` and then ``` rubocop \ --require rubocop-rspec \ --only FactoryBot/AttributeDefinedStatically \ --auto-correct ``` --- Gemfile.lock | 6 +++--- spec/factories/authorizations.rb | 6 +++--- spec/factories/events.rb | 4 ++-- spec/factories/identities.rb | 4 ++-- spec/factories/otp_presenter.rb | 8 ++++---- spec/factories/profiles.rb | 8 ++++---- spec/factories/service_providers.rb | 6 +++--- spec/factories/users.rb | 14 +++++++------- spec/factories/usps_confirmation_codes.rb | 2 +- 9 files changed, 29 insertions(+), 29 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index d0e93f3058a..c12d6924386 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -250,10 +250,10 @@ GEM actionmailer (>= 4.0, < 6) activesupport (>= 4.0, < 6) execjs (2.7.0) - factory_bot (4.10.0) + factory_bot (4.11.0) activesupport (>= 3.0.0) - factory_bot_rails (4.10.0) - factory_bot (~> 4.10.0) + factory_bot_rails (4.11.0) + factory_bot (~> 4.11.0) railties (>= 3.0.0) fakefs (0.18.0) faker (1.9.1) diff --git a/spec/factories/authorizations.rb b/spec/factories/authorizations.rb index bb59a4c0947..1832018523b 100644 --- a/spec/factories/authorizations.rb +++ b/spec/factories/authorizations.rb @@ -1,7 +1,7 @@ FactoryBot.define do factory :authorization do - provider 'saml' - uid '1234' - user_id 1 + provider { 'saml' } + uid { '1234' } + user_id { 1 } end end diff --git a/spec/factories/events.rb b/spec/factories/events.rb index e9c07e39e2b..6726efc75e2 100644 --- a/spec/factories/events.rb +++ b/spec/factories/events.rb @@ -1,6 +1,6 @@ FactoryBot.define do factory :event do - user_id 1 - event_type :account_created + user_id { 1 } + event_type { :account_created } end end diff --git a/spec/factories/identities.rb b/spec/factories/identities.rb index 347ba5135d9..b2f6f6766f9 100644 --- a/spec/factories/identities.rb +++ b/spec/factories/identities.rb @@ -1,10 +1,10 @@ FactoryBot.define do factory :identity do uuid { SecureRandom.uuid } - service_provider 'https://serviceprovider.com' + service_provider { 'https://serviceprovider.com' } end trait :active do - last_authenticated_at Time.zone.now + last_authenticated_at { Time.zone.now } end end diff --git a/spec/factories/otp_presenter.rb b/spec/factories/otp_presenter.rb index e30038bffb1..34f9184d33c 100644 --- a/spec/factories/otp_presenter.rb +++ b/spec/factories/otp_presenter.rb @@ -1,8 +1,8 @@ FactoryBot.define do factory :generic_otp_presenter, class: Hash do - otp_delivery_preference 'sms' - phone_number '***-***-1212' - code_value '12777' - unconfirmed_user false + otp_delivery_preference { 'sms' } + phone_number { '***-***-1212' } + code_value { '12777' } + unconfirmed_user { false } end end diff --git a/spec/factories/profiles.rb b/spec/factories/profiles.rb index 0aaa89cf04a..5370a87defb 100644 --- a/spec/factories/profiles.rb +++ b/spec/factories/profiles.rb @@ -2,16 +2,16 @@ factory :profile do association :user, factory: %i[user signed_up] transient do - pii false + pii { false } end trait :active do - active true - activated_at Time.zone.now + active { true } + activated_at { Time.zone.now } end trait :verified do - verified_at Time.zone.now + verified_at { Time.zone.now } end after(:build) do |profile, evaluator| diff --git a/spec/factories/service_providers.rb b/spec/factories/service_providers.rb index 58cb0799fe0..4f7210ace88 100644 --- a/spec/factories/service_providers.rb +++ b/spec/factories/service_providers.rb @@ -3,9 +3,9 @@ factory :service_provider do cert { 'saml_test_sp' } - friendly_name 'Test Service Provider' + friendly_name { 'Test Service Provider' } issuer { SecureRandom.uuid } - return_to_sp_url '/' - agency 'Test Agency' + return_to_sp_url { '/' } + agency { 'Test Agency' } end end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 783fa67ed25..34aeef7e86b 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -6,9 +6,9 @@ with { {} } end - confirmed_at Time.zone.now + confirmed_at { Time.zone.now } email { Faker::Internet.safe_email } - password '!1a Z@6s' * 16 # Maximum length password. + password { '!1a Z@6s' * 16 } # Maximum length password. trait :with_phone do after(:build) do |user, evaluator| @@ -56,15 +56,15 @@ trait :with_authentication_app do with_personal_key - otp_secret_key ROTP::Base32.random_base32 + otp_secret_key { ROTP::Base32.random_base32 } end trait :admin do - role :admin + role { :admin } end trait :tech_support do - role :tech + role { :tech } end trait :signed_up do @@ -73,8 +73,8 @@ end trait :unconfirmed do - confirmed_at nil - password nil + confirmed_at { nil } + password { nil } end end end diff --git a/spec/factories/usps_confirmation_codes.rb b/spec/factories/usps_confirmation_codes.rb index 5ddf28cf118..8a00f8d6eea 100644 --- a/spec/factories/usps_confirmation_codes.rb +++ b/spec/factories/usps_confirmation_codes.rb @@ -3,6 +3,6 @@ factory :usps_confirmation_code do profile - otp_fingerprint Pii::Fingerprinter.fingerprint('ABCDE12345') + otp_fingerprint { Pii::Fingerprinter.fingerprint('ABCDE12345') } end end From 524054a93af5c9baf1bec116b110c9828e49249e Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Thu, 6 Sep 2018 22:21:34 -0400 Subject: [PATCH 36/61] Update hashie from 3.5.7 to 3.6.0 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index c12d6924386..855639420e6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -295,7 +295,7 @@ GEM gyoku (1.3.1) builder (>= 2.1.2) hashdiff (0.3.7) - hashie (3.5.7) + hashie (3.6.0) heapy (0.1.3) highline (2.0.0) hiredis (0.6.1) From f37980214ea0dedfa92af73e857705632068c7a6 Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Thu, 6 Sep 2018 22:21:43 -0400 Subject: [PATCH 37/61] Update i18n-tasks from 0.9.23 to 0.9.24 --- Gemfile.lock | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 855639420e6..19bd0f34edb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -205,7 +205,6 @@ GEM daemons (1.2.4) database_cleaner (1.7.0) debug_inspector (0.0.3) - deepl-rb (2.1.0) derailed (0.1.0) derailed_benchmarks derailed_benchmarks (1.3.4) @@ -232,9 +231,6 @@ GEM actionpack (>= 4) i18n dumb_delegator (0.8.0) - easy_translate (0.5.1) - thread - thread_safe email_spec (2.2.0) htmlentities (~> 4.3.3) launchy (~> 2.1) @@ -308,13 +304,11 @@ GEM socksify i18n (1.1.0) concurrent-ruby (~> 1.0) - i18n-tasks (0.9.23) + i18n-tasks (0.9.24) activesupport (>= 4.0.2) ast (>= 2.1.0) - deepl-rb (>= 2.1.0) - easy_translate (>= 0.5.1) erubi - highline (>= 1.7.3) + highline (>= 2.0.0) i18n parser (>= 2.2.3.0) rainbow (>= 2.2.2, < 4.0) @@ -590,7 +584,6 @@ GEM eventmachine (~> 1.0, >= 1.0.4) rack (>= 1, < 3) thor (0.20.0) - thread (0.2.2) thread_safe (0.3.6) tilt (2.0.8) timecop (0.9.1) From 58ad239f64d2252e2b01907a6dfb6eb3e4b772f5 Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Thu, 6 Sep 2018 22:22:02 -0400 Subject: [PATCH 38/61] Update overcommit from 0.45.0 to 0.46.0 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 19bd0f34edb..8163744d384 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -366,7 +366,7 @@ GEM nenv (~> 0.1) shellany (~> 0.0) orm_adapter (0.5.0) - overcommit (0.45.0) + overcommit (0.46.0) childprocess (~> 0.6, >= 0.6.3) iniparse (~> 1.4) parallel (1.12.1) From 432ca1884e96eb94bbc99447c0bc5f13960258be Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Thu, 6 Sep 2018 22:22:19 -0400 Subject: [PATCH 39/61] Update pg from 1.0.0 to 1.1.3 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 8163744d384..f22f335d9f1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -372,7 +372,7 @@ GEM parallel (1.12.1) parser (2.5.1.2) ast (~> 2.4.0) - pg (1.0.0) + pg (1.1.3) phonelib (0.6.24) pkcs11 (0.2.7) powerpack (0.1.2) From 333dcb397db1c5bf445727ee01ebb214dd4b2909 Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Thu, 6 Sep 2018 22:22:28 -0400 Subject: [PATCH 40/61] Update recaptcha from 4.11.1 to 4.12.0 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index f22f335d9f1..9a69d27fa65 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -449,7 +449,7 @@ GEM readthis (2.2.0) connection_pool (~> 2.1) redis (>= 3.0, < 5.0) - recaptcha (4.11.1) + recaptcha (4.12.0) json redis (3.3.5) reek (4.8.1) From c973847328e99887653f1a3a16fa81502bd92dc6 Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Thu, 6 Sep 2018 22:22:57 -0400 Subject: [PATCH 41/61] Update ruby-saml from 1.8.0 to 1.9.0 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 9a69d27fa65..a0c3598992e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -496,7 +496,7 @@ GEM unicode-display_width (~> 1.0, >= 1.0.1) ruby-graphviz (1.2.3) ruby-progressbar (1.10.0) - ruby-saml (1.8.0) + ruby-saml (1.9.0) nokogiri (>= 1.5.10) ruby_dep (1.5.0) ruby_parser (3.11.0) From d751a1ae11547dd9c20ad780290cadde66100a9f Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Thu, 6 Sep 2018 22:23:06 -0400 Subject: [PATCH 42/61] Update slim_lint from 0.15.1 to 0.16.0 --- Gemfile.lock | 4 ++-- app/views/devise/mailer/confirmation_instructions.html.slim | 2 +- app/views/devise/mailer/reset_password_instructions.html.slim | 2 +- app/views/user_mailer/account_reset_granted.html.slim | 4 ++-- app/views/user_mailer/signup_with_your_email.html.slim | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index a0c3598992e..53df41fa4f6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -558,10 +558,10 @@ GEM actionpack (>= 3.1) railties (>= 3.1) slim (~> 3.0) - slim_lint (0.15.1) + slim_lint (0.16.0) rake (>= 10, < 13) rubocop (>= 0.50.0) - slim (~> 3.0) + slim (>= 3.0, < 5.0) sysexits (~> 1.1) socksify (1.7.1) sprockets (3.7.2) diff --git a/app/views/devise/mailer/confirmation_instructions.html.slim b/app/views/devise/mailer/confirmation_instructions.html.slim index dfe170fb1b1..dc924ad4b9f 100644 --- a/app/views/devise/mailer/confirmation_instructions.html.slim +++ b/app/views/devise/mailer/confirmation_instructions.html.slim @@ -31,4 +31,4 @@ table.hr th |   -p= t('mailer.confirmation_instructions.footer', confirmation_period: @confirmation_period) +p = t('mailer.confirmation_instructions.footer', confirmation_period: @confirmation_period) diff --git a/app/views/devise/mailer/reset_password_instructions.html.slim b/app/views/devise/mailer/reset_password_instructions.html.slim index 71674e9e400..dcda6aa5f37 100644 --- a/app/views/devise/mailer/reset_password_instructions.html.slim +++ b/app/views/devise/mailer/reset_password_instructions.html.slim @@ -30,4 +30,4 @@ table.hr th |   -p= t('mailer.reset_password.footer', expires: Devise.reset_password_within / 3600) +p = t('mailer.reset_password.footer', expires: Devise.reset_password_within / 3600) diff --git a/app/views/user_mailer/account_reset_granted.html.slim b/app/views/user_mailer/account_reset_granted.html.slim index e244672df0a..0aa95c92bb5 100644 --- a/app/views/user_mailer/account_reset_granted.html.slim +++ b/app/views/user_mailer/account_reset_granted.html.slim @@ -21,7 +21,7 @@ table.hr tr th |   -p= t('mailer.confirmation_instructions.footer', confirmation_period: '24 hours') -p== t('user_mailer.account_reset_granted.help', +p = t('mailer.confirmation_instructions.footer', confirmation_period: '24 hours') +p == t('user_mailer.account_reset_granted.help', cancel_account_reset: link_to(t('user_mailer.account_reset_granted.cancel_link_text'), account_reset_cancel_url(token: @token))) diff --git a/app/views/user_mailer/signup_with_your_email.html.slim b/app/views/user_mailer/signup_with_your_email.html.slim index 6dd1e27b865..d361aaa9c57 100644 --- a/app/views/user_mailer/signup_with_your_email.html.slim +++ b/app/views/user_mailer/signup_with_your_email.html.slim @@ -14,7 +14,7 @@ table.button.expanded.large.radius class: 'float-center', align: 'center' td.expander -p= link_to @root_url, @root_url, target: '_blank' +p = link_to @root_url, @root_url, target: '_blank' table.spacer tbody From 78e992a509333f9bc9807f97c83e8a8dc1bd9ec6 Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Thu, 6 Sep 2018 22:23:18 -0400 Subject: [PATCH 43/61] Update twilio-ruby from 5.12.1 to 5.12.4 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 53df41fa4f6..846c19bd573 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -587,7 +587,7 @@ GEM thread_safe (0.3.6) tilt (2.0.8) timecop (0.9.1) - twilio-ruby (5.12.1) + twilio-ruby (5.12.4) faraday (~> 0.9) jwt (>= 1.5, <= 2.5) nokogiri (>= 1.6, < 2.0) From d19895984d538dfd201486cac8d7be24573b7c2b Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Thu, 6 Sep 2018 23:19:09 -0400 Subject: [PATCH 44/61] Update selenium-webdriver from 3.11.0 to 3.14.0 --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 846c19bd573..d5d54b33fbe 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -501,7 +501,7 @@ GEM ruby_dep (1.5.0) ruby_parser (3.11.0) sexp_processor (~> 4.9) - rubyzip (1.2.1) + rubyzip (1.2.2) safe_yaml (1.0.4) safely_block (0.2.1) errbase @@ -527,7 +527,7 @@ GEM scrypt (3.0.5) ffi-compiler (>= 1.0, < 2.0) secure_headers (6.0.0) - selenium-webdriver (3.11.0) + selenium-webdriver (3.14.0) childprocess (~> 0.5) rubyzip (~> 1.2) sexp_processor (4.11.0) From 9bf35534843dab5e7705a0ebf6b4d62f2e246e50 Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Thu, 6 Sep 2018 23:24:15 -0400 Subject: [PATCH 45/61] Update rspec-rails from 3.7.2 to 3.8.0 --- Gemfile.lock | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index d5d54b33fbe..99f6d86e986 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -465,27 +465,27 @@ GEM rotp (3.3.1) rqrcode (0.10.1) chunky_png (~> 1.0) - rspec (3.7.0) - rspec-core (~> 3.7.0) - rspec-expectations (~> 3.7.0) - rspec-mocks (~> 3.7.0) - rspec-core (3.7.1) - rspec-support (~> 3.7.0) - rspec-expectations (3.7.0) + rspec (3.8.0) + rspec-core (~> 3.8.0) + rspec-expectations (~> 3.8.0) + rspec-mocks (~> 3.8.0) + rspec-core (3.8.0) + rspec-support (~> 3.8.0) + rspec-expectations (3.8.1) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.7.0) - rspec-mocks (3.7.0) + rspec-support (~> 3.8.0) + rspec-mocks (3.8.0) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.7.0) - rspec-rails (3.7.2) + rspec-support (~> 3.8.0) + rspec-rails (3.8.0) actionpack (>= 3.0) activesupport (>= 3.0) railties (>= 3.0) - rspec-core (~> 3.7.0) - rspec-expectations (~> 3.7.0) - rspec-mocks (~> 3.7.0) - rspec-support (~> 3.7.0) - rspec-support (3.7.1) + rspec-core (~> 3.8.0) + rspec-expectations (~> 3.8.0) + rspec-mocks (~> 3.8.0) + rspec-support (~> 3.8.0) + rspec-support (3.8.0) rubocop (0.58.2) jaro_winkler (~> 1.5.1) parallel (~> 1.10) From 28db70401c62066f3aa76a0f3b8591bc8173a73a Mon Sep 17 00:00:00 2001 From: James G Smith Date: Fri, 7 Sep 2018 16:33:35 -0400 Subject: [PATCH 46/61] [LG-574] make phone_configuration plural (#2484) * [LG-574] make phone_configuration plural **Why**: We want to support multiple phone numbers. This is an intermediate step in that direction. **How**: We go through and look for all the places we call phone_configuration. We pluralize and try to see if we're asking a question about the collection or a particular one. --- .../concerns/account_recoverable.rb | 2 +- app/controllers/concerns/authorizable.rb | 2 +- .../concerns/phone_confirmation.rb | 3 +- .../concerns/two_factor_authenticatable.rb | 10 +++---- .../idv/confirmations_controller.rb | 4 ++- .../otp_verification_controller.rb | 4 +-- .../piv_cac_verification_controller.rb | 10 ++++--- .../users/phone_setup_controller.rb | 3 +- app/controllers/users/phones_controller.rb | 3 +- ...piv_cac_authentication_setup_controller.rb | 2 +- .../two_factor_authentication_controller.rb | 4 +-- app/decorators/user_decorator.rb | 2 +- app/forms/idv/phone_form.rb | 6 ++-- app/forms/two_factor_options_form.rb | 2 +- app/forms/user_phone_form.rb | 8 ++--- app/models/phone_configuration.rb | 2 +- app/models/user.rb | 4 +-- app/policies/sms_login_option_policy.rb | 2 +- app/policies/voice_login_option_policy.rb | 5 ++-- app/services/account_reset/cancel.rb | 2 +- app/services/account_reset/create_request.rb | 2 +- .../otp_delivery_preference_updater.rb | 2 +- app/services/pii/cacher.rb | 8 ++--- app/services/remember_device_cookie.rb | 4 ++- app/services/update_user.rb | 6 ++-- app/views/accounts/show.html.slim | 2 +- .../exception_notifier/_session.text.erb | 4 +-- config/environments/test.rb | 2 +- lib/tasks/create_test_accounts.rb | 2 +- lib/tasks/dev.rake | 14 +++++---- lib/tasks/rotate.rake | 3 +- .../idv/confirmations_controller_spec.rb | 2 +- spec/controllers/idv/phone_controller_spec.rb | 2 +- .../controllers/idv/review_controller_spec.rb | 2 +- .../otp_verification_controller_spec.rb | 11 ++++--- .../users/phones_controller_spec.rb | 14 ++++----- ...o_factor_authentication_controller_spec.rb | 8 ++--- spec/factories/phone_configurations.rb | 2 +- spec/factories/users.rb | 29 ++++++++++++------- spec/features/idv/steps/phone_step_spec.rb | 2 +- .../change_factor_spec.rb | 12 ++++---- .../two_factor_authentication/sign_in_spec.rb | 8 +++-- .../features/users/piv_cac_management_spec.rb | 5 ++-- spec/features/users/user_profile_spec.rb | 4 +++ .../visitors/phone_confirmation_spec.rb | 7 +++-- spec/forms/idv/phone_form_spec.rb | 2 +- spec/forms/user_phone_form_spec.rb | 9 +++--- spec/lib/tasks/rotate_rake_spec.rb | 10 +++---- spec/models/user_spec.rb | 2 +- spec/services/account_reset/cancel_spec.rb | 9 +++--- spec/services/otp_rate_limiter_spec.rb | 10 +++++-- spec/services/pii/cacher_spec.rb | 4 +-- spec/services/update_user_spec.rb | 6 ++-- spec/support/features/idv_step_helper.rb | 2 +- spec/support/features/session_helper.rb | 2 +- spec/support/shared_examples/sign_in.rb | 3 +- .../shared_examples_for_phone_validation.rb | 8 ++--- spec/support/sp_auth_helper.rb | 2 +- .../totp_verification/show.html.slim_spec.rb | 2 +- 59 files changed, 168 insertions(+), 140 deletions(-) diff --git a/app/controllers/concerns/account_recoverable.rb b/app/controllers/concerns/account_recoverable.rb index 492401c6c75..aabeb95017e 100644 --- a/app/controllers/concerns/account_recoverable.rb +++ b/app/controllers/concerns/account_recoverable.rb @@ -1,5 +1,5 @@ module AccountRecoverable def piv_cac_enabled_but_not_phone_enabled? - current_user.piv_cac_enabled? && !current_user.phone_configuration&.mfa_enabled? + current_user.piv_cac_enabled? && current_user.phone_configurations.none?(&:mfa_enabled?) end end diff --git a/app/controllers/concerns/authorizable.rb b/app/controllers/concerns/authorizable.rb index ced31305fc1..8a1cffe732b 100644 --- a/app/controllers/concerns/authorizable.rb +++ b/app/controllers/concerns/authorizable.rb @@ -1,6 +1,6 @@ module Authorizable def authorize_user - return unless current_user.phone_configuration&.mfa_enabled? + return unless current_user.phone_configurations.any?(&:mfa_enabled?) if user_fully_authenticated? redirect_to account_url diff --git a/app/controllers/concerns/phone_confirmation.rb b/app/controllers/concerns/phone_confirmation.rb index c79db11b066..907dd65e29e 100644 --- a/app/controllers/concerns/phone_confirmation.rb +++ b/app/controllers/concerns/phone_confirmation.rb @@ -15,6 +15,7 @@ def prompt_to_confirm_phone(phone:, selected_delivery_method: nil) def otp_delivery_method(phone, selected_delivery_method) return :sms if PhoneNumberCapabilities.new(phone).sms_only? return selected_delivery_method if selected_delivery_method.present? - current_user.phone_configuration&.delivery_preference || current_user.otp_delivery_preference + current_user.phone_configurations.first&.delivery_preference || + current_user.otp_delivery_preference end end diff --git a/app/controllers/concerns/two_factor_authenticatable.rb b/app/controllers/concerns/two_factor_authenticatable.rb index 8e235e6e5f3..d6014ac096b 100644 --- a/app/controllers/concerns/two_factor_authenticatable.rb +++ b/app/controllers/concerns/two_factor_authenticatable.rb @@ -140,7 +140,7 @@ def assign_phone end def old_phone - current_user.phone_configuration&.phone + current_user.phone_configurations.first&.phone end def phone_changed @@ -233,7 +233,7 @@ def authenticator_view_data two_factor_authentication_method: two_factor_authentication_method, user_email: current_user.email, remember_device_available: false, - phone_enabled: current_user.phone_configuration&.mfa_enabled?, + phone_enabled: current_user.phone_configurations.any?(&:mfa_enabled?), }.merge(generic_data) end @@ -255,7 +255,7 @@ def display_phone_to_deliver_to def voice_otp_delivery_unsupported? phone_number = if authentication_context? - current_user.phone_configuration&.phone + current_user.phone_configurations.first&.phone else user_session[:unconfirmed_phone] end @@ -268,7 +268,7 @@ def decorated_user def reenter_phone_number_path locale = LinkLocaleResolver.locale - if current_user.phone_configuration.present? + if current_user.phone_configurations.any? manage_phone_path(locale: locale) else phone_setup_path(locale: locale) @@ -276,7 +276,7 @@ def reenter_phone_number_path end def confirmation_for_phone_change? - confirmation_context? && current_user.phone_configuration.present? + confirmation_context? && current_user.phone_configurations.any? end def presenter_for_two_factor_authentication_method diff --git a/app/controllers/idv/confirmations_controller.rb b/app/controllers/idv/confirmations_controller.rb index 4bb1ce61c71..e5a210c8579 100644 --- a/app/controllers/idv/confirmations_controller.rb +++ b/app/controllers/idv/confirmations_controller.rb @@ -35,7 +35,9 @@ def confirm_profile_has_been_created def track_final_idv_event result = { success: true, - new_phone_added: idv_session.params['phone'] != current_user.phone_configuration&.phone, + new_phone_added: !current_user.phone_configurations.map(&:phone).include?( + idv_session.params['phone'] + ), } analytics.track_event(Analytics::IDV_FINAL, result) end diff --git a/app/controllers/two_factor_authentication/otp_verification_controller.rb b/app/controllers/two_factor_authentication/otp_verification_controller.rb index d319528176c..f955715df68 100644 --- a/app/controllers/two_factor_authentication/otp_verification_controller.rb +++ b/app/controllers/two_factor_authentication/otp_verification_controller.rb @@ -36,7 +36,7 @@ def confirm_two_factor_enabled end def phone_enabled? - current_user.phone_configuration&.mfa_enabled? + current_user.phone_configurations.any?(&:mfa_enabled?) end def confirm_voice_capability @@ -54,7 +54,7 @@ def confirm_voice_capability end def phone - current_user&.phone_configuration&.phone || user_session[:unconfirmed_phone] + current_user&.phone_configurations&.first&.phone || user_session[:unconfirmed_phone] end def form_params diff --git a/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb b/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb index 19380eee8e7..821c27be3e8 100644 --- a/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb +++ b/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb @@ -39,9 +39,11 @@ def handle_valid_piv_cac end def next_step - return account_recovery_setup_url unless current_user.phone_configuration&.mfa_enabled? - - after_otp_verification_confirmation_url + if current_user.phone_configurations.any?(&:mfa_enabled?) + after_otp_verification_confirmation_url + else + account_recovery_setup_url + end end def handle_invalid_piv_cac @@ -64,7 +66,7 @@ def piv_cac_view_data user_email: current_user.email, remember_device_available: false, totp_enabled: current_user.totp_enabled?, - phone_enabled: current_user.phone_configuration&.mfa_enabled?, + phone_enabled: current_user.phone_configurations.any?(&:mfa_enabled?), piv_cac_nonce: piv_cac_nonce, }.merge(generic_data) end diff --git a/app/controllers/users/phone_setup_controller.rb b/app/controllers/users/phone_setup_controller.rb index adbcf2af193..7f035e4d117 100644 --- a/app/controllers/users/phone_setup_controller.rb +++ b/app/controllers/users/phone_setup_controller.rb @@ -30,7 +30,8 @@ def create private def delivery_preference - current_user.phone_configuration&.delivery_preference || current_user.otp_delivery_preference + current_user.phone_configurations.first&.delivery_preference || + current_user.otp_delivery_preference end def two_factor_enabled? diff --git a/app/controllers/users/phones_controller.rb b/app/controllers/users/phones_controller.rb index 86f655b7aea..708af96a429 100644 --- a/app/controllers/users/phones_controller.rb +++ b/app/controllers/users/phones_controller.rb @@ -27,7 +27,8 @@ def user_params end def delivery_preference - current_user.phone_configuration&.delivery_preference || current_user.otp_delivery_preference + current_user.phone_configurations.first&.delivery_preference || + current_user.otp_delivery_preference end def process_updates diff --git a/app/controllers/users/piv_cac_authentication_setup_controller.rb b/app/controllers/users/piv_cac_authentication_setup_controller.rb index b7df4bbd715..3e10f52b4d3 100644 --- a/app/controllers/users/piv_cac_authentication_setup_controller.rb +++ b/app/controllers/users/piv_cac_authentication_setup_controller.rb @@ -79,7 +79,7 @@ def process_valid_submission end def next_step - return account_url if current_user.phone_configuration&.mfa_enabled? + return account_url if current_user.phone_configurations.any?(&:mfa_enabled?) account_recovery_setup_url end diff --git a/app/controllers/users/two_factor_authentication_controller.rb b/app/controllers/users/two_factor_authentication_controller.rb index 92337b8001b..d87c2a8c74b 100644 --- a/app/controllers/users/two_factor_authentication_controller.rb +++ b/app/controllers/users/two_factor_authentication_controller.rb @@ -41,7 +41,7 @@ def phone_enabled? end def phone_configuration - current_user.phone_configuration + current_user.phone_configurations.first end def validate_otp_delivery_preference_and_send_code @@ -87,7 +87,7 @@ def invalid_phone_number(exception, action:) def redirect_to_otp_verification_with_error flash[:error] = t('errors.messages.phone_unsupported') redirect_to login_two_factor_url( - otp_delivery_preference: current_user.phone_configuration.delivery_preference, + otp_delivery_preference: phone_configuration.delivery_preference, reauthn: reauthn? ) end diff --git a/app/decorators/user_decorator.rb b/app/decorators/user_decorator.rb index 919595205e3..4550daa3018 100644 --- a/app/decorators/user_decorator.rb +++ b/app/decorators/user_decorator.rb @@ -36,7 +36,7 @@ def confirmation_period end def masked_two_factor_phone_number - masked_number(user.phone_configuration&.phone) + masked_number(user.phone_configurations.first&.phone) end def active_identity_for(service_provider) diff --git a/app/forms/idv/phone_form.rb b/app/forms/idv/phone_form.rb index 30f4a1420ce..2bca7248048 100644 --- a/app/forms/idv/phone_form.rb +++ b/app/forms/idv/phone_form.rb @@ -10,7 +10,7 @@ class PhoneForm def initialize(idv_params, user) @idv_params = idv_params @user = user - self.phone = initial_phone_value(idv_params[:phone] || user.phone_configuration&.phone) + self.phone = initial_phone_value(idv_params[:phone] || user.phone_configurations.first&.phone) self.international_code = PhoneFormatter::DEFAULT_COUNTRY end @@ -45,11 +45,11 @@ def update_idv_params(phone) idv_params[:phone] = normalized_phone return idv_params[:phone_confirmed_at] = nil unless phone == formatted_user_phone - idv_params[:phone_confirmed_at] = user.phone_configuration&.confirmed_at + idv_params[:phone_confirmed_at] = user.phone_configurations.first&.confirmed_at end def formatted_user_phone - Phonelib.parse(user.phone_configuration&.phone).international + Phonelib.parse(user.phone_configurations.first&.phone).international end def parsed_phone diff --git a/app/forms/two_factor_options_form.rb b/app/forms/two_factor_options_form.rb index adb16f78cc1..c62048eef4e 100644 --- a/app/forms/two_factor_options_form.rb +++ b/app/forms/two_factor_options_form.rb @@ -36,7 +36,7 @@ def extra_analytics_attributes def user_needs_updating? return false unless %w[voice sms].include?(selection) - return false if selection == user.phone_configuration&.delivery_preference + return false if selection == user.phone_configurations.first&.delivery_preference selection != user.otp_delivery_preference end diff --git a/app/forms/user_phone_form.rb b/app/forms/user_phone_form.rb index 3acaa11a420..aaa26676a22 100644 --- a/app/forms/user_phone_form.rb +++ b/app/forms/user_phone_form.rb @@ -5,11 +5,11 @@ class UserPhoneForm validates :otp_delivery_preference, inclusion: { in: %w[voice sms] } - attr_accessor :phone, :international_code, :otp_delivery_preference + attr_accessor :phone, :international_code, :otp_delivery_preference, :phone_configuration def initialize(user) self.user = user - phone_configuration = user.phone_configuration + self.phone_configuration = user.phone_configurations.first if phone_configuration.nil? self.otp_delivery_preference = user.otp_delivery_preference else @@ -59,7 +59,7 @@ def ingest_submitted_params(params) end def otp_delivery_preference_changed? - otp_delivery_preference != user.phone_configuration&.delivery_preference + otp_delivery_preference != phone_configuration&.delivery_preference end def update_otp_delivery_preference_for_user @@ -68,6 +68,6 @@ def update_otp_delivery_preference_for_user end def formatted_user_phone - user.phone_configuration&.formatted_phone + phone_configuration&.formatted_phone end end diff --git a/app/models/phone_configuration.rb b/app/models/phone_configuration.rb index dfd8007a0cb..7e505ef8e96 100644 --- a/app/models/phone_configuration.rb +++ b/app/models/phone_configuration.rb @@ -1,7 +1,7 @@ class PhoneConfiguration < ApplicationRecord include EncryptableAttribute - belongs_to :user, inverse_of: :phone_configuration + belongs_to :user, inverse_of: :phone_configurations validates :user_id, presence: true validates :encrypted_phone, presence: true diff --git a/app/models/user.rb b/app/models/user.rb index 653fcf16aa5..bc59f4b9dc3 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -41,7 +41,7 @@ class User < ApplicationRecord has_many :profiles, dependent: :destroy has_many :events, dependent: :destroy has_one :account_reset_request, dependent: :destroy - has_one :phone_configuration, dependent: :destroy, inverse_of: :user + has_many :phone_configurations, dependent: :destroy, inverse_of: :user has_many :webauthn_configurations, dependent: :destroy validates :x509_dn_uuid, uniqueness: true, allow_nil: true @@ -69,7 +69,7 @@ def need_two_factor_authentication?(_request) end def two_factor_enabled? - phone_configuration&.mfa_enabled? || totp_enabled? || piv_cac_enabled? + phone_configurations.any?(&:mfa_enabled?) || totp_enabled? || piv_cac_enabled? end def send_two_factor_authentication_code(_code) diff --git a/app/policies/sms_login_option_policy.rb b/app/policies/sms_login_option_policy.rb index 45611708dbd..aee36acdf4c 100644 --- a/app/policies/sms_login_option_policy.rb +++ b/app/policies/sms_login_option_policy.rb @@ -5,7 +5,7 @@ def initialize(user) def configured? return false unless user - user.phone_configuration.present? + user.phone_configurations.any? end private diff --git a/app/policies/voice_login_option_policy.rb b/app/policies/voice_login_option_policy.rb index 55913a29704..24914c4c85b 100644 --- a/app/policies/voice_login_option_policy.rb +++ b/app/policies/voice_login_option_policy.rb @@ -12,7 +12,8 @@ def configured? attr_reader :user def user_has_a_phone_number_that_we_can_call? - phone = user.phone_configuration&.phone - phone.present? && !PhoneNumberCapabilities.new(phone).sms_only? + user.phone_configurations.any? do |phone_configuration| + !PhoneNumberCapabilities.new(phone_configuration.phone).sms_only? + end end end diff --git a/app/services/account_reset/cancel.rb b/app/services/account_reset/cancel.rb index 4171cd34e12..de85bbc54a9 100644 --- a/app/services/account_reset/cancel.rb +++ b/app/services/account_reset/cancel.rb @@ -54,7 +54,7 @@ def user end def phone - user.phone_configuration&.phone + user.phone_configurations.first&.phone end def extra_analytics_attributes diff --git a/app/services/account_reset/create_request.rb b/app/services/account_reset/create_request.rb index ac0ea23b662..71ced1cd6f6 100644 --- a/app/services/account_reset/create_request.rb +++ b/app/services/account_reset/create_request.rb @@ -30,7 +30,7 @@ def notify_user_by_email end def notify_user_by_sms_if_applicable - phone = user.phone_configuration&.phone + phone = user.phone_configurations.first&.phone return unless phone SmsAccountResetNotifierJob.perform_now( phone: phone, diff --git a/app/services/otp_delivery_preference_updater.rb b/app/services/otp_delivery_preference_updater.rb index c6c13cf23e9..67a2a79e0bf 100644 --- a/app/services/otp_delivery_preference_updater.rb +++ b/app/services/otp_delivery_preference_updater.rb @@ -21,7 +21,7 @@ def should_update_user? def otp_delivery_preference_changed? return true if preference != user.otp_delivery_preference - phone_configuration = user.phone_configuration + phone_configuration = user.phone_configurations.first phone_configuration.present? && preference != phone_configuration.delivery_preference end end diff --git a/app/services/pii/cacher.rb b/app/services/pii/cacher.rb index 629a74912c9..1c9c4d048f6 100644 --- a/app/services/pii/cacher.rb +++ b/app/services/pii/cacher.rb @@ -38,9 +38,9 @@ def rotate_fingerprints(profile) def rotate_encrypted_attributes KeyRotator::AttributeEncryption.new(user).rotate - phone_configuration = user.phone_configuration - return if phone_configuration.blank? - KeyRotator::AttributeEncryption.new(phone_configuration).rotate + user.phone_configurations.each do |phone_configuration| + KeyRotator::AttributeEncryption.new(phone_configuration).rotate + end end def stale_fingerprints?(profile) @@ -52,7 +52,7 @@ def stale_email_fingerprint? end def stale_attributes? - user.phone_configuration&.stale_encrypted_phone? || user.stale_encrypted_email? || + user.phone_configurations.any?(&:stale_encrypted_phone?) || user.stale_encrypted_email? || user.stale_encrypted_otp_secret_key? end diff --git a/app/services/remember_device_cookie.rb b/app/services/remember_device_cookie.rb index 28a916d39d3..ad1b6b5f663 100644 --- a/app/services/remember_device_cookie.rb +++ b/app/services/remember_device_cookie.rb @@ -46,6 +46,8 @@ def expired? end def user_has_changed_phone?(user) - user.phone_configuration&.confirmed_at.to_i > created_at.to_i + user.phone_configurations.any? do |phone_configuration| + phone_configuration.confirmed_at.to_i > created_at.to_i + end end end diff --git a/app/services/update_user.rb b/app/services/update_user.rb index 950de065250..ff67ac120f8 100644 --- a/app/services/update_user.rb +++ b/app/services/update_user.rb @@ -15,7 +15,7 @@ def call attr_reader :user, :attributes def manage_phone_configuration - if user.phone_configuration.present? + if user.phone_configurations.any? update_phone_configuration else create_phone_configuration @@ -23,12 +23,12 @@ def manage_phone_configuration end def update_phone_configuration - user.phone_configuration.update!(phone_attributes) + user.phone_configurations.first.update!(phone_attributes) end def create_phone_configuration return if phone_attributes[:phone].blank? - user.create_phone_configuration(phone_attributes) + user.phone_configurations.create(phone_attributes) end def phone_attributes diff --git a/app/views/accounts/show.html.slim b/app/views/accounts/show.html.slim index 7c04cd4f6a9..43d733a0b1e 100644 --- a/app/views/accounts/show.html.slim +++ b/app/views/accounts/show.html.slim @@ -36,7 +36,7 @@ h1.hide = t('titles.account') = render 'account_item', name: t('account.index.phone'), - content: current_user.phone_configuration&.phone, + content: current_user.phone_configurations.first&.phone, path: manage_phone_path, action: @view_model.edit_action_partial diff --git a/app/views/exception_notifier/_session.text.erb b/app/views/exception_notifier/_session.text.erb index 6b6db940d9b..6560cec71f6 100644 --- a/app/views/exception_notifier/_session.text.erb +++ b/app/views/exception_notifier/_session.text.erb @@ -18,8 +18,8 @@ Session: <%= session %> <% user = @kontroller.analytics_user || AnonymousUser.new %> User UUID: <%= user.uuid %> -<% if user.phone_configuration %> - User's Country (based on phone): <%= Phonelib.parse(user.phone_configuration.phone).country %> +<% if user.phone_configurations.any? %> + User's Country (based on first phone): <%= Phonelib.parse(user.phone_configurations.first.phone).country %> <% end %> Visitor ID: <%= @request.cookies['ahoy_visitor'] %> diff --git a/config/environments/test.rb b/config/environments/test.rb index 9d3896121e2..9f56c86ad2a 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -35,7 +35,7 @@ Bullet.bullet_logger = true Bullet.raise = true Bullet.add_whitelist( - type: :n_plus_one_query, class_name: 'User', association: :phone_configuration + type: :n_plus_one_query, class_name: 'User', association: :phone_configurations ) end diff --git a/lib/tasks/create_test_accounts.rb b/lib/tasks/create_test_accounts.rb index 3dd22c658e3..aa2ac0ae785 100644 --- a/lib/tasks/create_test_accounts.rb +++ b/lib/tasks/create_test_accounts.rb @@ -16,7 +16,7 @@ def create_account(email: 'joe.smith@email.com', password: 'salty pickles', mfa_ user.skip_confirmation! user.reset_password(password, password) user.save! - user.create_phone_configuration( + user.phone_configurations.create( phone: mfa_phone || phone, confirmed_at: Time.zone.now, delivery_preference: user.otp_delivery_preference diff --git a/lib/tasks/dev.rake b/lib/tasks/dev.rake index bff3595d76c..50b78ae7577 100644 --- a/lib/tasks/dev.rake +++ b/lib/tasks/dev.rake @@ -88,11 +88,7 @@ namespace :dev do user.encrypted_email = args[:ee].encrypted user.skip_confirmation! user.reset_password(args[:pw], args[:pw]) - user.create_phone_configuration( - delivery_preference: user.otp_delivery_preference, - phone: format('+1 (415) 555-%04d', args[:num]), - confirmed_at: Time.zone.now - ) + user.phone_configurations.create(phone_configuration_data(user, args)) Event.create(user_id: user.id, event_type: :account_created) end @@ -107,4 +103,12 @@ namespace :dev do def fingerprint(email) Pii::Fingerprinter.fingerprint(email) end + + def phone_configuration_data(user, args) + { + delivery_preference: user.otp_delivery_preference, + phone: format('+1 (415) 555-%04d', args[:num]), + confirmed_at: Time.zone.now, + } + end end diff --git a/lib/tasks/rotate.rake b/lib/tasks/rotate.rake index 7acb2eab9e6..f5beb664915 100644 --- a/lib/tasks/rotate.rake +++ b/lib/tasks/rotate.rake @@ -13,8 +13,7 @@ namespace :rotate do users.each do |user| rotator = KeyRotator::AttributeEncryption.new(user) rotator.rotate - phone_configuration = user.phone_configuration - if phone_configuration.present? + user.phone_configurations.each do |phone_configuration| rotator = KeyRotator::AttributeEncryption.new(phone_configuration) rotator.rotate end diff --git a/spec/controllers/idv/confirmations_controller_spec.rb b/spec/controllers/idv/confirmations_controller_spec.rb index d9c2dd4b10b..713e51d88f6 100644 --- a/spec/controllers/idv/confirmations_controller_spec.rb +++ b/spec/controllers/idv/confirmations_controller_spec.rb @@ -110,7 +110,7 @@ def index context 'user used 2FA phone as phone of record' do before do - subject.idv_session.params['phone'] = user.phone_configuration.phone + subject.idv_session.params['phone'] = user.phone_configurations.first.phone end it 'tracks final IdV event' do diff --git a/spec/controllers/idv/phone_controller_spec.rb b/spec/controllers/idv/phone_controller_spec.rb index 674c40d0ba0..ff2aac508a5 100644 --- a/spec/controllers/idv/phone_controller_spec.rb +++ b/spec/controllers/idv/phone_controller_spec.rb @@ -138,7 +138,7 @@ expected_params = { phone: normalized_phone, - phone_confirmed_at: user.phone_configuration.confirmed_at, + phone_confirmed_at: user.phone_configurations.first.confirmed_at, } expect(subject.idv_session.params).to eq expected_params end diff --git a/spec/controllers/idv/review_controller_spec.rb b/spec/controllers/idv/review_controller_spec.rb index 73d47e96423..b981a6d942f 100644 --- a/spec/controllers/idv/review_controller_spec.rb +++ b/spec/controllers/idv/review_controller_spec.rb @@ -20,7 +20,7 @@ city: 'Somewhere', state: 'KS', zipcode: zipcode, - phone: user.phone_configuration&.phone, + phone: user.phone_configurations.first&.phone, ssn: '12345678', } end diff --git a/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb b/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb index d6f131a0f1d..da6606f3cb7 100644 --- a/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb +++ b/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb @@ -264,7 +264,7 @@ sign_in_as_user subject.user_session[:unconfirmed_phone] = '+1 (703) 555-5555' subject.user_session[:context] = 'confirmation' - @previous_phone_confirmed_at = subject.current_user.phone_configuration&.confirmed_at + @previous_phone_confirmed_at = subject.current_user.phone_configurations.first&.confirmed_at subject.current_user.create_direct_otp stub_analytics allow(@analytics).to receive(:track_event) @@ -272,7 +272,7 @@ @mailer = instance_double(ActionMailer::MessageDelivery, deliver_later: true) allow(UserMailer).to receive(:phone_changed).with(subject.current_user). and_return(@mailer) - @previous_phone = subject.current_user.phone_configuration&.phone + @previous_phone = subject.current_user.phone_configurations.first&.phone end context 'user has an existing phone number' do @@ -322,9 +322,9 @@ end it 'does not update user phone or phone_confirmed_at attributes' do - expect(subject.current_user.phone_configuration.phone).to eq('+1 202-555-1212') + expect(subject.current_user.phone_configurations.first.phone).to eq('+1 202-555-1212') expect( - subject.current_user.phone_configuration.confirmed_at + subject.current_user.phone_configurations.first.confirmed_at ).to eq(@previous_phone_confirmed_at) end @@ -353,8 +353,7 @@ context 'when user does not have an existing phone number' do before do - subject.current_user.phone_configuration.destroy - subject.current_user.phone_configuration = nil + subject.current_user.phone_configurations.clear subject.current_user.create_direct_otp end diff --git a/spec/controllers/users/phones_controller_spec.rb b/spec/controllers/users/phones_controller_spec.rb index de01fb134b4..fbb5b11a0c9 100644 --- a/spec/controllers/users/phones_controller_spec.rb +++ b/spec/controllers/users/phones_controller_spec.rb @@ -25,7 +25,7 @@ it 'lets user know they need to confirm their new phone' do expect(flash[:notice]).to eq t('devise.registrations.phone_update_needs_confirmation') - expect(user.reload.phone_configuration.phone).to_not eq '+1 202-555-4321' + expect(user.phone_configurations.reload.first.phone).to_not eq '+1 202-555-4321' expect(@analytics).to have_received(:track_event). with(Analytics::PHONE_CHANGE_REQUESTED) expect(response).to redirect_to( @@ -47,7 +47,7 @@ otp_delivery_preference: 'sms' }, } - expect(user.reload.phone_configuration.phone).to be_present + expect(user.phone_configurations.reload.first).to be_present expect(response).to render_template(:edit) end end @@ -60,7 +60,7 @@ allow(@analytics).to receive(:track_event) put :update, params: { - user_phone_form: { phone: second_user.phone_configuration.phone, + user_phone_form: { phone: second_user.phone_configurations.first.phone, international_code: 'US', otp_delivery_preference: 'sms' }, } @@ -68,8 +68,8 @@ it 'processes successfully and informs user' do expect(flash[:notice]).to eq t('devise.registrations.phone_update_needs_confirmation') - expect(user.phone_configuration.reload.phone).to_not eq( - second_user.phone_configuration.phone + expect(user.phone_configurations.reload.first.phone).to_not eq( + second_user.phone_configurations.first.phone ) expect(@analytics).to have_received(:track_event). with(Analytics::PHONE_CHANGE_REQUESTED) @@ -94,7 +94,7 @@ otp_delivery_preference: 'sms' }, } - expect(user.phone_configuration.phone).not_to eq invalid_phone + expect(user.phone_configurations.first.phone).not_to eq invalid_phone expect(response).to render_template(:edit) end end @@ -104,7 +104,7 @@ stub_sign_in(user) put :update, params: { - user_phone_form: { phone: user.phone_configuration.phone, + user_phone_form: { phone: user.phone_configurations.first.phone, international_code: 'US', otp_delivery_preference: 'sms' }, } diff --git a/spec/controllers/users/two_factor_authentication_controller_spec.rb b/spec/controllers/users/two_factor_authentication_controller_spec.rb index 3fcb40b8a8b..2287da46a7f 100644 --- a/spec/controllers/users/two_factor_authentication_controller_spec.rb +++ b/spec/controllers/users/two_factor_authentication_controller_spec.rb @@ -134,7 +134,7 @@ def index expect(SmsOtpSenderJob).to have_received(:perform_later).with( code: subject.current_user.direct_otp, - phone: subject.current_user.phone_configuration.phone, + phone: subject.current_user.phone_configurations.first.phone, otp_created_at: subject.current_user.direct_otp_sent_at.to_s, message: 'jobs.sms_otp_sender_job.login_message', locale: nil @@ -151,7 +151,7 @@ def index expect(SmsOtpSenderJob).to have_received(:perform_later).with( code: subject.current_user.direct_otp, - phone: subject.current_user.phone_configuration.phone, + phone: subject.current_user.phone_configurations.first.phone, otp_created_at: subject.current_user.direct_otp_sent_at.to_s, message: 'jobs.sms_otp_sender_job.login_message', locale: nil @@ -180,7 +180,7 @@ def index it 'calls OtpRateLimiter#exceeded_otp_send_limit? and #increment' do otp_rate_limiter = instance_double(OtpRateLimiter) allow(OtpRateLimiter).to receive(:new).with( - phone: @user.phone_configuration.phone, + phone: @user.phone_configurations.first.phone, user: @user ).and_return(otp_rate_limiter) @@ -218,7 +218,7 @@ def index expect(VoiceOtpSenderJob).to have_received(:perform_later).with( code: subject.current_user.direct_otp, - phone: subject.current_user.phone_configuration.phone, + phone: subject.current_user.phone_configurations.first.phone, otp_created_at: subject.current_user.direct_otp_sent_at.to_s, locale: nil ) diff --git a/spec/factories/phone_configurations.rb b/spec/factories/phone_configurations.rb index cca856f2000..804dcb09241 100644 --- a/spec/factories/phone_configurations.rb +++ b/spec/factories/phone_configurations.rb @@ -5,6 +5,6 @@ confirmed_at { Time.zone.now } phone { '+1 202-555-1212' } mfa_enabled { true } - association :user + user { association :user } end end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 783fa67ed25..9a0825085bc 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -12,18 +12,27 @@ trait :with_phone do after(:build) do |user, evaluator| - if user.phone_configuration.nil? - user.phone_configuration = build( - :phone_configuration, - { user: user, delivery_preference: user.otp_delivery_preference }.merge( - evaluator.with.slice(:phone, :confirmed_at, :delivery_preference, :mfa_enabled) + if user.phone_configurations.empty? + user.save! + if user.id.present? + create(:phone_configuration, + { user: user, delivery_preference: user.otp_delivery_preference }.merge( + evaluator.with.slice(:phone, :confirmed_at, :delivery_preference, :mfa_enabled) + )) + user.reload + else + user.phone_configurations << build( + :phone_configuration, + { delivery_preference: user.otp_delivery_preference }.merge( + evaluator.with.slice(:phone, :confirmed_at, :delivery_preference, :mfa_enabled) + ) ) - ) + end end end after(:create) do |user, evaluator| - if user.phone_configuration.nil? + if user.phone_configurations.empty? create(:phone_configuration, { user: user, delivery_preference: user.otp_delivery_preference }.merge( evaluator.with.slice(:phone, :confirmed_at, :delivery_preference, :mfa_enabled) @@ -33,10 +42,10 @@ end after(:stub) do |user, evaluator| - if user.phone_configuration.nil? - user.phone_configuration = build_stubbed( + if user.phone_configurations.empty? + user.phone_configurations << build( :phone_configuration, - { user: user, delivery_preference: user.otp_delivery_preference }.merge( + { delivery_preference: user.otp_delivery_preference }.merge( evaluator.with.slice(:phone, :confirmed_at, :delivery_preference, :mfa_enabled) ) ) diff --git a/spec/features/idv/steps/phone_step_spec.rb b/spec/features/idv/steps/phone_step_spec.rb index 9a204e460f5..7a3a0bbb20b 100644 --- a/spec/features/idv/steps/phone_step_spec.rb +++ b/spec/features/idv/steps/phone_step_spec.rb @@ -19,7 +19,7 @@ user = user_with_2fa start_idv_from_sp complete_idv_steps_before_phone_step(user) - fill_out_phone_form_ok(user.phone_configuration.phone) + fill_out_phone_form_ok(user.phone_configurations.first.phone) click_idv_continue expect(page).to have_content(t('idv.titles.session.review')) diff --git a/spec/features/two_factor_authentication/change_factor_spec.rb b/spec/features/two_factor_authentication/change_factor_spec.rb index 1de6a318652..3d2cf3aa677 100644 --- a/spec/features/two_factor_authentication/change_factor_spec.rb +++ b/spec/features/two_factor_authentication/change_factor_spec.rb @@ -20,7 +20,7 @@ mailer = instance_double(ActionMailer::MessageDelivery, deliver_later: true) allow(UserMailer).to receive(:phone_changed).with(user).and_return(mailer) - @previous_phone_confirmed_at = user.phone_configuration.reload.confirmed_at + @previous_phone_confirmed_at = user.phone_configurations.reload.first.confirmed_at new_phone = '+1 703-555-0100' visit manage_phone_path @@ -39,7 +39,7 @@ enter_incorrect_otp_code expect(page).to have_content t('devise.two_factor_authentication.invalid_otp') - expect(user.phone_configuration.reload.phone).to_not eq new_phone + expect(user.phone_configurations.reload.first.phone).to_not eq new_phone expect(page).to have_link t('forms.two_factor.try_again'), href: manage_phone_path submit_correct_otp @@ -49,7 +49,7 @@ expect(mailer).to have_received(:deliver_later) expect(page).to have_content new_phone expect( - user.phone_configuration.reload.confirmed_at + user.phone_configurations.reload.first.confirmed_at ).to_not eq(@previous_phone_confirmed_at) visit login_two_factor_path(otp_delivery_preference: 'sms') @@ -58,7 +58,7 @@ scenario 'editing phone number with no voice otp support only allows sms delivery' do user.update(otp_delivery_preference: 'voice') - user.phone_configuration.update(delivery_preference: 'voice') + user.phone_configurations.first.update(delivery_preference: 'voice') unsupported_phone = '242-327-0143' visit manage_phone_path @@ -81,7 +81,7 @@ allow(SmsOtpSenderJob).to receive(:perform_later) user = sign_in_and_2fa_user - old_phone = user.phone_configuration.phone + old_phone = user.phone_configurations.first.phone visit manage_phone_path update_phone_number @@ -108,7 +108,7 @@ allow(SmsOtpSenderJob).to receive(:perform_later) user = sign_in_and_2fa_user - old_phone = user.phone_configuration.phone + old_phone = user.phone_configurations.first.phone Timecop.travel(Figaro.env.reauthn_window.to_i + 1) do visit manage_phone_path diff --git a/spec/features/two_factor_authentication/sign_in_spec.rb b/spec/features/two_factor_authentication/sign_in_spec.rb index 885a4ed263e..31ce0e50b10 100644 --- a/spec/features/two_factor_authentication/sign_in_spec.rb +++ b/spec/features/two_factor_authentication/sign_in_spec.rb @@ -34,7 +34,7 @@ expect(page).to_not have_content invalid_phone_message expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'sms') - expect(user.reload.phone_configuration).to be_nil + expect(user.phone_configurations).to be_empty expect(user.sms?).to eq true end @@ -349,7 +349,7 @@ def submit_prefilled_otp_code expect(current_path).to eq account_path - phone_fingerprint = Pii::Fingerprinter.fingerprint(user.phone_configuration.phone) + phone_fingerprint = Pii::Fingerprinter.fingerprint(user.phone_configurations.first.phone) rate_limited_phone = OtpRequestsTracker.find_by(phone_fingerprint: phone_fingerprint) # let findtime period expire @@ -386,7 +386,9 @@ def submit_prefilled_otp_code sign_in_before_2fa(second_user) click_link t('links.two_factor_authentication.get_another_code') - phone_fingerprint = Pii::Fingerprinter.fingerprint(first_user.phone_configuration.phone) + phone_fingerprint = Pii::Fingerprinter.fingerprint( + first_user.phone_configurations.first.phone + ) rate_limited_phone = OtpRequestsTracker.find_by(phone_fingerprint: phone_fingerprint) expect(current_path).to eq otp_send_path diff --git a/spec/features/users/piv_cac_management_spec.rb b/spec/features/users/piv_cac_management_spec.rb index 941add76a96..ceec1f6ca59 100644 --- a/spec/features/users/piv_cac_management_spec.rb +++ b/spec/features/users/piv_cac_management_spec.rb @@ -107,9 +107,8 @@ def find_form(page, attributes) stub_piv_cac_service user.update(otp_secret_key: 'secret') - user.phone_configuration.destroy - user.reload - expect(user.phone_configuration).to be_nil + user.phone_configurations.clear + expect(user.phone_configurations).to be_empty sign_in_and_2fa_user(user) visit account_path click_link t('forms.buttons.enable'), href: setup_piv_cac_url diff --git a/spec/features/users/user_profile_spec.rb b/spec/features/users/user_profile_spec.rb index 53819705d0d..c3288adbaab 100644 --- a/spec/features/users/user_profile_spec.rb +++ b/spec/features/users/user_profile_spec.rb @@ -65,13 +65,17 @@ end it 'allows credentials to be reused for sign up' do + expect(User.count).to eq 0 pii = { ssn: '1234', dob: '1920-01-01' } profile = create(:profile, :active, :verified, pii: pii) + expect(User.count).to eq 1 sign_in_live_with_2fa(profile.user) visit account_path click_link(t('account.links.delete_account')) click_button t('users.delete.actions.delete') + expect(User.count).to eq 0 + profile = create(:profile, :active, :verified, pii: pii) sign_in_live_with_2fa(profile.user) expect(User.count).to eq 1 diff --git a/spec/features/visitors/phone_confirmation_spec.rb b/spec/features/visitors/phone_confirmation_spec.rb index f47f577aa3c..4915e1a9462 100644 --- a/spec/features/visitors/phone_confirmation_spec.rb +++ b/spec/features/visitors/phone_confirmation_spec.rb @@ -22,7 +22,7 @@ it 'updates phone_confirmed_at and redirects to acknowledge personal key' do click_button t('forms.buttons.submit.default') - expect(@user.reload.phone_configuration.confirmed_at).to be_present + expect(@user.phone_configurations.reload.first.confirmed_at).to be_present expect(current_path).to eq sign_up_personal_key_path click_acknowledge_personal_key @@ -60,7 +60,8 @@ @existing_user = create(:user, :signed_up) @user = sign_in_before_2fa select_2fa_option('sms') - fill_in 'user_phone_form_phone', with: @existing_user.phone_configuration.phone + fill_in 'user_phone_form_phone', + with: @existing_user.phone_configurations.detect(&:mfa_enabled?).phone click_send_security_code end @@ -75,7 +76,7 @@ fill_in 'code', with: 'foobar' click_submit_default - expect(@user.reload.phone_configuration).to be_nil + expect(@user.phone_configurations.reload).to be_empty expect(page).to have_content t('devise.two_factor_authentication.invalid_otp') expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'sms') end diff --git a/spec/forms/idv/phone_form_spec.rb b/spec/forms/idv/phone_form_spec.rb index 27997f499f1..22667076b30 100644 --- a/spec/forms/idv/phone_form_spec.rb +++ b/spec/forms/idv/phone_form_spec.rb @@ -45,7 +45,7 @@ expected_params = { phone: '2025551212', - phone_confirmed_at: user.phone_configuration.confirmed_at, + phone_confirmed_at: user.phone_configurations.first.confirmed_at, } expect(subject.idv_params).to eq expected_params diff --git a/spec/forms/user_phone_form_spec.rb b/spec/forms/user_phone_form_spec.rb index e3cbbebae82..5badd5ac3e8 100644 --- a/spec/forms/user_phone_form_spec.rb +++ b/spec/forms/user_phone_form_spec.rb @@ -23,7 +23,7 @@ ) subject = UserPhoneForm.new(user) - expect(subject.phone).to eq(user.phone_configuration.phone) + expect(subject.phone).to eq(user.phone_configurations.first.phone) expect(subject.international_code).to eq('US') expect(subject.otp_delivery_preference).to eq(user.otp_delivery_preference) end @@ -78,7 +78,7 @@ subject.submit(params) user.reload - expect(user.phone_configuration).to be_nil + expect(user.phone_configurations).to be_empty end it 'preserves the format of the submitted phone number if phone is invalid' do @@ -211,7 +211,7 @@ end it 'returns false if the user phone has not changed' do - params[:phone] = user.phone_configuration.phone + params[:phone] = user.phone_configurations.first.phone subject.submit(params) expect(subject.phone_changed?).to eq(false) @@ -219,8 +219,7 @@ context 'when a user has no phone' do it 'returns true' do - user.phone_configuration.destroy - user.reload + user.phone_configurations.clear params[:phone] = '+1 504 444 1643' subject.submit(params) diff --git a/spec/lib/tasks/rotate_rake_spec.rb b/spec/lib/tasks/rotate_rake_spec.rb index 99e74498dd1..0f757beb498 100644 --- a/spec/lib/tasks/rotate_rake_spec.rb +++ b/spec/lib/tasks/rotate_rake_spec.rb @@ -15,20 +15,20 @@ describe 'attribute_encryption_key' do it 'runs successfully' do old_email = user.email - old_phone = user.phone_configuration.phone + old_phone = user.phone_configurations.first.phone old_encrypted_email = user.encrypted_email - old_encrypted_phone = user.phone_configuration.encrypted_phone + old_encrypted_phone = user.phone_configurations.first.encrypted_phone rotate_attribute_encryption_key Rake::Task['rotate:attribute_encryption_key'].execute user.reload - user.phone_configuration.reload - expect(user.phone_configuration.phone).to eq old_phone + user.phone_configurations.reload + expect(user.phone_configurations.first.phone).to eq old_phone expect(user.email).to eq old_email expect(user.encrypted_email).to_not eq old_encrypted_email - expect(user.phone_configuration.encrypted_phone).to_not eq old_encrypted_phone + expect(user.phone_configurations.first.encrypted_phone).to_not eq old_encrypted_phone end it 'does not raise an exception when encrypting/decrypting a user' do diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index c767d7fea98..0a31acfe469 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -11,7 +11,7 @@ it { is_expected.to have_many(:profiles) } it { is_expected.to have_many(:events) } it { is_expected.to have_one(:account_reset_request) } - it { is_expected.to have_one(:phone_configuration) } + it { is_expected.to have_many(:phone_configurations) } it { is_expected.to have_many(:webauthn_configurations) } end diff --git a/spec/services/account_reset/cancel_spec.rb b/spec/services/account_reset/cancel_spec.rb index 5fe0e876281..85bd56db94e 100644 --- a/spec/services/account_reset/cancel_spec.rb +++ b/spec/services/account_reset/cancel_spec.rb @@ -22,7 +22,7 @@ context 'when the token is valid' do context 'when the user has a phone enabled for SMS' do before(:each) do - user.phone_configuration.update!(delivery_preference: :sms) + user.phone_configurations.first.update!(delivery_preference: :sms) end it 'notifies the user via SMS of the account reset cancellation' do @@ -32,7 +32,7 @@ AccountReset::Cancel.new(token).call expect(SmsAccountResetCancellationNotifierJob). - to have_received(:perform_now).with(phone: user.phone_configuration.phone) + to have_received(:perform_now).with(phone: user.phone_configurations.first.phone) end end @@ -40,8 +40,7 @@ it 'does not notify the user via SMS' do token = create_account_reset_request_for(user) allow(SmsAccountResetCancellationNotifierJob).to receive(:perform_now) - user.phone_configuration.destroy! - user.reload + user.phone_configurations.clear AccountReset::Cancel.new(token).call @@ -87,7 +86,7 @@ context 'when the user does not have a phone enabled for SMS' do it 'does not notify the user via SMS' do allow(SmsAccountResetCancellationNotifierJob).to receive(:perform_now) - user.phone_configuration.update!(mfa_enabled: false) + user.phone_configurations.first.update!(mfa_enabled: false) AccountReset::Cancel.new('foo').call diff --git a/spec/services/otp_rate_limiter_spec.rb b/spec/services/otp_rate_limiter_spec.rb index 0a3c37268e4..51ce271c314 100644 --- a/spec/services/otp_rate_limiter_spec.rb +++ b/spec/services/otp_rate_limiter_spec.rb @@ -3,10 +3,12 @@ RSpec.describe OtpRateLimiter do let(:current_user) { build(:user, :with_phone) } subject(:otp_rate_limiter) do - OtpRateLimiter.new(phone: current_user.phone_configuration.phone, user: current_user) + OtpRateLimiter.new(phone: current_user.phone_configurations.first.phone, user: current_user) end - let(:phone_fingerprint) { Pii::Fingerprinter.fingerprint(current_user.phone_configuration.phone) } + let(:phone_fingerprint) do + Pii::Fingerprinter.fingerprint(current_user.phone_configurations.first.phone) + end let(:rate_limited_phone) { OtpRequestsTracker.find_by(phone_fingerprint: phone_fingerprint) } describe '#exceeded_otp_send_limit?' do @@ -27,7 +29,9 @@ describe '#increment' do it 'updates otp_last_sent_at' do - tracker = OtpRequestsTracker.find_or_create_with_phone(current_user.phone_configuration.phone) + tracker = OtpRequestsTracker.find_or_create_with_phone( + current_user.phone_configurations.first.phone + ) old_otp_last_sent_at = tracker.reload.otp_last_sent_at otp_rate_limiter.increment new_otp_last_sent_at = tracker.reload.otp_last_sent_at diff --git a/spec/services/pii/cacher_spec.rb b/spec/services/pii/cacher_spec.rb index 22a64d1fa7f..ec31714a24c 100644 --- a/spec/services/pii/cacher_spec.rb +++ b/spec/services/pii/cacher_spec.rb @@ -38,7 +38,7 @@ old_ssn_signature = profile.ssn_signature old_email_fingerprint = user.email_fingerprint old_encrypted_email = user.encrypted_email - old_encrypted_phone = user.phone_configuration.encrypted_phone + old_encrypted_phone = user.phone_configurations.first.encrypted_phone old_encrypted_otp_secret_key = user.encrypted_otp_secret_key rotate_all_keys @@ -55,7 +55,7 @@ expect(user.email_fingerprint).to_not eq old_email_fingerprint expect(user.encrypted_email).to_not eq old_encrypted_email expect(profile.ssn_signature).to_not eq old_ssn_signature - expect(user.phone_configuration.encrypted_phone).to_not eq old_encrypted_phone + expect(user.phone_configurations.first.encrypted_phone).to_not eq old_encrypted_phone expect(user.encrypted_otp_secret_key).to_not eq old_encrypted_otp_secret_key end diff --git a/spec/services/update_user_spec.rb b/spec/services/update_user_spec.rb index 9a480bfb358..cd55785b766 100644 --- a/spec/services/update_user_spec.rb +++ b/spec/services/update_user_spec.rb @@ -25,7 +25,7 @@ } updater = UpdateUser.new(user: user, attributes: attributes) updater.call - phone_configuration = user.reload.phone_configuration + phone_configuration = user.phone_configurations.reload.first expect(phone_configuration.delivery_preference).to eq 'voice' expect(phone_configuration.confirmed_at).to eq confirmed_at expect(phone_configuration.phone).to eq '+1 222 333-4444' @@ -35,7 +35,7 @@ attributes = { phone: nil } updater = UpdateUser.new(user: user, attributes: attributes) updater.call - expect(user.reload.phone_configuration).to_not be_nil + expect(user.phone_configurations.reload).to_not be_empty end end @@ -50,7 +50,7 @@ } updater = UpdateUser.new(user: user, attributes: attributes) updater.call - phone_configuration = user.reload.phone_configuration + phone_configuration = user.phone_configurations.reload.first expect(phone_configuration.delivery_preference).to eq 'voice' expect(phone_configuration.confirmed_at).to eq confirmed_at expect(phone_configuration.phone).to eq '+1 222 333-4444' diff --git a/spec/support/features/idv_step_helper.rb b/spec/support/features/idv_step_helper.rb index be7ad433e6d..879fdd3b932 100644 --- a/spec/support/features/idv_step_helper.rb +++ b/spec/support/features/idv_step_helper.rb @@ -63,7 +63,7 @@ def complete_idv_steps_before_phone_otp_verification_step(user = user_with_2fa) def complete_idv_steps_with_phone_before_review_step(user = user_with_2fa) complete_idv_steps_before_phone_step(user) - fill_out_phone_form_ok(user.phone_configuration.phone) + fill_out_phone_form_ok(user.phone_configurations.first.phone) click_idv_continue end diff --git a/spec/support/features/session_helper.rb b/spec/support/features/session_helper.rb index 03074679eec..fb84f47b6d0 100644 --- a/spec/support/features/session_helper.rb +++ b/spec/support/features/session_helper.rb @@ -81,7 +81,7 @@ def sign_in_before_2fa(user = create(:user)) allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) login_as(user, scope: :user, run_callbacks: false) - if user.phone_configuration.present? + if user.phone_configurations.any? Warden.on_next_request do |proxy| session = proxy.env['rack.session'] session['warden.user.user.session'] = {} diff --git a/spec/support/shared_examples/sign_in.rb b/spec/support/shared_examples/sign_in.rb index 751a42b03bf..564464611c5 100644 --- a/spec/support/shared_examples/sign_in.rb +++ b/spec/support/shared_examples/sign_in.rb @@ -175,8 +175,7 @@ stub_piv_cac_service user = create(:user, :signed_up, :with_piv_or_cac) - user.phone_configuration.destroy - user.reload + user.phone_configurations.clear visit_idp_from_sp_with_loa1(sp) click_link t('links.sign_in') fill_in_credentials_and_submit(user.email, user.password) diff --git a/spec/support/shared_examples_for_phone_validation.rb b/spec/support/shared_examples_for_phone_validation.rb index c876e6d1c91..2d8e33503b8 100644 --- a/spec/support/shared_examples_for_phone_validation.rb +++ b/spec/support/shared_examples_for_phone_validation.rb @@ -16,10 +16,10 @@ second_user = build_stubbed(:user, :signed_up, with: { phone: '+1 (202) 555-1213' }) allow(User).to receive(:exists?).with(email: 'new@gmail.com').and_return(false) allow(User).to receive(:exists?).with( - phone_configuration: { phone: second_user.phone_configuration.phone } + phone_configuration: { phone: second_user.phone_configurations.first.phone } ).and_return(true) - params[:phone] = second_user.phone_configuration.phone + params[:phone] = second_user.phone_configurations.first.phone result = subject.submit(params) expect(result).to be_kind_of(FormResponse) @@ -37,8 +37,8 @@ context 'when phone is same as current user' do it 'is valid' do - user.phone_configuration.phone = '+1 (703) 500-5000' - params[:phone] = user.phone_configuration.phone + user.phone_configurations.first.phone = '+1 (703) 500-5000' + params[:phone] = user.phone_configurations.first.phone result = subject.submit(params) expect(result).to be_kind_of(FormResponse) diff --git a/spec/support/sp_auth_helper.rb b/spec/support/sp_auth_helper.rb index ed88e19f435..3036c9b524e 100644 --- a/spec/support/sp_auth_helper.rb +++ b/spec/support/sp_auth_helper.rb @@ -27,7 +27,7 @@ def create_loa3_account_go_back_to_sp_and_sign_out(sp) fill_out_idv_form_ok click_idv_continue click_idv_continue - fill_out_phone_form_ok(user.phone_configuration.phone) + fill_out_phone_form_ok(user.phone_configurations.detect(&:mfa_enabled?).phone) click_idv_continue fill_in :user_password, with: user.password click_continue diff --git a/spec/views/two_factor_authentication/totp_verification/show.html.slim_spec.rb b/spec/views/two_factor_authentication/totp_verification/show.html.slim_spec.rb index 04d24969a7d..18312877504 100644 --- a/spec/views/two_factor_authentication/totp_verification/show.html.slim_spec.rb +++ b/spec/views/two_factor_authentication/totp_verification/show.html.slim_spec.rb @@ -6,7 +6,7 @@ attributes_for(:generic_otp_presenter).merge( two_factor_authentication_method: 'authenticator', user_email: view.current_user.email, - phone_enabled: user.phone_configuration&.mfa_enabled? + phone_enabled: user.phone_configurations.any?(&:mfa_enabled?) ) end From 910f2a1db149377c55bddcb1c1d4171840e5ebe1 Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Thu, 6 Sep 2018 23:38:38 -0400 Subject: [PATCH 47/61] LG-598 LG-600 List/delete webauthn configurations for a user **Why**: So we can support strong authentication via hardware security keys as a new 2FA option **How**: Update the accounts page to list a user's webauthn configurations. Add new styling. --- app/assets/stylesheets/components/_btn.scss | 16 ++++++ .../users/webauthn_setup_controller.rb | 7 +++ app/models/webauthn_configuration.rb | 2 +- app/services/analytics.rb | 1 + app/view_models/account_show.rb | 4 -- app/views/accounts/_webauthn.html.slim | 16 ++++++ .../accounts/actions/_add_webauthn.html.slim | 3 -- app/views/accounts/show.html.slim | 5 +- config/locales/account/en.yml | 2 + config/locales/account/es.yml | 2 + config/locales/account/fr.yml | 2 + config/locales/notices/en.yml | 1 + config/locales/notices/es.yml | 1 + config/locales/notices/fr.yml | 1 + config/routes.rb | 1 + .../users/webauthn_setup_controller_spec.rb | 26 ++++++++++ .../users/webauthn_management_spec.rb | 51 +++++++++++++++++-- 17 files changed, 124 insertions(+), 17 deletions(-) create mode 100644 app/views/accounts/_webauthn.html.slim delete mode 100644 app/views/accounts/actions/_add_webauthn.html.slim diff --git a/app/assets/stylesheets/components/_btn.scss b/app/assets/stylesheets/components/_btn.scss index a08edb375bf..e84a3215c71 100644 --- a/app/assets/stylesheets/components/_btn.scss +++ b/app/assets/stylesheets/components/_btn.scss @@ -75,3 +75,19 @@ border-color: $gray; color: $gray; } + +.btn-account-action { + border: 0; + color: $blue; + font-size: .8125rem; + font-weight: normal; + margin-bottom: -3px; + margin-top: -3px; + padding: .5rem; + padding-bottom: .125rem; + padding-top: .125rem; + + a { + text-decoration: none; + } +} diff --git a/app/controllers/users/webauthn_setup_controller.rb b/app/controllers/users/webauthn_setup_controller.rb index 08db60a38a0..d433ad64df1 100644 --- a/app/controllers/users/webauthn_setup_controller.rb +++ b/app/controllers/users/webauthn_setup_controller.rb @@ -19,6 +19,13 @@ def confirm end end + def delete + analytics.track_event(Analytics::WEBAUTHN_DELETED) + WebauthnConfiguration.where(user_id: current_user.id, id: params[:id]).destroy_all + flash[:success] = t('notices.webauthn_deleted') + redirect_to account_url + end + private def save_challenge_in_session diff --git a/app/models/webauthn_configuration.rb b/app/models/webauthn_configuration.rb index 8b69a5de172..7f99ec0bc7d 100644 --- a/app/models/webauthn_configuration.rb +++ b/app/models/webauthn_configuration.rb @@ -1,5 +1,5 @@ class WebauthnConfiguration < ApplicationRecord - belongs_to :user, inverse_of: :webauthn_configuration + belongs_to :user validates :user_id, presence: true validates :name, presence: true validates :credential_id, presence: true diff --git a/app/services/analytics.rb b/app/services/analytics.rb index fa41fdd51b4..7bd725a87bb 100644 --- a/app/services/analytics.rb +++ b/app/services/analytics.rb @@ -134,6 +134,7 @@ def browser USER_REGISTRATION_PIV_CAC_DISABLED = 'User Registration: piv cac disabled'.freeze USER_REGISTRATION_PIV_CAC_ENABLED = 'User Registration: piv cac enabled'.freeze USER_REGISTRATION_PIV_CAC_SETUP_VISIT = 'User Registration: piv cac setup visited'.freeze + WEBAUTHN_DELETED = 'WebAuthn Deleted'.freeze WEBAUTHN_SETUP_VISIT = 'WebAuthn Setup Visited'.freeze WEBAUTHN_SETUP_SUBMITTED = 'WebAuthn Setup Submitted'.freeze # rubocop:enable Metrics/LineLength diff --git a/app/view_models/account_show.rb b/app/view_models/account_show.rb index d7c72547ed4..45fb06b4db7 100644 --- a/app/view_models/account_show.rb +++ b/app/view_models/account_show.rb @@ -68,10 +68,6 @@ def piv_cac_partial end end - def webauthn_partial - 'accounts/actions/add_webauthn' - end - def manage_personal_key_partial yield if decorated_user.password_reset_profile.blank? end diff --git a/app/views/accounts/_webauthn.html.slim b/app/views/accounts/_webauthn.html.slim new file mode 100644 index 00000000000..e60e49e928e --- /dev/null +++ b/app/views/accounts/_webauthn.html.slim @@ -0,0 +1,16 @@ +.clearfix.border-top.border-blue-light +.clearfix.border-top.border-blue-light + .p2.col.col-12.border-bottom.border-blue-light + .col.col-6.bold + = t('account.index.webauthn') + .right-align.col.col-6 + .btn.btn-account-action.rounded-lg.bg-light-blue + = link_to t('account.index.webauthn_add'), webauthn_setup_url +- current_user.webauthn_configurations.each do |cfg| + .p2.col.col-12 + .col.col-8.sm-6.truncate + = cfg.name + .col.col-4.sm-6.right-align + = button_to(t('account.index.webauthn_delete'), webauthn_setup_path(id: cfg.id), + method: :delete, class: 'btn btn-link') +.clearfix.border-bottom.border-blue-light diff --git a/app/views/accounts/actions/_add_webauthn.html.slim b/app/views/accounts/actions/_add_webauthn.html.slim deleted file mode 100644 index 120fb67e9ec..00000000000 --- a/app/views/accounts/actions/_add_webauthn.html.slim +++ /dev/null @@ -1,3 +0,0 @@ -= link_to webauthn_setup_url do - span.hide = t('account.index.webauthn') - = t('forms.buttons.enable') diff --git a/app/views/accounts/show.html.slim b/app/views/accounts/show.html.slim index 43d733a0b1e..ebceab75168 100644 --- a/app/views/accounts/show.html.slim +++ b/app/views/accounts/show.html.slim @@ -46,10 +46,7 @@ h1.hide = t('titles.account') action: @view_model.totp_partial - if FeatureManagement.webauthn_enabled? - = render 'account_item', - name: t('account.index.webauthn'), - content: content_tag(:em, @view_model.totp_content), - action: @view_model.webauthn_partial + = render 'webauthn' - if current_user.piv_cac_available? = render 'account_item', diff --git a/config/locales/account/en.yml b/config/locales/account/en.yml index 3ddd24856ca..fde7644c6fe 100644 --- a/config/locales/account/en.yml +++ b/config/locales/account/en.yml @@ -26,6 +26,8 @@ en: reactivate_button: Enter the code you received via US mail success: Your account has been verified. webauthn: Hardware security key + webauthn_add: "+ Add hardware security key" + webauthn_delete: Delete items: delete_your_account: Delete your account personal_key: Personal key diff --git a/config/locales/account/es.yml b/config/locales/account/es.yml index bea02a24af6..64d6d1c37ef 100644 --- a/config/locales/account/es.yml +++ b/config/locales/account/es.yml @@ -26,6 +26,8 @@ es: reactivate_button: Ingrese el código que recibió por correo postal. success: Su cuenta ha sido verificada. webauthn: Clave de seguridad de hardware + webauthn_add: "+ Agregar clave de seguridad de hardware" + webauthn_delete: Borrar items: delete_your_account: Eliminar su cuenta personal_key: Clave personal diff --git a/config/locales/account/fr.yml b/config/locales/account/fr.yml index cd76489de60..c6e0da5a599 100644 --- a/config/locales/account/fr.yml +++ b/config/locales/account/fr.yml @@ -28,6 +28,8 @@ fr: reactivate_button: Entrez le code que vous avez reçu par la poste success: Votre compte a été vérifié. webauthn: Clé de sécurité matérielle + webauthn_add: "+ Ajouter une clé de sécurité matérielle" + webauthn_delete: Effacer items: delete_your_account: Supprimer votre compte personal_key: Clé personnelle diff --git a/config/locales/notices/en.yml b/config/locales/notices/en.yml index 60d0ac6051a..72abeff218c 100644 --- a/config/locales/notices/en.yml +++ b/config/locales/notices/en.yml @@ -46,5 +46,6 @@ en: link: use a different email address text_html: Or, %{link} webauthn_added: You added a hardware security key. + webauthn_deleted: You deleted a hardware security key. session_timedout: We signed you out. For your security, %{app} ends your session when you haven’t moved to a new page for %{minutes} minutes. diff --git a/config/locales/notices/es.yml b/config/locales/notices/es.yml index 0ce7829e1be..d19462fb9ba 100644 --- a/config/locales/notices/es.yml +++ b/config/locales/notices/es.yml @@ -46,5 +46,6 @@ es: link: use un email diferente text_html: O %{link} webauthn_added: Agregaste una clave de seguridad de hardware. + webauthn_deleted: Has eliminado una clave de seguridad de hardware. session_timedout: Hemos terminado su sesión. Para su seguridad, %{app} cierra su sesión cuando usted no pasa a una nueva página durante %{minutes} minutos. diff --git a/config/locales/notices/fr.yml b/config/locales/notices/fr.yml index 9d840ab589e..3a39856f34b 100644 --- a/config/locales/notices/fr.yml +++ b/config/locales/notices/fr.yml @@ -48,6 +48,7 @@ fr: link: utilisez une adresse courriel différente text_html: Or, %{link} webauthn_added: Vous avez ajouté une clé de sécurité matérielle. + webauthn_deleted: Vous avez supprimé une clé de sécurité matérielle. session_timedout: Nous vous avons déconnecté. Pour votre sécurité, %{app} désactive votre session lorsque vous demeurez sur une page sans vous déplacer pendant %{minutes} minutes. diff --git a/config/routes.rb b/config/routes.rb index e5218c15fd6..adc12757ff3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -126,6 +126,7 @@ if FeatureManagement.webauthn_enabled? get '/webauthn_setup' => 'users/webauthn_setup#new', as: :webauthn_setup patch '/webauthn_setup' => 'users/webauthn_setup#confirm' + delete '/webauthn_setup' => 'users/webauthn_setup#delete' end delete '/authenticator_setup' => 'users/totp_setup#disable', as: :disable_totp diff --git a/spec/controllers/users/webauthn_setup_controller_spec.rb b/spec/controllers/users/webauthn_setup_controller_spec.rb index b6fcd732e08..a800de65125 100644 --- a/spec/controllers/users/webauthn_setup_controller_spec.rb +++ b/spec/controllers/users/webauthn_setup_controller_spec.rb @@ -82,5 +82,31 @@ patch :confirm, params: params end end + + describe 'delete' do + it 'deletes a webauthn configuration' do + cfg = create_webauthn_configuration(controller.current_user, 'key1', 'id1', 'foo1') + delete :delete, params: { id: cfg.id } + + expect(response).to redirect_to(account_url) + expect(flash.now[:success]).to eq t('notices.webauthn_deleted') + expect(WebauthnConfiguration.count).to eq(0) + end + + it 'tracks the delete' do + cfg = create_webauthn_configuration(controller.current_user, 'key1', 'id1', 'foo1') + + expect(@analytics).to receive(:track_event).with(Analytics::WEBAUTHN_DELETED) + + delete :delete, params: { id: cfg.id } + end + end + end + + def create_webauthn_configuration(user, name, id, key) + WebauthnConfiguration.create(user_id: user.id, + credential_public_key: key, + credential_id: id, + name: name) end end diff --git a/spec/features/users/webauthn_management_spec.rb b/spec/features/users/webauthn_management_spec.rb index e4d23460bc0..cd41c2fbdbd 100644 --- a/spec/features/users/webauthn_management_spec.rb +++ b/spec/features/users/webauthn_management_spec.rb @@ -12,7 +12,7 @@ visit account_path expect(current_path).to eq account_path - click_link t('forms.buttons.enable'), href: webauthn_setup_url + click_link t('account.index.webauthn_add'), href: webauthn_setup_url expect(current_path).to eq webauthn_setup_path mock_press_button_on_hardware_key @@ -27,7 +27,7 @@ visit account_path expect(current_path).to eq account_path - click_link t('forms.buttons.enable'), href: webauthn_setup_url + click_link t('account.index.webauthn_add'), href: webauthn_setup_url expect(current_path).to eq webauthn_setup_path mock_press_button_on_hardware_key @@ -43,7 +43,7 @@ visit account_path expect(current_path).to eq account_path - click_link t('forms.buttons.enable'), href: webauthn_setup_url + click_link t('account.index.webauthn_add'), href: webauthn_setup_url expect(current_path).to eq webauthn_setup_path click_submit_default @@ -59,7 +59,7 @@ visit account_path expect(current_path).to eq account_path - click_link t('forms.buttons.enable'), href: webauthn_setup_url + click_link t('account.index.webauthn_add'), href: webauthn_setup_url expect(current_path).to eq webauthn_setup_path mock_press_button_on_hardware_key @@ -68,7 +68,7 @@ expect(current_path).to eq account_path expect(page).to have_content t('notices.webauthn_added') - click_link t('forms.buttons.enable'), href: webauthn_setup_url + click_link t('account.index.webauthn_add'), href: webauthn_setup_url expect(current_path).to eq webauthn_setup_path mock_press_button_on_hardware_key @@ -77,6 +77,40 @@ expect(current_path).to eq webauthn_setup_path expect(page).to have_content t('errors.webauthn_setup.unique_name') end + + it 'displays a link to add a hardware security key' do + sign_in_and_2fa_user(user) + + visit account_path + expect(page).to have_link(t('account.index.webauthn_add'), href: webauthn_setup_url) + end + end + + context 'with webauthn associations' do + it 'displays the user supplied names of the webauthn keys' do + create_webauthn_configuration(user, 'key1', '1', 'foo1') + create_webauthn_configuration(user, 'key2', '2', 'bar2') + + sign_in_and_2fa_user(user) + visit account_path + + expect(page).to have_content 'key1' + expect(page).to have_content 'key2' + end + + it 'allows the user to delete the webauthn key' do + create_webauthn_configuration(user, 'key1', '1', 'foo1') + + sign_in_and_2fa_user(user) + visit account_path + + expect(page).to have_content 'key1' + + click_button t('account.index.webauthn_delete') + + expect(page).to_not have_content 'key1' + expect(page).to have_content t('notices.webauthn_deleted') + end end def mock_challenge @@ -97,4 +131,11 @@ def mock_press_button_on_hardware_key def set_hidden_field(id, value) first("input##{id}", visible: false).set(value) end + + def create_webauthn_configuration(user, name, id, key) + WebauthnConfiguration.create(user_id: user.id, + credential_public_key: key, + credential_id: id, + name: name) + end end From 26bdcb88b73ad1c5d0346c12c433160c76a8c9bb Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Fri, 7 Sep 2018 13:12:30 -0400 Subject: [PATCH 48/61] Prevented deleting last mfa. Changed text from delete to remove --- .../users/webauthn_setup_controller.rb | 27 ++++++++++++++++--- app/models/user.rb | 9 ++++++- config/locales/account/en.yml | 2 +- config/locales/account/es.yml | 2 +- config/locales/account/fr.yml | 2 +- config/locales/errors/en.yml | 1 + config/locales/errors/es.yml | 1 + config/locales/errors/fr.yml | 1 + .../users/webauthn_management_spec.rb | 21 +++++++++++++++ 9 files changed, 59 insertions(+), 7 deletions(-) diff --git a/app/controllers/users/webauthn_setup_controller.rb b/app/controllers/users/webauthn_setup_controller.rb index d433ad64df1..adeccae67a8 100644 --- a/app/controllers/users/webauthn_setup_controller.rb +++ b/app/controllers/users/webauthn_setup_controller.rb @@ -20,14 +20,35 @@ def confirm end def delete - analytics.track_event(Analytics::WEBAUTHN_DELETED) - WebauthnConfiguration.where(user_id: current_user.id, id: params[:id]).destroy_all - flash[:success] = t('notices.webauthn_deleted') + if current_user.total_mfa_options_enabled > 1 + handle_successful_delete + else + handle_failed_delete + end redirect_to account_url end private + def handle_successful_delete + WebauthnConfiguration.where(user_id: current_user.id, id: params[:id]).destroy_all + flash[:success] = t('notices.webauthn_deleted') + track_delete(true) + end + + def handle_failed_delete + flash[:error] = t('errors.webauthn_setup.delete_last') + track_delete(false) + end + + def track_delete(success) + analytics.track_event( + Analytics::WEBAUTHN_DELETED, + success: success, + mfa_options: current_user.total_mfa_options_enabled + ) + end + def save_challenge_in_session credential_creation_options = ::WebAuthn.credential_creation_options user_session[:webauthn_challenge] = credential_creation_options[:challenge].bytes.to_a diff --git a/app/models/user.rb b/app/models/user.rb index bc59f4b9dc3..f0a8534cd23 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -69,7 +69,8 @@ def need_two_factor_authentication?(_request) end def two_factor_enabled? - phone_configurations.any?(&:mfa_enabled?) || totp_enabled? || piv_cac_enabled? + phone_configurations.any?(&:mfa_enabled?) || totp_enabled? || piv_cac_enabled? || + !webauthn_configurations.empty? end def send_two_factor_authentication_code(_code) @@ -159,5 +160,11 @@ def send_custom_confirmation_instructions(id = nil, instructions = nil) send_devise_notification(:confirmation_instructions, @raw_confirmation_token, opts) end + + def total_mfa_options_enabled + total = [phone_configuration.mfa_enabled?, piv_cac_enabled?, totp_enabled?].count { |tf| tf } + total += webauthn_configurations.size + total + end end # rubocop:enable Rails/HasManyOrHasOneDependent diff --git a/config/locales/account/en.yml b/config/locales/account/en.yml index fde7644c6fe..17476875454 100644 --- a/config/locales/account/en.yml +++ b/config/locales/account/en.yml @@ -27,7 +27,7 @@ en: success: Your account has been verified. webauthn: Hardware security key webauthn_add: "+ Add hardware security key" - webauthn_delete: Delete + webauthn_delete: Remove items: delete_your_account: Delete your account personal_key: Personal key diff --git a/config/locales/account/es.yml b/config/locales/account/es.yml index 64d6d1c37ef..b32a03c4e7d 100644 --- a/config/locales/account/es.yml +++ b/config/locales/account/es.yml @@ -27,7 +27,7 @@ es: success: Su cuenta ha sido verificada. webauthn: Clave de seguridad de hardware webauthn_add: "+ Agregar clave de seguridad de hardware" - webauthn_delete: Borrar + webauthn_delete: Retirar items: delete_your_account: Eliminar su cuenta personal_key: Clave personal diff --git a/config/locales/account/fr.yml b/config/locales/account/fr.yml index c6e0da5a599..1212575cb64 100644 --- a/config/locales/account/fr.yml +++ b/config/locales/account/fr.yml @@ -29,7 +29,7 @@ fr: success: Votre compte a été vérifié. webauthn: Clé de sécurité matérielle webauthn_add: "+ Ajouter une clé de sécurité matérielle" - webauthn_delete: Effacer + webauthn_delete: Retirer items: delete_your_account: Supprimer votre compte personal_key: Clé personnelle diff --git a/config/locales/errors/en.yml b/config/locales/errors/en.yml index 2e37d79d262..dc901de9d81 100644 --- a/config/locales/errors/en.yml +++ b/config/locales/errors/en.yml @@ -52,6 +52,7 @@ en: letter for a new code. weak_password: Your password is not strong enough. %{feedback} webauthn_setup: + delete_last: Sorry you can not remove your last MFA option. general_error: There was an error adding your hardward security key. Please try again. unique_name: That name is already taken. Please choose a different name. diff --git a/config/locales/errors/es.yml b/config/locales/errors/es.yml index 5aa1c6e15e4..45e05e16ae1 100644 --- a/config/locales/errors/es.yml +++ b/config/locales/errors/es.yml @@ -47,6 +47,7 @@ es: usps_otp_expired: NOT TRANSLATED YET weak_password: Su contraseña no es suficientemente segura. %{feedback} webauthn_setup: + delete_last: Lo sentimos, no puedes eliminar tu última opción de MFA. general_error: Hubo un error al agregar su clave de seguridad de hardware. Inténtalo de nuevo. unique_name: El nombre ya fue escogido. Por favor, elija un nombre diferente. diff --git a/config/locales/errors/fr.yml b/config/locales/errors/fr.yml index bf6afc1e7fb..5de9f9da324 100644 --- a/config/locales/errors/fr.yml +++ b/config/locales/errors/fr.yml @@ -49,6 +49,7 @@ fr: usps_otp_expired: NOT TRANSLATED YET weak_password: Votre mot de passe n'est pas assez fort. %{feedback} webauthn_setup: + delete_last: Désolé, vous ne pouvez pas supprimer votre dernière option MFA general_error: Une erreur s'est produite lors de l'ajout de votre clé de sécurité matérielle. Veuillez réessayer. unique_name: Ce nom est déjà pris. Veuillez choisir un autre nom. diff --git a/spec/features/users/webauthn_management_spec.rb b/spec/features/users/webauthn_management_spec.rb index cd41c2fbdbd..f273a4618c9 100644 --- a/spec/features/users/webauthn_management_spec.rb +++ b/spec/features/users/webauthn_management_spec.rb @@ -3,6 +3,12 @@ feature 'Webauthn Management' do include WebauthnHelper +<<<<<<< HEAD +======= + let(:user) { create(:user, :signed_up, phone: '+1 202-555-1212') } + let(:no_phone_user) { build_stubbed(:user, :signed_up, otp_secret_key: '6pcrpu334cx7zyf7') } + +>>>>>>> Prevented deleting last mfa. Changed text from delete to remove context 'with no webauthn associated yet' do let(:user) { create(:user, :signed_up, with: { phone: '+1 202-555-1212' }) } @@ -111,6 +117,21 @@ expect(page).to_not have_content 'key1' expect(page).to have_content t('notices.webauthn_deleted') end + + it 'prevents a user from deleting the last key' do + create_webauthn_configuration(user, 'key1', '1', 'foo1') + + sign_in_and_2fa_user(user) + PhoneConfiguration.first.update(mfa_enabled: false) + visit account_path + + expect(page).to have_content 'key1' + + click_button t('account.index.webauthn_delete') + + expect(page).to have_content 'key1' + expect(page).to have_content t('errors.webauthn_setup.delete_last') + end end def mock_challenge From 30834a454f8acc55f734d5a378c719d15934c03f Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Fri, 7 Sep 2018 13:25:35 -0400 Subject: [PATCH 49/61] Fix spec --- spec/features/users/webauthn_management_spec.rb | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/spec/features/users/webauthn_management_spec.rb b/spec/features/users/webauthn_management_spec.rb index f273a4618c9..1b7c89156d0 100644 --- a/spec/features/users/webauthn_management_spec.rb +++ b/spec/features/users/webauthn_management_spec.rb @@ -3,15 +3,9 @@ feature 'Webauthn Management' do include WebauthnHelper -<<<<<<< HEAD -======= - let(:user) { create(:user, :signed_up, phone: '+1 202-555-1212') } - let(:no_phone_user) { build_stubbed(:user, :signed_up, otp_secret_key: '6pcrpu334cx7zyf7') } + let(:user) { create(:user, :signed_up, with: { phone: '+1 202-555-1212' }) } ->>>>>>> Prevented deleting last mfa. Changed text from delete to remove context 'with no webauthn associated yet' do - let(:user) { create(:user, :signed_up, with: { phone: '+1 202-555-1212' }) } - it 'allows user to add a webauthn configuration' do mock_challenge sign_in_and_2fa_user(user) From f74aa3a0c98f8b58ff671ac0f79d897d391a8b7c Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Fri, 7 Sep 2018 13:50:41 -0400 Subject: [PATCH 50/61] Misc fixes --- app/models/user.rb | 2 +- app/views/users/webauthn_setup/new.html.slim | 2 +- spec/controllers/users/webauthn_setup_controller_spec.rb | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index f0a8534cd23..01cc9e15d8f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -162,7 +162,7 @@ def send_custom_confirmation_instructions(id = nil, instructions = nil) end def total_mfa_options_enabled - total = [phone_configuration.mfa_enabled?, piv_cac_enabled?, totp_enabled?].count { |tf| tf } + total = [phone_configuration&.mfa_enabled?, piv_cac_enabled?, totp_enabled?].count { |tf| tf } total += webauthn_configurations.size total end diff --git a/app/views/users/webauthn_setup/new.html.slim b/app/views/users/webauthn_setup/new.html.slim index a271def3b13..0d09bfe4baa 100644 --- a/app/views/users/webauthn_setup/new.html.slim +++ b/app/views/users/webauthn_setup/new.html.slim @@ -31,7 +31,7 @@ ul.list-reset = hidden_field_tag :webauthn_public_key, '', id: 'webauthn_public_key' = hidden_field_tag :attestation_object, '', id: 'attestation_object' = hidden_field_tag :client_data_json, '', id: 'client_data_json' - = text_field_tag :name, '', required: true, pattern: '[A-Za-z0-9]*', id: 'name', + = text_field_tag :name, '', required: true, id: 'name', class: 'block col-12 field monospace', size: 16, maxlength: 20, 'aria-labelledby': 'totp-label' .col.col-6.sm-col-5.px1 diff --git a/spec/controllers/users/webauthn_setup_controller_spec.rb b/spec/controllers/users/webauthn_setup_controller_spec.rb index a800de65125..3a5db5a52dd 100644 --- a/spec/controllers/users/webauthn_setup_controller_spec.rb +++ b/spec/controllers/users/webauthn_setup_controller_spec.rb @@ -96,7 +96,8 @@ it 'tracks the delete' do cfg = create_webauthn_configuration(controller.current_user, 'key1', 'id1', 'foo1') - expect(@analytics).to receive(:track_event).with(Analytics::WEBAUTHN_DELETED) + result = { success: true, mfa_options: 0 } + expect(@analytics).to receive(:track_event).with(Analytics::WEBAUTHN_DELETED, result) delete :delete, params: { id: cfg.id } end From f168caea570b5b7b135acb7f402cc001a36564ce Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Fri, 7 Sep 2018 15:06:20 -0400 Subject: [PATCH 51/61] Fixed specs 2 --- app/controllers/users/webauthn_setup_controller.rb | 2 +- app/views/accounts/_webauthn.html.slim | 1 - spec/controllers/users/webauthn_setup_controller_spec.rb | 6 +++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/controllers/users/webauthn_setup_controller.rb b/app/controllers/users/webauthn_setup_controller.rb index adeccae67a8..f2a771d4068 100644 --- a/app/controllers/users/webauthn_setup_controller.rb +++ b/app/controllers/users/webauthn_setup_controller.rb @@ -45,7 +45,7 @@ def track_delete(success) analytics.track_event( Analytics::WEBAUTHN_DELETED, success: success, - mfa_options: current_user.total_mfa_options_enabled + mfa_options_enabled: current_user.total_mfa_options_enabled ) end diff --git a/app/views/accounts/_webauthn.html.slim b/app/views/accounts/_webauthn.html.slim index e60e49e928e..ac1ff8226a9 100644 --- a/app/views/accounts/_webauthn.html.slim +++ b/app/views/accounts/_webauthn.html.slim @@ -13,4 +13,3 @@ .col.col-4.sm-6.right-align = button_to(t('account.index.webauthn_delete'), webauthn_setup_path(id: cfg.id), method: :delete, class: 'btn btn-link') -.clearfix.border-bottom.border-blue-light diff --git a/spec/controllers/users/webauthn_setup_controller_spec.rb b/spec/controllers/users/webauthn_setup_controller_spec.rb index 3a5db5a52dd..2ac17d1fe70 100644 --- a/spec/controllers/users/webauthn_setup_controller_spec.rb +++ b/spec/controllers/users/webauthn_setup_controller_spec.rb @@ -84,6 +84,10 @@ end describe 'delete' do + before do + allow(controller.current_user).to receive(:total_mfa_options_enabled).and_return(2) + end + it 'deletes a webauthn configuration' do cfg = create_webauthn_configuration(controller.current_user, 'key1', 'id1', 'foo1') delete :delete, params: { id: cfg.id } @@ -96,7 +100,7 @@ it 'tracks the delete' do cfg = create_webauthn_configuration(controller.current_user, 'key1', 'id1', 'foo1') - result = { success: true, mfa_options: 0 } + result = { success: true, mfa_options_enabled: 2 } expect(@analytics).to receive(:track_event).with(Analytics::WEBAUTHN_DELETED, result) delete :delete, params: { id: cfg.id } From 213ea618a3a63fb774b3d92080ea0b735af8bda1 Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Fri, 7 Sep 2018 15:22:23 -0400 Subject: [PATCH 52/61] Lint 3 --- app/models/user.rb | 11 +++++++---- app/views/accounts/_webauthn.html.slim | 1 + 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index 01cc9e15d8f..b25e0b04502 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -70,7 +70,7 @@ def need_two_factor_authentication?(_request) def two_factor_enabled? phone_configurations.any?(&:mfa_enabled?) || totp_enabled? || piv_cac_enabled? || - !webauthn_configurations.empty? + webauthn_configurations.any? end def send_two_factor_authentication_code(_code) @@ -162,9 +162,12 @@ def send_custom_confirmation_instructions(id = nil, instructions = nil) end def total_mfa_options_enabled - total = [phone_configuration&.mfa_enabled?, piv_cac_enabled?, totp_enabled?].count { |tf| tf } - total += webauthn_configurations.size - total + total = [phone_mfa_enabled?, piv_cac_enabled?, totp_enabled?].count { |tf| tf } + total + webauthn_configurations.size + end + + def phone_mfa_enabled? + phone_configuration&.mfa_enabled? end end # rubocop:enable Rails/HasManyOrHasOneDependent diff --git a/app/views/accounts/_webauthn.html.slim b/app/views/accounts/_webauthn.html.slim index ac1ff8226a9..e60e49e928e 100644 --- a/app/views/accounts/_webauthn.html.slim +++ b/app/views/accounts/_webauthn.html.slim @@ -13,3 +13,4 @@ .col.col-4.sm-6.right-align = button_to(t('account.index.webauthn_delete'), webauthn_setup_path(id: cfg.id), method: :delete, class: 'btn btn-link') +.clearfix.border-bottom.border-blue-light From 30753b4053b8867e5a57ec99bfaa11972b55ab08 Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Fri, 7 Sep 2018 15:31:07 -0400 Subject: [PATCH 53/61] Lint 5 --- app/models/user.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/user.rb b/app/models/user.rb index b25e0b04502..3a8b175daae 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -167,7 +167,7 @@ def total_mfa_options_enabled end def phone_mfa_enabled? - phone_configuration&.mfa_enabled? + phone_configuration&.mfa_enabled end end # rubocop:enable Rails/HasManyOrHasOneDependent From bf54f7609d01cbc879f54a60e9072dedc7807da9 Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Fri, 7 Sep 2018 15:37:27 -0400 Subject: [PATCH 54/61] Battling reek on nil check --- app/models/user.rb | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index 3a8b175daae..0d04fbb7ce1 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -162,12 +162,8 @@ def send_custom_confirmation_instructions(id = nil, instructions = nil) end def total_mfa_options_enabled - total = [phone_mfa_enabled?, piv_cac_enabled?, totp_enabled?].count { |tf| tf } + total = [phone_configuration&.mfa_enabled, piv_cac_enabled?, totp_enabled?].count { |tf| tf } total + webauthn_configurations.size end - - def phone_mfa_enabled? - phone_configuration&.mfa_enabled - end end # rubocop:enable Rails/HasManyOrHasOneDependent From 7cc367ef006007532f7ee426407f19465dad04ed Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Fri, 7 Sep 2018 16:57:49 -0400 Subject: [PATCH 55/61] Fix double lines --- app/views/accounts/_webauthn.html.slim | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/views/accounts/_webauthn.html.slim b/app/views/accounts/_webauthn.html.slim index e60e49e928e..02f7ff553f5 100644 --- a/app/views/accounts/_webauthn.html.slim +++ b/app/views/accounts/_webauthn.html.slim @@ -1,16 +1,15 @@ .clearfix.border-top.border-blue-light -.clearfix.border-top.border-blue-light - .p2.col.col-12.border-bottom.border-blue-light + .p2.col.col-12 .col.col-6.bold = t('account.index.webauthn') .right-align.col.col-6 .btn.btn-account-action.rounded-lg.bg-light-blue = link_to t('account.index.webauthn_add'), webauthn_setup_url - current_user.webauthn_configurations.each do |cfg| - .p2.col.col-12 + .p2.col.col-12.border-top.border-blue-light .col.col-8.sm-6.truncate = cfg.name .col.col-4.sm-6.right-align = button_to(t('account.index.webauthn_delete'), webauthn_setup_path(id: cfg.id), method: :delete, class: 'btn btn-link') -.clearfix.border-bottom.border-blue-light + .clearfix \ No newline at end of file From 5e389d39f0f3a69d9926904f366d18eb540d516c Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Fri, 7 Sep 2018 17:17:56 -0400 Subject: [PATCH 56/61] Lint --- app/models/user.rb | 6 +++++- app/views/accounts/_webauthn.html.slim | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index 0d04fbb7ce1..3166f42bf4c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -162,8 +162,12 @@ def send_custom_confirmation_instructions(id = nil, instructions = nil) end def total_mfa_options_enabled - total = [phone_configuration&.mfa_enabled, piv_cac_enabled?, totp_enabled?].count { |tf| tf } + total = [phone_mfa_enabled?, piv_cac_enabled?, totp_enabled?].count { |tf| tf } total + webauthn_configurations.size end + + def phone_mfa_enabled? + phone_configurations.any?(&:mfa_enabled?) + end end # rubocop:enable Rails/HasManyOrHasOneDependent diff --git a/app/views/accounts/_webauthn.html.slim b/app/views/accounts/_webauthn.html.slim index 02f7ff553f5..3e480dc8b39 100644 --- a/app/views/accounts/_webauthn.html.slim +++ b/app/views/accounts/_webauthn.html.slim @@ -12,4 +12,4 @@ .col.col-4.sm-6.right-align = button_to(t('account.index.webauthn_delete'), webauthn_setup_path(id: cfg.id), method: :delete, class: 'btn btn-link') - .clearfix \ No newline at end of file + .clearfix From 59a639ee56afe16197502f3b8dc990e126554c25 Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Fri, 7 Sep 2018 17:19:09 -0400 Subject: [PATCH 57/61] Comma --- config/locales/errors/en.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/locales/errors/en.yml b/config/locales/errors/en.yml index dc901de9d81..aab2f394d27 100644 --- a/config/locales/errors/en.yml +++ b/config/locales/errors/en.yml @@ -52,7 +52,7 @@ en: letter for a new code. weak_password: Your password is not strong enough. %{feedback} webauthn_setup: - delete_last: Sorry you can not remove your last MFA option. + delete_last: Sorry, you can not remove your last MFA option. general_error: There was an error adding your hardward security key. Please try again. unique_name: That name is already taken. Please choose a different name. From 6290b127fdd463d684de85d1303d8066ee876020 Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Fri, 7 Sep 2018 22:56:49 -0400 Subject: [PATCH 58/61] Update Reek from 4.8.1 to 5.0.2 See upgrade guide for more info: https://github.com/troessner/reek/blob/master/docs/Reek-4-to-Reek-5-migration.md --- .reek.yml | 220 +++++++++++++++++++++++++++++++++++++++++++++++++++ Gemfile.lock | 6 +- 2 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 .reek.yml diff --git a/.reek.yml b/.reek.yml new file mode 100644 index 00000000000..0af0017f933 --- /dev/null +++ b/.reek.yml @@ -0,0 +1,220 @@ +detectors: + Attribute: + enabled: false + ControlParameter: + exclude: + - CustomDeviseFailureApp#i18n_message + - OpenidConnectRedirector#initialize + - NoRetryJobs#call + - PhoneFormatter#self.format + - Users::TwoFactorAuthenticationController#invalid_phone_number + DuplicateMethodCall: + exclude: + - ApplicationController#disable_caching + - IdvFailureConcern#render_failure + - ServiceProviderSessionDecorator#registration_heading + - MfaConfirmationController#handle_invalid_password + - needs_to_confirm_email_change? + - WorkerHealthChecker#status + - UserFlowExporter#self.massage_assets + - BasicAuthUrl#build + - fallback_to_english + - Upaya::RandomTools#self.random_weighted_sample + - SmsController#authenticate + FeatureEnvy: + exclude: + - ActiveJob::Logging::LogSubscriber#json_for + - Ahoy::Store#track_event + - Aws::SES::Base#deliver + - CustomDeviseFailureApp#build_options + - CustomDeviseFailureApp#keys + - track_registration + - append_info_to_payload + - generate_slo_request + - reauthn? + - mark_profile_inactive + - EncryptedSidekiqRedis#zrem + - UserDecorator#should_acknowledge_personal_key? + - Pii::Attributes#[]= + - OpenidConnectLogoutForm#load_identity + - Idv::ProfileMaker#pii_from_applicant + - Idv::Step#vendor_validator_result + - IdvSession#vendor_result_timed_out? + - ServiceProviderSeeder#run + - OtpDeliverySelectionForm#unsupported_phone? + - fallback_to_english + - UserEncryptedAttributeOverrides#find_with_email + - Utf8Sanitizer#event_attributes + - Utf8Sanitizer#remote_ip + - TwoFactorAuthenticationController#capture_analytics_for_exception + - UspsConfirmationExporter#make_entry_row + InstanceVariableAssumption: + exclude: + - User + - JWT + IrresponsibleModule: + enabled: false + ManualDispatch: + exclude: + - EncryptedSidekiqRedis#respond_to_missing? + - CloudhsmKeyGenerator#initialize_settings + NestedIterators: + exclude: + - UserFlowExporter#self.massage_html + - TwilioService::Utils#sanitize_phone_number + - ServiceProviderSeeder#run + - UspsConfirmationUploader#upload_export + NilCheck: + enabled: false + LongParameterList: + max_params: 4 + exclude: + - IdentityLinker#optional_attributes + - Idv::ProoferJob#perform + - Idv::VendorResult#initialize + - JWT + - SmsOtpSenderJob#perform + RepeatedConditional: + exclude: + - Users::ResetPasswordsController + - IdvController + - Idv::Base + - Rack::Attack + TooManyConstants: + exclude: + - Analytics + TooManyInstanceVariables: + exclude: + - OpenidConnectAuthorizeForm + - OpenidConnectRedirector + - Idv::VendorResult + - CloudhsmKeyGenerator + - CloudhsmKeySharer + - WebauthnSetupForm + TooManyStatements: + max_statements: 6 + exclude: + - IdvFailureConcern#render_failure + - OpenidConnect::AuthorizationController#index + - OpenidConnect::AuthorizationController#store_request + - SamlIdpAuthConcern#store_saml_request + - Users::PhoneConfirmationController + - UserFlowExporter#self.massage_assets + - UserFlowExporter#self.massage_html + - UserFlowExporter#self.run + - Idv::Agent#proof + - Idv::VendorResult#initialize + - SamlIdpController#auth + - Upaya::QueueConfig#self.choose_queue_adapter + - Upaya::RandomTools#self.random_weighted_sample + - UserFlowFormatter#stop + - Upaya::QueueConfig#self.choose_queue_adapter + - Users::TwoFactorAuthenticationController#send_code + TooManyMethods: + exclude: + - Users::ConfirmationsController + - ApplicationController + - OpenidConnectAuthorizeForm + - OpenidConnect::AuthorizationController + - Idv::Session + - User + - Idv::SessionsController + - ServiceProviderSessionDecorator + - SessionDecorator + - HolidayService + - PhoneDeliveryPresenter + - CloudhsmKeyGenerator + UncommunicativeMethodName: + exclude: + - PhoneConfirmationFlow + - render_401 + - SessionDecorator#registration_bullet_1 + - ServiceProviderSessionDecorator#registration_bullet_1 + UncommunicativeModuleName: + exclude: + - X509 + - X509::Attribute + - X509::Attributes + - X509::SessionStore + UnusedParameters: + exclude: + - SmsOtpSenderJob#perform + - VoiceOtpSenderJob#perform + UnusedPrivateMethod: + exclude: + - ApplicationController + - ActiveJob::Logging::LogSubscriber + - SamlIdpController + - Users::PhoneConfirmationController + - ssn_is_unique + UtilityFunction: + public_methods_only: true + exclude: + - AnalyticsEventJob#perform + - ApplicationController#default_url_options + - ApplicationHelper#step_class + - NullTwilioClient#http_client + - PersonalKeyFormatter#regexp + - SessionTimeoutWarningHelper#frequency + - SessionTimeoutWarningHelper#start + - SessionTimeoutWarningHelper#warning + - SessionDecorator + - WorkerHealthChecker::Middleware#call + - UserEncryptedAttributeOverrides#create_fingerprint + - LocaleHelper#locale_url_param + - IdvSession#timed_out_vendor_error + - JWT::Signature#sign + - SmsAccountResetCancellationNotifierJob#perform +directories: + 'app/controllers': + InstanceVariableAssumption: + enabled: false + 'spec': + BooleanParameter: + exclude: + - SamlAuthHelper#generate_saml_response + ControlParameter: + exclude: + - complete_idv_session + - SamlAuthHelper#link_user_to_identity + - visit_idp_from_sp_with_loa1 + - visit_idp_from_sp_with_loa3 + DuplicateMethodCall: + enabled: false + FeatureEnvy: + enabled: false + NestedIterators: + exclude: + - complete_idv_questions_fail + - complete_idv_questions_ok + - create_sidekiq_queues + NilCheck: + exclude: + - complete_idv_questions_fail + - complete_idv_questions_ok + TooManyInstanceVariables: + enabled: false + TooManyMethods: + enabled: false + TooManyStatements: + enabled: false + UncommunicativeMethodName: + exclude: + - visit_idp_from_sp_with_loa1 + - visit_idp_from_sp_with_loa3 + - visit_idp_from_mobile_app_with_loa1 + - visit_idp_from_oidc_sp_with_loa1 + - visit_idp_from_oidc_sp_with_loa3 + UncommunicativeParameterName: + exclude: + - begin_sign_up_with_sp_and_loa + UncommunicativeVariableName: + exclude: + - complete_idv_questions_fail + - complete_idv_questions_ok + UtilityFunction: + enabled: false +exclude_paths: + - db/migrate + - spec + - lib/tasks/ diff --git a/Gemfile.lock b/Gemfile.lock index 99f6d86e986..a809613d807 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -326,6 +326,7 @@ GEM jwt (2.1.0) knapsack (1.16.0) rake + kwalify (0.7.2) launchy (2.4.3) addressable (~> 2.3) listen (3.1.5) @@ -452,9 +453,10 @@ GEM recaptcha (4.12.0) json redis (3.3.5) - reek (4.8.1) + reek (5.0.2) codeclimate-engine-rb (~> 0.4.0) - parser (>= 2.5.0.0, < 2.6) + kwalify (~> 0.7.0) + parser (>= 2.5.0.0, < 2.6, != 2.5.1.1) rainbow (>= 2.0, < 4.0) referer-parser (0.3.0) request_store (1.4.1) From 137644032f446bfcdca24f6086a3f579dea2c406 Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Tue, 4 Sep 2018 23:44:41 -0400 Subject: [PATCH 59/61] LG-610 Don't show recovery code before IdV flow **Why**: During LOA1 account creation, the user is given their personal key after setting up 2FA. However, once an LOA3 user passes the verification steps, and after they are prompted for their password in order to encrypt their data, they must receive a new personal key. In order to provide a better account creation experience for LOA3 users, we only show them the personal key once, instead of once after 2FA setup, and then again after verification. This was working as expected for users who used a phone for 2FA, but the logic for authentication app users was wrong. --- .reek | 1 - .../concerns/two_factor_authenticatable.rb | 8 ++- .../users/totp_setup_controller.rb | 12 +++- app/decorators/user_decorator.rb | 8 --- .../personal_key_for_new_user_policy.rb | 34 ++++++++++++ spec/decorators/user_decorator_spec.rb | 36 ------------ spec/features/idv/account_creation_spec.rb | 9 +++ .../personal_key_for_new_user_policy_spec.rb | 55 +++++++++++++++++++ spec/support/features/session_helper.rb | 2 +- .../shared_examples/account_creation.rb | 33 +++++++++++ 10 files changed, 148 insertions(+), 50 deletions(-) create mode 100644 app/policies/personal_key_for_new_user_policy.rb create mode 100644 spec/features/idv/account_creation_spec.rb create mode 100644 spec/policies/personal_key_for_new_user_policy_spec.rb diff --git a/.reek b/.reek index 3371a700ea8..969eb54dd5f 100644 --- a/.reek +++ b/.reek @@ -33,7 +33,6 @@ FeatureEnvy: - reauthn? - mark_profile_inactive - EncryptedSidekiqRedis#zrem - - UserDecorator#should_acknowledge_personal_key? - Pii::Attributes#[]= - OpenidConnectLogoutForm#load_identity - Idv::ProfileMaker#pii_from_applicant diff --git a/app/controllers/concerns/two_factor_authenticatable.rb b/app/controllers/concerns/two_factor_authenticatable.rb index d6014ac096b..82a1cd0994f 100644 --- a/app/controllers/concerns/two_factor_authenticatable.rb +++ b/app/controllers/concerns/two_factor_authenticatable.rb @@ -173,13 +173,17 @@ def after_otp_verification_confirmation_url end def after_otp_action_required? + policy = PersonalKeyForNewUserPolicy.new(user: current_user, session: session) + decorated_user.password_reset_profile.present? || @updating_existing_number || - decorated_user.should_acknowledge_personal_key?(session) + policy.show_personal_key_after_initial_2fa_setup? end def after_otp_action_url - if decorated_user.should_acknowledge_personal_key?(user_session) + policy = PersonalKeyForNewUserPolicy.new(user: current_user, session: session) + + if policy.show_personal_key_after_initial_2fa_setup? sign_up_personal_key_url elsif @updating_existing_number account_url diff --git a/app/controllers/users/totp_setup_controller.rb b/app/controllers/users/totp_setup_controller.rb index e8a4c829015..6b0dac80dfc 100644 --- a/app/controllers/users/totp_setup_controller.rb +++ b/app/controllers/users/totp_setup_controller.rb @@ -63,13 +63,21 @@ def mark_user_as_fully_authenticated end def url_after_entering_valid_code - if current_user.decorate.should_acknowledge_personal_key?(user_session) + return account_url if user_already_has_a_personal_key? + + policy = PersonalKeyForNewUserPolicy.new(user: current_user, session: session) + + if policy.show_personal_key_after_initial_2fa_setup? sign_up_personal_key_url else - account_url + idv_jurisdiction_url end end + def user_already_has_a_personal_key? + PersonalKeyLoginOptionPolicy.new(current_user).configured? + end + def process_invalid_code flash[:error] = t('errors.invalid_totp') redirect_to authenticator_setup_url diff --git a/app/decorators/user_decorator.rb b/app/decorators/user_decorator.rb index 4550daa3018..9e44ee157db 100644 --- a/app/decorators/user_decorator.rb +++ b/app/decorators/user_decorator.rb @@ -99,14 +99,6 @@ def no_longer_locked_out? user.second_factor_locked_at.present? && lockout_period_expired? end - def should_acknowledge_personal_key?(session) - return true if session[:personal_key] - - sp_session = session[:sp] - - user.encrypted_recovery_code_digest.blank? && (sp_session.blank? || sp_session[:loa3] == false) - end - def recent_events events = user.events.order('created_at DESC').limit(MAX_RECENT_EVENTS).map(&:decorate) identities = user.identities.order('last_authenticated_at DESC').map(&:decorate) diff --git a/app/policies/personal_key_for_new_user_policy.rb b/app/policies/personal_key_for_new_user_policy.rb new file mode 100644 index 00000000000..0067e07bd7c --- /dev/null +++ b/app/policies/personal_key_for_new_user_policy.rb @@ -0,0 +1,34 @@ +class PersonalKeyForNewUserPolicy + def initialize(user:, session:) + @user = user + @session = session + end + + # For new users who visit the site directly or via an LOA1 request, + # we show them their personal key after they set up 2FA for the first + # time during account creation. These users only see the personal key + # once during account creation. LOA3 users, on the other hand, need to + # confirm their personal key after verifying their identity because + # the key is used to encrypt their PII. Rather than making LOA3 users + # confirm personal keys twice, once after 2FA setup, and once after + # proofing, we only show it to them once after proofing. + def show_personal_key_after_initial_2fa_setup? + user_does_not_have_a_personal_key? && user_did_not_make_an_loa3_request? + end + + private + + attr_reader :user, :session + + def user_does_not_have_a_personal_key? + user.encrypted_recovery_code_digest.blank? + end + + def user_did_not_make_an_loa3_request? + sp_session.empty? || sp_session[:loa3] == false + end + + def sp_session + session.fetch(:sp, {}) + end +end diff --git a/spec/decorators/user_decorator_spec.rb b/spec/decorators/user_decorator_spec.rb index 6f5b5e08236..72252376dd3 100644 --- a/spec/decorators/user_decorator_spec.rb +++ b/spec/decorators/user_decorator_spec.rb @@ -204,42 +204,6 @@ end end - describe '#should_acknowledge_personal_key?' do - context 'user has no personal key' do - context 'service provider with loa1' do - it 'returns true' do - user_decorator = UserDecorator.new(User.new) - session = { sp: { loa3: false } } - - expect(user_decorator.should_acknowledge_personal_key?(session)).to eq true - end - end - - context 'no service provider' do - it 'returns true' do - user_decorator = UserDecorator.new(User.new) - session = {} - - expect(user_decorator.should_acknowledge_personal_key?(session)).to eq true - end - end - - it 'returns false when the user has a personal key' do - user_decorator = UserDecorator.new(User.new(personal_key: 'foo')) - session = {} - - expect(user_decorator.should_acknowledge_personal_key?(session)).to eq false - end - - it 'returns false if the user is loa3' do - user_decorator = UserDecorator.new(User.new) - session = { sp: { loa3: true } } - - expect(user_decorator.should_acknowledge_personal_key?(session)).to eq false - end - end - end - describe '#recent_events' do let!(:user) { create(:user, :signed_up, created_at: Time.zone.now - 100.days) } let(:decorated_user) { user.decorate } diff --git a/spec/features/idv/account_creation_spec.rb b/spec/features/idv/account_creation_spec.rb new file mode 100644 index 00000000000..a76067bc3cd --- /dev/null +++ b/spec/features/idv/account_creation_spec.rb @@ -0,0 +1,9 @@ +require 'rails_helper' + +describe 'LOA3 account creation' do + include IdvHelper + include SamlAuthHelper + + it_behaves_like 'creating an LOA3 account using authenticator app for 2FA', :saml + it_behaves_like 'creating an LOA3 account using authenticator app for 2FA', :oidc +end diff --git a/spec/policies/personal_key_for_new_user_policy_spec.rb b/spec/policies/personal_key_for_new_user_policy_spec.rb new file mode 100644 index 00000000000..27064f50b77 --- /dev/null +++ b/spec/policies/personal_key_for_new_user_policy_spec.rb @@ -0,0 +1,55 @@ +require 'rails_helper' + +describe PersonalKeyForNewUserPolicy do + describe '#show_personal_key_after_initial_2fa_setup?' do + context 'user has no personal key and made LOA1 request' do + it 'returns true' do + user = User.new + session = { sp: { loa3: false } } + policy = PersonalKeyForNewUserPolicy.new(user: user, session: session) + + expect(policy.show_personal_key_after_initial_2fa_setup?).to eq true + end + end + + context 'user has no personal key and visited the site directly' do + it 'returns true' do + user = User.new + session = {} + policy = PersonalKeyForNewUserPolicy.new(user: user, session: session) + + expect(policy.show_personal_key_after_initial_2fa_setup?).to eq true + end + end + + context 'user has a personal key' do + it 'returns false' do + user = User.new(personal_key: 'foo') + session = {} + policy = PersonalKeyForNewUserPolicy.new(user: user, session: session) + + expect(policy.show_personal_key_after_initial_2fa_setup?).to eq false + end + end + + context 'user does not have a personal key and made an LOA3 request' do + it 'returns false' do + user = User.new + session = { sp: { loa3: true } } + policy = PersonalKeyForNewUserPolicy.new(user: user, session: session) + + expect(policy.show_personal_key_after_initial_2fa_setup?).to eq false + end + end + + context 'user has a personal key and made an LOA3 request' do + it 'returns false' do + user = User.new(personal_key: 'foo') + session = { sp: { loa3: true } } + policy = PersonalKeyForNewUserPolicy.new(user: user, session: session) + + expect(policy.show_personal_key_after_initial_2fa_setup?).to eq false + end + end + end +end diff --git a/spec/support/features/session_helper.rb b/spec/support/features/session_helper.rb index fb84f47b6d0..5d8f458b1a2 100644 --- a/spec/support/features/session_helper.rb +++ b/spec/support/features/session_helper.rb @@ -394,7 +394,7 @@ def register_user(email = 'test@test.com') def confirm_email_and_password(email) allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) - click_link t('sign_up.registrations.create_account') + find_link(t('sign_up.registrations.create_account')).click submit_form_with_valid_email(email) click_confirmation_link_in_email(email) submit_form_with_valid_password diff --git a/spec/support/shared_examples/account_creation.rb b/spec/support/shared_examples/account_creation.rb index 141d16f4772..dde4eaf5b47 100644 --- a/spec/support/shared_examples/account_creation.rb +++ b/spec/support/shared_examples/account_creation.rb @@ -69,6 +69,39 @@ end end +shared_examples 'creating an LOA3 account using authenticator app for 2FA' do |sp| + it 'does not prompt for recovery code before IdV flow', email: true, idv_job: true do + visit_idp_from_sp_with_loa3(sp) + register_user_with_authenticator_app + fill_out_idv_jurisdiction_ok + click_idv_continue + fill_out_idv_form_ok + click_idv_continue + click_idv_continue + fill_out_phone_form_ok + click_idv_continue + choose_idv_otp_delivery_method_sms + click_submit_default + fill_in 'Password', with: Features::SessionHelper::VALID_PASSWORD + click_continue + click_acknowledge_personal_key + + if sp == :oidc + expect(page.response_headers['Content-Security-Policy']). + to(include('form-action \'self\' http://localhost:7654')) + end + + click_on t('forms.buttons.continue') + expect(current_url).to eq @saml_authn_request if sp == :saml + + if sp == :oidc + redirect_uri = URI(current_url) + + expect(redirect_uri.to_s).to start_with('http://localhost:7654/auth/result') + end + end +end + shared_examples 'creating an account using PIV/CAC for 2FA' do |sp| it 'redirects to the SP', email: true do allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) From e2042dc6ff0690d16c7bdf4382e9ceb9eb3f24f8 Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Fri, 7 Sep 2018 20:00:01 -0400 Subject: [PATCH 60/61] Revert removal of #2351 (redirect uri validation) This reverts commit a14b8905e6e33dfd337a9a037165dd2c1604ffcd. We reverted #2351 over a month ago to give agencies a chance to provide their logout redirect URIs. We gave them a deadline of August 23, which has passed two weeks ago. --- .reek | 2 - .../concerns/secure_headers_concern.rb | 7 +- .../authorization_controller.rb | 2 +- .../service_provider_session_decorator.rb | 25 +- app/forms/openid_connect_authorize_form.rb | 29 +- app/forms/openid_connect_logout_form.rb | 41 ++- app/services/openid_connect_redirector.rb | 97 ------- app/validators/redirect_uri_validator.rb | 32 +++ config/service_providers.yml | 3 +- .../openid_connect/logout_controller_spec.rb | 6 +- .../openid_connect/openid_connect_spec.rb | 2 +- .../redirect_uri_validation_spec.rb | 259 ++++++++++++++++++ .../saml/redirect_uri_validation_spec.rb | 27 ++ .../openid_connect_authorize_form_spec.rb | 4 +- spec/forms/openid_connect_logout_form_spec.rb | 4 +- .../openid_connect_redirector_spec.rb | 150 ---------- 16 files changed, 389 insertions(+), 301 deletions(-) delete mode 100644 app/services/openid_connect_redirector.rb create mode 100644 app/validators/redirect_uri_validator.rb create mode 100644 spec/features/openid_connect/redirect_uri_validation_spec.rb create mode 100644 spec/features/saml/redirect_uri_validation_spec.rb delete mode 100644 spec/services/openid_connect_redirector_spec.rb diff --git a/.reek b/.reek index 3371a700ea8..0241b866d87 100644 --- a/.reek +++ b/.reek @@ -3,7 +3,6 @@ Attribute: ControlParameter: exclude: - CustomDeviseFailureApp#i18n_message - - OpenidConnectRedirector#initialize - NoRetryJobs#call - PhoneFormatter#self.format - Users::TwoFactorAuthenticationController#invalid_phone_number @@ -85,7 +84,6 @@ TooManyConstants: TooManyInstanceVariables: exclude: - OpenidConnectAuthorizeForm - - OpenidConnectRedirector - Idv::VendorResult - CloudhsmKeyGenerator - CloudhsmKeySharer diff --git a/app/controllers/concerns/secure_headers_concern.rb b/app/controllers/concerns/secure_headers_concern.rb index 74a11e2078b..23da64ea5c3 100644 --- a/app/controllers/concerns/secure_headers_concern.rb +++ b/app/controllers/concerns/secure_headers_concern.rb @@ -5,17 +5,20 @@ def apply_secure_headers_override return if stored_url_for_user.blank? authorize_params = URIService.params(stored_url_for_user) - authorize_form = OpenidConnectAuthorizeForm.new(authorize_params) return unless authorize_form.valid? + redirect_uri = authorize_params[:redirect_uri] + override_content_security_policy_directives( - form_action: ["'self'", authorize_form.sp_redirect_uri].compact, + form_action: ["'self'", redirect_uri].compact, preserve_schemes: true ) end + private + def stored_url_for_user sp_session[:request_url] end diff --git a/app/controllers/openid_connect/authorization_controller.rb b/app/controllers/openid_connect/authorization_controller.rb index a7bfe5f4870..941c1cea532 100644 --- a/app/controllers/openid_connect/authorization_controller.rb +++ b/app/controllers/openid_connect/authorization_controller.rb @@ -53,7 +53,7 @@ def track_authorize_analytics(result) def apply_secure_headers_override override_content_security_policy_directives( - form_action: ["'self'", @authorize_form.sp_redirect_uri].compact, + form_action: ["'self'", authorization_params[:redirect_uri]].compact, preserve_schemes: true ) end diff --git a/app/decorators/service_provider_session_decorator.rb b/app/decorators/service_provider_session_decorator.rb index 8b01772dd58..46e2a470c72 100644 --- a/app/decorators/service_provider_session_decorator.rb +++ b/app/decorators/service_provider_session_decorator.rb @@ -84,8 +84,12 @@ def sp_agency end def sp_return_url - if sp.redirect_uris.present? && request_url.is_a?(String) && openid_connect_redirector.valid? - openid_connect_redirector.decline_redirect_uri + if sp.redirect_uris.present? && valid_oidc_request? + URIService.add_params( + oidc_redirect_uri, + error: 'access_denied', + state: request_params[:state] + ) else sp.return_to_sp_url end @@ -123,7 +127,20 @@ def request_url sp_session[:request_url] || service_provider_request.url end - def openid_connect_redirector - @_openid_connect_redirector ||= OpenidConnectRedirector.from_request_url(request_url) + def valid_oidc_request? + return false if request_url.nil? + authorize_form.valid? + end + + def authorize_form + OpenidConnectAuthorizeForm.new(request_params) + end + + def oidc_redirect_uri + request_params[:redirect_uri] + end + + def request_params + @request_params ||= URIService.params(request_url) end end diff --git a/app/forms/openid_connect_authorize_form.rb b/app/forms/openid_connect_authorize_form.rb index 4ee64191fd9..83596f7876b 100644 --- a/app/forms/openid_connect_authorize_form.rb +++ b/app/forms/openid_connect_authorize_form.rb @@ -2,6 +2,7 @@ class OpenidConnectAuthorizeForm include ActiveModel::Model include ActionView::Helpers::TranslationHelper + include RedirectUriValidator SIMPLE_ATTRS = %i[ client_id @@ -33,7 +34,6 @@ class OpenidConnectAuthorizeForm validate :validate_acr_values validate :validate_client_id - validate :validate_redirect_uri validate :validate_scope def initialize(params) @@ -43,10 +43,6 @@ def initialize(params) instance_variable_set(:"@#{key}", params[key]) end @prompt ||= 'select_account' - - @openid_connect_redirector = OpenidConnectRedirector.new( - redirect_uri: redirect_uri, service_provider: service_provider, state: state, errors: errors - ) end def submit @@ -59,10 +55,6 @@ def loa3_requested? loa == 3 end - def sp_redirect_uri - openid_connect_redirector.validated_input_redirect_uri - end - def service_provider @_service_provider ||= ServiceProvider.from_issuer(client_id) end @@ -79,13 +71,15 @@ def link_identity_to_service_provider(current_user, rails_session_id) end def success_redirect_uri + uri = redirect_uri unless errors.include?(:redirect_uri) code = identity&.session_uuid - openid_connect_redirector.success_redirect_uri(code: code) if code + + URIService.add_params(uri, code: code, state: state) if code end private - attr_reader :identity, :success, :openid_connect_redirector, :already_linked + attr_reader :identity, :success, :already_linked def requested_attributes @requested_attributes ||= @@ -107,10 +101,6 @@ def validate_client_id errors.add(:client_id, t('openid_connect.authorization.errors.bad_client_id')) end - def validate_redirect_uri - openid_connect_redirector.validate - end - def validate_scope return if scope.present? errors.add(:scope, t('openid_connect.authorization.errors.no_valid_scope')) @@ -141,7 +131,14 @@ def result_uri end def error_redirect_uri - openid_connect_redirector.error_redirect_uri + uri = redirect_uri unless errors.include?(:redirect_uri) + + URIService.add_params( + uri, + error: 'invalid_request', + error_description: errors.full_messages.join(' '), + state: state + ) end end # rubocop:enable Metrics/ClassLength diff --git a/app/forms/openid_connect_logout_form.rb b/app/forms/openid_connect_logout_form.rb index 1b21e5bd241..98bc7d2af4a 100644 --- a/app/forms/openid_connect_logout_form.rb +++ b/app/forms/openid_connect_logout_form.rb @@ -1,6 +1,7 @@ class OpenidConnectLogoutForm include ActiveModel::Model include ActionView::Helpers::TranslationHelper + include RedirectUriValidator ATTRS = %i[ id_token_hint @@ -16,7 +17,6 @@ class OpenidConnectLogoutForm validates :post_logout_redirect_uri, presence: true validates :state, presence: true, length: { minimum: RANDOM_VALUE_MINIMUM_LENGTH } - validate :validate_redirect_uri validate :validate_identity def initialize(params) @@ -25,7 +25,6 @@ def initialize(params) end @identity = load_identity - @openid_connect_redirector = build_openid_connect_redirector end def submit @@ -39,7 +38,6 @@ def submit private attr_reader :identity, - :openid_connect_redirector, :success def load_identity @@ -58,20 +56,6 @@ def identity_from_payload(payload) AgencyIdentityLinker.sp_identity_from_uuid_and_sp(uuid, sp) end - def build_openid_connect_redirector - OpenidConnectRedirector.new( - redirect_uri: post_logout_redirect_uri, - service_provider: service_provider, - state: state, - errors: errors, - error_attr: :post_logout_redirect_uri - ) - end - - def validate_redirect_uri - openid_connect_redirector.validate - end - def validate_identity errors.add(:id_token_hint, t('openid_connect.logout.errors.id_token_hint')) unless identity end @@ -90,10 +74,23 @@ def extra_analytics_attributes end def redirect_uri - if success - openid_connect_redirector.logout_redirect_uri - else - openid_connect_redirector.error_redirect_uri - end + success ? logout_redirect_uri : error_redirect_uri + end + + def logout_redirect_uri + uri = post_logout_redirect_uri unless errors.include?(:redirect_uri) + + URIService.add_params(uri, state: state) + end + + def error_redirect_uri + uri = post_logout_redirect_uri unless errors.include?(:redirect_uri) + + URIService.add_params( + uri, + error: 'invalid_request', + error_description: errors.full_messages.join(' '), + state: state + ) end end diff --git a/app/services/openid_connect_redirector.rb b/app/services/openid_connect_redirector.rb deleted file mode 100644 index 4e5d1b0ebbf..00000000000 --- a/app/services/openid_connect_redirector.rb +++ /dev/null @@ -1,97 +0,0 @@ -class OpenidConnectRedirector - include ActionView::Helpers::TranslationHelper - - def self.from_request_url(request_url) - params = URIService.params(request_url) - - new( - redirect_uri: params[:redirect_uri], - service_provider: ServiceProvider.from_issuer(params[:client_id]), - state: params[:state] - ) - end - - def initialize(redirect_uri:, service_provider:, state:, errors: nil, error_attr: :redirect_uri) - @redirect_uri = redirect_uri - @service_provider = service_provider - @state = state - @errors = errors || ActiveModel::Errors.new(self) - @error_attr = error_attr - end - - def valid? - validate - errors.blank? - end - - def validate - validate_redirect_uri - validate_redirect_uri_matches_sp_redirect_uri - end - - def success_redirect_uri(code:) - URIService.add_params(validated_input_redirect_uri, code: code, state: state) - end - - def decline_redirect_uri - URIService.add_params( - validated_input_redirect_uri, - error: 'access_denied', - state: state - ) - end - - def error_redirect_uri - URIService.add_params( - validated_input_redirect_uri, - error: 'invalid_request', - error_description: errors.full_messages.join(' '), - state: state - ) - end - - def logout_redirect_uri - URIService.add_params(validated_input_redirect_uri, state: state) - end - - def validated_input_redirect_uri - redirect_uri if redirect_uri_matches_sp_redirect_uri? - end - - private - - attr_reader :redirect_uri, :service_provider, :state, :errors, :error_attr - - def validate_redirect_uri - _uri = URI(redirect_uri) - rescue ArgumentError, URI::InvalidURIError - errors.add(error_attr, t('openid_connect.authorization.errors.redirect_uri_invalid')) - end - - def validate_redirect_uri_matches_sp_redirect_uri - return if redirect_uri_matches_sp_redirect_uri? - errors.add(error_attr, t('openid_connect.authorization.errors.redirect_uri_no_match')) - end - - # rubocop:disable Metrics/AbcSize - # rubocop:disable Metrics/CyclomaticComplexity - # rubocop:disable Metrics/MethodLength - def redirect_uri_matches_sp_redirect_uri? - return unless redirect_uri.present? && service_provider.active? - parsed_redirect_uri = URI(redirect_uri) - service_provider.redirect_uris.any? do |sp_redirect_uri| - parsed_sp_redirect_uri = URI(sp_redirect_uri) - - parsed_redirect_uri.scheme == parsed_sp_redirect_uri.scheme && - parsed_redirect_uri.port == parsed_sp_redirect_uri.port && - parsed_redirect_uri.host == parsed_sp_redirect_uri.host && - parsed_redirect_uri.path.start_with?(parsed_sp_redirect_uri.path) - end - rescue URI::Error - false - end - - # rubocop:enable Metrics/AbcSize - # rubocop:enable Metrics/CyclomaticComplexity - # rubocop:enable Metrics/MethodLength -end diff --git a/app/validators/redirect_uri_validator.rb b/app/validators/redirect_uri_validator.rb new file mode 100644 index 00000000000..a28c91816c8 --- /dev/null +++ b/app/validators/redirect_uri_validator.rb @@ -0,0 +1,32 @@ +module RedirectUriValidator + extend ActiveSupport::Concern + + included do + attr_reader :redirect_uri, :post_logout_redirect_uri, :service_provider + + validate :allowed_redirect_uri + end + + private + + def allowed_redirect_uri + return if any_registered_sp_redirect_uris_identical_to_the_requested_uri? + + errors.add(:redirect_uri, t('openid_connect.authorization.errors.redirect_uri_no_match')) + end + + def any_registered_sp_redirect_uris_identical_to_the_requested_uri? + service_provider.redirect_uris.any? do |sp_redirect_uri| + parsed_sp_redirect_uri = URI.parse(sp_redirect_uri) + + parsed_sp_redirect_uri == parsed_redirect_uri + end + rescue ArgumentError, URI::InvalidURIError + errors.add(:redirect_uri, t('openid_connect.authorization.errors.redirect_uri_invalid')) + end + + def parsed_redirect_uri + requested_uri = post_logout_redirect_uri || redirect_uri + URI.parse(requested_uri) + end +end diff --git a/config/service_providers.yml b/config/service_providers.yml index 5a9061eac7a..7ec97a5db43 100644 --- a/config/service_providers.yml +++ b/config/service_providers.yml @@ -48,6 +48,7 @@ test: 'urn:gov:gsa:openidconnect:test': redirect_uris: - 'gov.gsa.openidconnect.test://result' + - 'gov.gsa.openidconnect.test://result/logout' cert: 'saml_test_sp' friendly_name: 'Example iOS App' agency: '18F' @@ -58,7 +59,7 @@ test: 'urn:gov:gsa:openidconnect:sp:server': agency_id: 2 redirect_uris: - - 'http://localhost:7654/' + - 'http://localhost:7654/auth/result' - 'https://example.com' cert: 'saml_test_sp' friendly_name: 'Test SP' diff --git a/spec/controllers/openid_connect/logout_controller_spec.rb b/spec/controllers/openid_connect/logout_controller_spec.rb index 38500e8c69f..67bda6e2382 100644 --- a/spec/controllers/openid_connect/logout_controller_spec.rb +++ b/spec/controllers/openid_connect/logout_controller_spec.rb @@ -80,11 +80,15 @@ it 'tracks analytics' do stub_analytics + + errors = { + redirect_uri: [t('openid_connect.authorization.errors.redirect_uri_no_match')], + } expect(@analytics).to receive(:track_event). with(Analytics::LOGOUT_INITIATED, success: false, client_id: service_provider, - errors: hash_including(:post_logout_redirect_uri), + errors: errors, sp_initiated: true, oidc: true) diff --git a/spec/features/openid_connect/openid_connect_spec.rb b/spec/features/openid_connect/openid_connect_spec.rb index eeea3adcfa3..314a4ca0a65 100644 --- a/spec/features/openid_connect/openid_connect_spec.rb +++ b/spec/features/openid_connect/openid_connect_spec.rb @@ -437,7 +437,7 @@ def sign_in_get_id_token response_type: 'code', acr_values: Saml::Idp::Constants::LOA1_AUTHN_CONTEXT_CLASSREF, scope: 'openid email', - redirect_uri: 'gov.gsa.openidconnect.test://result/auth', + redirect_uri: 'gov.gsa.openidconnect.test://result', state: state, prompt: 'select_account', nonce: nonce, diff --git a/spec/features/openid_connect/redirect_uri_validation_spec.rb b/spec/features/openid_connect/redirect_uri_validation_spec.rb new file mode 100644 index 00000000000..2a3f4949044 --- /dev/null +++ b/spec/features/openid_connect/redirect_uri_validation_spec.rb @@ -0,0 +1,259 @@ +require 'rails_helper' + +describe 'redirect_uri validation' do + context 'when the redirect_uri in the request does not match one that is registered' do + it 'displays error instead of branded landing page' do + visit_idp_from_sp_with_loa1_with_disallowed_redirect_uri + current_host = URI.parse(page.current_url).host + + expect(current_host).to eq 'www.example.com' + expect(page). + to have_content t('openid_connect.authorization.errors.redirect_uri_no_match') + end + end + + context 'when the redirect_uri is not a valid URI' do + it 'displays error instead of branded landing page' do + visit_idp_from_sp_with_loa1_with_invalid_redirect_uri + current_host = URI.parse(page.current_url).host + + expect(current_host).to eq 'www.example.com' + expect(page). + to have_content t('openid_connect.authorization.errors.redirect_uri_invalid') + end + end + + context 'when the service_provider is not active' do + it 'displays error instead of branded landing page' do + visit_idp_from_inactive_sp + current_host = URI.parse(page.current_url).host + + expect(current_host).to eq 'www.example.com' + expect(page). + to have_content t('openid_connect.authorization.errors.bad_client_id') + end + end + + context 'when redirect_uri is present in params but the request is not from an SP' do + it 'does not provide a link to the redirect_uri' do + visit sign_up_start_path(request_id: '123', redirect_uri: 'evil.com') + + expect(page).to_not have_link t('links.back_to_sp') + + visit new_user_session_path(request_id: '123', redirect_uri: 'evil.com') + + expect(page).to_not have_link t('links.back_to_sp') + end + end + + context 'when new non-SP request with redirect_uri is made after initial SP request' do + it 'does not provide a link to the new redirect_uri' do + state = SecureRandom.hex + visit_idp_from_sp_with_loa1_with_valid_redirect_uri(state: state) + visit sign_up_start_path(request_id: '123', redirect_uri: 'evil.com') + sp_redirect_uri = "http://localhost:7654/auth/result?error=access_denied&state=#{state}" + + expect(page). + to have_link(t('links.back_to_sp', sp: 'Test SP'), href: sp_redirect_uri) + + visit new_user_session_path(request_id: '123', redirect_uri: 'evil.com') + + expect(page). + to have_link(t('links.back_to_sp', sp: 'Test SP'), href: sp_redirect_uri) + end + end + + context 'when the user is already signed in directly' do + it 'displays error instead of redirecting' do + sign_in_and_2fa_user + + visit_idp_from_inactive_sp + current_host = URI.parse(page.current_url).host + + expect(current_host).to eq 'www.example.com' + expect(page). + to have_content t('openid_connect.authorization.errors.bad_client_id') + + visit_idp_from_sp_with_loa1_with_invalid_redirect_uri + current_host = URI.parse(page.current_url).host + + expect(current_host).to eq 'www.example.com' + expect(page). + to have_content t('openid_connect.authorization.errors.redirect_uri_invalid') + + visit_idp_from_sp_with_loa1_with_disallowed_redirect_uri + current_host = URI.parse(page.current_url).host + + expect(current_host).to eq 'www.example.com' + expect(page). + to have_content t('openid_connect.authorization.errors.redirect_uri_no_match') + end + end + + context 'when the user is already signed in via an SP' do + it 'displays error instead of redirecting' do + allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) + user = create(:user, :signed_up) + visit_idp_from_sp_with_loa1_with_valid_redirect_uri + click_link t('links.sign_in') + fill_in_credentials_and_submit(user.email, user.password) + click_submit_default + click_continue + + visit_idp_from_inactive_sp + current_host = URI.parse(page.current_url).host + + expect(current_host).to eq 'www.example.com' + expect(page). + to have_content t('openid_connect.authorization.errors.bad_client_id') + + visit_idp_from_sp_with_loa1_with_invalid_redirect_uri + current_host = URI.parse(page.current_url).host + + expect(current_host).to eq 'www.example.com' + expect(page). + to have_content t('openid_connect.authorization.errors.redirect_uri_invalid') + + visit_idp_from_sp_with_loa1_with_disallowed_redirect_uri + current_host = URI.parse(page.current_url).host + + expect(current_host).to eq 'www.example.com' + expect(page). + to have_content t('openid_connect.authorization.errors.redirect_uri_no_match') + end + end + + context 'when the SP has multiple registered redirect_uris and the second one is requested' do + it 'considers the request valid and redirects to the one requested' do + allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) + user = create(:user, :signed_up) + visit_idp_from_sp_with_loa1_with_second_valid_redirect_uri + click_link t('links.sign_in') + fill_in_credentials_and_submit(user.email, user.password) + click_submit_default + click_continue + + redirect_host = URI.parse(current_url).host + redirect_scheme = URI.parse(current_url).scheme + + expect(redirect_host).to eq('example.com') + expect(redirect_scheme).to eq('https') + end + end + + context 'when the SP does not have any registered redirect_uris' do + it 'considers the request invalid and does not redirect if the user signs in' do + allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) + user = create(:user, :signed_up) + visit_idp_from_sp_that_does_not_have_redirect_uris + current_host = URI.parse(page.current_url).host + + expect(current_host).to eq 'www.example.com' + expect(page). + to have_content t('openid_connect.authorization.errors.redirect_uri_no_match') + + visit new_user_session_path + fill_in_credentials_and_submit(user.email, user.password) + click_submit_default + click_continue + + expect(page).to have_current_path account_path + end + end + + def visit_idp_from_sp_with_loa1_with_disallowed_redirect_uri(state: SecureRandom.hex) + client_id = 'urn:gov:gsa:openidconnect:sp:server' + nonce = SecureRandom.hex + + visit openid_connect_authorize_path( + client_id: client_id, + response_type: 'code', + acr_values: Saml::Idp::Constants::LOA1_AUTHN_CONTEXT_CLASSREF, + scope: 'openid email', + redirect_uri: 'https://example.com.evil.com/auth/result', + state: state, + prompt: 'select_account', + nonce: nonce + ) + end + + def visit_idp_from_sp_with_loa1_with_invalid_redirect_uri(state: SecureRandom.hex) + client_id = 'urn:gov:gsa:openidconnect:sp:server' + nonce = SecureRandom.hex + + visit openid_connect_authorize_path( + client_id: client_id, + response_type: 'code', + acr_values: Saml::Idp::Constants::LOA1_AUTHN_CONTEXT_CLASSREF, + scope: 'openid email', + redirect_uri: ':aaaa', + state: state, + prompt: 'select_account', + nonce: nonce + ) + end + + def visit_idp_from_inactive_sp(state: SecureRandom.hex) + client_id = 'inactive' + nonce = SecureRandom.hex + + visit openid_connect_authorize_path( + client_id: client_id, + response_type: 'code', + acr_values: Saml::Idp::Constants::LOA1_AUTHN_CONTEXT_CLASSREF, + scope: 'openid email', + redirect_uri: 'http://localhost:7654/auth/result', + state: state, + prompt: 'select_account', + nonce: nonce + ) + end + + def visit_idp_from_sp_with_loa1_with_valid_redirect_uri(state: SecureRandom.hex) + client_id = 'urn:gov:gsa:openidconnect:sp:server' + nonce = SecureRandom.hex + + visit openid_connect_authorize_path( + client_id: client_id, + response_type: 'code', + acr_values: Saml::Idp::Constants::LOA1_AUTHN_CONTEXT_CLASSREF, + scope: 'openid email', + redirect_uri: 'http://localhost:7654/auth/result', + state: state, + prompt: 'select_account', + nonce: nonce + ) + end + + def visit_idp_from_sp_with_loa1_with_second_valid_redirect_uri(state: SecureRandom.hex) + client_id = 'urn:gov:gsa:openidconnect:sp:server' + nonce = SecureRandom.hex + + visit openid_connect_authorize_path( + client_id: client_id, + response_type: 'code', + acr_values: Saml::Idp::Constants::LOA1_AUTHN_CONTEXT_CLASSREF, + scope: 'openid email', + redirect_uri: 'https://example.com', + state: state, + prompt: 'select_account', + nonce: nonce + ) + end + + def visit_idp_from_sp_that_does_not_have_redirect_uris(state: SecureRandom.hex) + client_id = 'http://test.host' + nonce = SecureRandom.hex + + visit openid_connect_authorize_path( + client_id: client_id, + response_type: 'code', + acr_values: Saml::Idp::Constants::LOA1_AUTHN_CONTEXT_CLASSREF, + scope: 'openid email', + redirect_uri: 'http://test.host', + state: state, + prompt: 'select_account', + nonce: nonce + ) + end +end diff --git a/spec/features/saml/redirect_uri_validation_spec.rb b/spec/features/saml/redirect_uri_validation_spec.rb new file mode 100644 index 00000000000..8b1c5199428 --- /dev/null +++ b/spec/features/saml/redirect_uri_validation_spec.rb @@ -0,0 +1,27 @@ +require 'rails_helper' + +describe 'redirect_uri validation' do + include SamlAuthHelper + + context 'when redirect_uri param is included in SAML request' do + it 'uses the return_to_sp_url URL and not the redirect_uri' do + allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) + user = create(:user, :signed_up) + visit api_saml_auth_path( + SAMLRequest: CGI.unescape(saml_request(saml_settings)), redirect_uri: 'http://evil.com' + ) + sp = ServiceProvider.find_by(issuer: 'http://localhost:3000') + + expect(page). + to have_link t('links.back_to_sp', sp: sp.friendly_name), href: sp.return_to_sp_url + + click_link t('links.sign_in') + fill_in_credentials_and_submit(user.email, user.password) + click_submit_default + click_continue + click_submit_default + + expect(current_url).to eq sp.acs_url + end + end +end diff --git a/spec/forms/openid_connect_authorize_form_spec.rb b/spec/forms/openid_connect_authorize_form_spec.rb index 6522593872d..4ff1fa7fd0e 100644 --- a/spec/forms/openid_connect_authorize_form_spec.rb +++ b/spec/forms/openid_connect_authorize_form_spec.rb @@ -180,9 +180,9 @@ end end - context 'with a redirect_uri that adds on to the registered redirect_uri' do + context 'with a redirect_uri that only partially matches any registered redirect_uri' do let(:redirect_uri) { 'gov.gsa.openidconnect.test://result/more/extra' } - it { expect(valid?).to eq(true) } + it { expect(valid?).to eq(false) } end end diff --git a/spec/forms/openid_connect_logout_form_spec.rb b/spec/forms/openid_connect_logout_form_spec.rb index 1a385df42ed..09f9914e908 100644 --- a/spec/forms/openid_connect_logout_form_spec.rb +++ b/spec/forms/openid_connect_logout_form_spec.rb @@ -137,7 +137,7 @@ it 'is not valid' do expect(valid?).to eq(false) - expect(form.errors[:post_logout_redirect_uri]).to be_present + expect(form.errors[:redirect_uri]).to be_present end end @@ -146,7 +146,7 @@ it 'is not valid' do expect(valid?).to eq(false) - expect(form.errors[:post_logout_redirect_uri]). + expect(form.errors[:redirect_uri]). to include(t('openid_connect.authorization.errors.redirect_uri_no_match')) end end diff --git a/spec/services/openid_connect_redirector_spec.rb b/spec/services/openid_connect_redirector_spec.rb deleted file mode 100644 index 0c1c621e956..00000000000 --- a/spec/services/openid_connect_redirector_spec.rb +++ /dev/null @@ -1,150 +0,0 @@ -require 'rails_helper' - -RSpec.describe OpenidConnectRedirector do - include Rails.application.routes.url_helpers - - let(:redirect_uri) { 'http://localhost:7654/' } - let(:state) { SecureRandom.hex } - let(:service_provider) { ServiceProvider.from_issuer('urn:gov:gsa:openidconnect:sp:server') } - let(:errors) { ActiveModel::Errors.new(nil) } - - subject(:redirector) do - OpenidConnectRedirector.new( - redirect_uri: redirect_uri, - service_provider: service_provider, - state: state, - errors: errors - ) - end - - describe '.from_request_url' do - it 'builds a redirector from an OpenID request_url' do - request_url = openid_connect_authorize_url( - client_id: service_provider.issuer, - redirect_uri: redirect_uri, - state: state - ) - - result = OpenidConnectRedirector.from_request_url(request_url) - - expect(result).to be_a(OpenidConnectRedirector) - expect(result.send(:redirect_uri)).to eq(redirect_uri) - expect(result.send(:service_provider)).to eq(service_provider) - expect(result.send(:state)).to eq(state) - end - end - - describe '#validate' do - context 'with a redirect_uri that spoofs a hostname' do - let(:redirect_uri) { 'https://example.com.evilish.com/' } - - it 'is invalid' do - redirector.validate - expect(errors[:redirect_uri]). - to include(t('openid_connect.authorization.errors.redirect_uri_no_match')) - end - end - - context 'with a valid redirect_uri' do - let(:redirect_uri) { 'http://localhost:7654/result/more/extra' } - it 'is valid' do - redirector.validate - expect(errors).to be_empty - end - end - - context 'with a malformed redirect_uri' do - let(:redirect_uri) { ':aaaa' } - it 'has errors' do - redirector.validate - expect(errors[:redirect_uri]). - to include(t('openid_connect.authorization.errors.redirect_uri_invalid')) - end - end - - context 'with a redirect_uri not registered to the service provider' do - let(:redirect_uri) { 'http://localhost:3000/test' } - it 'has errors' do - redirector.validate - expect(errors[:redirect_uri]). - to include(t('openid_connect.authorization.errors.redirect_uri_no_match')) - end - end - end - - describe '#success_redirect_uri' do - it 'adds the code and state to the URL' do - code = SecureRandom.hex - expect(redirector.success_redirect_uri(code: code)). - to eq(URIService.add_params(redirect_uri, code: code, state: state)) - end - end - - describe '#decline_redirect_uri' do - it 'adds the state and access_denied to the URL' do - expect(redirector.decline_redirect_uri). - to eq(URIService.add_params(redirect_uri, state: state, error: 'access_denied')) - end - end - - describe '#error_redirect_uri' do - before { expect(errors).to receive(:full_messages).and_return(['some attribute is missing']) } - - it 'adds the errors to the URL' do - expect(redirector.error_redirect_uri). - to eq(URIService.add_params(redirect_uri, - state: state, - error: 'invalid_request', - error_description: 'some attribute is missing')) - end - end - - describe '#logout_redirect_uri' do - it 'adds the state to the URL' do - expect(redirector.logout_redirect_uri). - to eq(URIService.add_params(redirect_uri, state: state)) - end - end - - describe '#validated_input_redirect_uri' do - let(:service_provider) { ServiceProvider.new(redirect_uris: redirect_uris, active: true) } - - subject(:validated_input_redirect_uri) { redirector.validated_input_redirect_uri } - - context 'when the service provider has no redirect URIs' do - let(:redirect_uris) { [] } - - it 'is nil' do - expect(validated_input_redirect_uri).to be_nil - end - end - - context 'when the service provider has 2 redirect URIs' do - let(:redirect_uris) { %w[http://localhost:1234/result my-app://result] } - - context 'when a URL matching the first redirect_uri is passed in' do - let(:redirect_uri) { 'http://localhost:1234/result/more' } - - it 'is that URL' do - expect(validated_input_redirect_uri).to eq(redirect_uri) - end - end - - context 'when a URL matching the second redirect_uri is passed in' do - let(:redirect_uri) { 'my-app://result/more' } - - it 'is that URL' do - expect(validated_input_redirect_uri).to eq(redirect_uri) - end - end - - context 'when a URL matching the neither redirect_uri is passed in' do - let(:redirect_uri) { 'https://example.com' } - - it 'is nil' do - expect(validated_input_redirect_uri).to be_nil - end - end - end - end -end From 43859665e7fe3ed2d3042a4d0a0b29fb77de318a Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Mon, 10 Sep 2018 09:24:54 -0500 Subject: [PATCH 61/61] Use strings values in webauthn config in application.yml (#2502) **Why**: Figaro prefers strings for the config values and will convert the booleans into strings. This reduces confusion. --- config/application.yml.example | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config/application.yml.example b/config/application.yml.example index b9c2cbae692..ac959f5d3cd 100644 --- a/config/application.yml.example +++ b/config/application.yml.example @@ -192,7 +192,7 @@ development: usps_upload_sftp_username: 'brady' usps_upload_sftp_password: 'test' usps_upload_token: '123ABC' - webauthn_enabled: true + webauthn_enabled: 'true' # These values serve as defaults for all production-like environments, which # includes *.identitysandbox.gov and *.login.gov. @@ -304,7 +304,7 @@ production: usps_upload_sftp_username: usps_upload_sftp_password: usps_upload_token: - webauthn_enabled: false + webauthn_enabled: 'false' test: aamva_cert_enabled: 'true' @@ -420,4 +420,4 @@ test: usps_upload_sftp_username: 'user' usps_upload_sftp_password: 'pass' usps_upload_token: 'test_token' - webauthn_enabled: true + webauthn_enabled: 'true'