From 3bafda642be793bbba810b80a9367fa18efccb5c Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Sun, 3 Jun 2018 15:56:43 -0400 Subject: [PATCH 01/40] LG-259 Password reset tokens should not leak to third party resources (GA and NR) **Why**: Password reset tokens on the url for the password reset page are leaked to third party sites. **How**: Implement redirection from the url with the token after validation to the url without it while saving the token in session. --- .../users/reset_passwords_controller.rb | 30 +++++++++++++++++-- .../users/reset_passwords_controller_spec.rb | 19 ++++++++---- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/app/controllers/users/reset_passwords_controller.rb b/app/controllers/users/reset_passwords_controller.rb index 1a327f05a41..348989fe823 100644 --- a/app/controllers/users/reset_passwords_controller.rb +++ b/app/controllers/users/reset_passwords_controller.rb @@ -1,6 +1,8 @@ +# rubocop:disable Metrics/ClassLength module Users class ResetPasswordsController < Devise::PasswordsController include RecaptchaConcern + before_action :prevent_token_leakage, only: %i[edit] def new @password_reset_email_form = PasswordResetEmailForm.new('') @@ -34,7 +36,7 @@ def edit # PUT /resource/password def update - self.resource = user_matching_token(user_params[:reset_password_token]) + self.resource = user_matching_token(session[:reset_password_token]) @reset_password_form = ResetPasswordForm.new(resource) @@ -94,7 +96,14 @@ def user_matching_token(token) end def token_user - @_token_user ||= User.with_reset_password_token(params[:reset_password_token]) + @_token_user ||= User.with_reset_password_token(session[:reset_password_token]) + end + + def validated_token_from_url + reset_password_token = params[:reset_password_token] + return if reset_password_token.blank? + user = User.with_reset_password_token(reset_password_token) + user ? reset_password_token : nil end def build_user @@ -110,12 +119,14 @@ def handle_successful_password_reset redirect_to new_user_session_url EmailNotifier.new(resource).send_password_changed_email + session.delete(:reset_password_token) end def handle_unsuccessful_password_reset(result) if result.errors[:reset_password_token].present? flash[:error] = t('devise.passwords.token_expired') redirect_to new_user_password_url + session.delete(:reset_password_token) return end @@ -136,5 +147,20 @@ def user_params params.require(:reset_password_form). permit(:password, :reset_password_token) end + + def redirect_without_token_url(token) + session[:reset_password_token] = token + redirect_to url_for + end + + def prevent_token_leakage + token = validated_token_from_url + redirect_without_token_url(token) if token + end + + def assert_reset_token_passed + # remove devise's default behavior + end end end +# rubocop:enable Metrics/ClassLength diff --git a/spec/controllers/users/reset_passwords_controller_spec.rb b/spec/controllers/users/reset_passwords_controller_spec.rb index b12736df20c..d31353be89d 100644 --- a/spec/controllers/users/reset_passwords_controller_spec.rb +++ b/spec/controllers/users/reset_passwords_controller_spec.rb @@ -33,6 +33,7 @@ allow(user).to receive(:reset_password_period_valid?).and_return(false) get :edit, params: { reset_password_token: 'foo' } + get :edit analytics_hash = { success: false, @@ -65,6 +66,9 @@ get :edit, params: { reset_password_token: 'foo' } + expect(response).to redirect_to edit_user_password_url + + get :edit expect(response).to render_template :edit expect(flash.keys).to be_empty expect(response.body).to match('') @@ -87,8 +91,9 @@ reset_password_token: db_confirmation_token ) - params = { password: 'short', reset_password_token: raw_reset_token } + params = { password: 'short' } + get :edit, params: { reset_password_token: raw_reset_token } put :update, params: { reset_password_form: params } analytics_hash = { @@ -122,7 +127,7 @@ reset_password_token: db_confirmation_token, reset_password_sent_at: Time.zone.now ) - form_params = { password: 'short', reset_password_token: raw_reset_token } + form_params = { password: 'short' } analytics_hash = { success: false, errors: { password: ['is too short (minimum is 9 characters)'] }, @@ -134,6 +139,7 @@ expect(@analytics).to receive(:track_event). with(Analytics::PASSWORD_RESET_PASSWORD, analytics_hash) + get :edit, params: { reset_password_token: raw_reset_token } put :update, params: { reset_password_form: form_params } expect(response).to render_template(:edit) @@ -161,8 +167,9 @@ stub_email_notifier(user) password = 'a really long passw0rd' - params = { password: password, reset_password_token: raw_reset_token } + params = { password: password } + get :edit, params: { reset_password_token: raw_reset_token } put :update, params: { reset_password_form: params } analytics_hash = { @@ -199,8 +206,9 @@ stub_email_notifier(user) + get :edit, params: { reset_password_token: raw_reset_token } password = 'a really long passw0rd' - params = { password: password, reset_password_token: raw_reset_token } + params = { password: password } put :update, params: { reset_password_form: params } @@ -239,8 +247,9 @@ stub_email_notifier(user) password = 'a really long passw0rd' - params = { password: password, reset_password_token: raw_reset_token } + params = { password: password } + get :edit, params: { reset_password_token: raw_reset_token } put :update, params: { reset_password_form: params } analytics_hash = { From 0b5405d35a2920ce4368c0a358a519601c7c9dba Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Mon, 4 Jun 2018 09:33:25 -0400 Subject: [PATCH 02/40] LG-320 Account History should log when their personal key is changed **Why**: A user should be aware of sensitive account changes **How**: Create a new new_personal_key event when a user changes their personal key. --- app/controllers/users/personal_keys_controller.rb | 1 + app/models/event.rb | 1 + config/locales/event_types/en.yml | 1 + config/locales/event_types/es.yml | 1 + config/locales/event_types/fr.yml | 1 + spec/controllers/users/personal_keys_controller_spec.rb | 2 ++ spec/features/account_history_spec.rb | 5 ++++- 7 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/controllers/users/personal_keys_controller.rb b/app/controllers/users/personal_keys_controller.rb index 49769184a37..0f903186138 100644 --- a/app/controllers/users/personal_keys_controller.rb +++ b/app/controllers/users/personal_keys_controller.rb @@ -26,6 +26,7 @@ def update def create user_session[:personal_key] = create_new_code analytics.track_event(Analytics::PROFILE_PERSONAL_KEY_CREATE) + Event.create(user_id: current_user.id, event_type: :new_personal_key) flash[:success] = t('notices.send_code.personal_key') if params[:resend].present? redirect_to manage_personal_key_url end diff --git a/app/models/event.rb b/app/models/event.rb index 0b414c6af97..1d9bedc78c7 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -13,6 +13,7 @@ class Event < ApplicationRecord usps_mail_sent: 9, piv_cac_enabled: 10, piv_cac_disabled: 11, + new_personal_key: 12, } validates :event_type, presence: true diff --git a/config/locales/event_types/en.yml b/config/locales/event_types/en.yml index 471a56a9a39..f87f2ea5404 100644 --- a/config/locales/event_types/en.yml +++ b/config/locales/event_types/en.yml @@ -9,6 +9,7 @@ en: authenticator_enabled: Authenticator app enabled eastern_timestamp: "%{timestamp} (Eastern)" email_changed: Email address changed + new_personal_key: Personal key changed password_changed: Password changed phone_changed: Phone number changed phone_confirmed: Phone confirmed diff --git a/config/locales/event_types/es.yml b/config/locales/event_types/es.yml index 4048ec62ed9..735cf988a5f 100644 --- a/config/locales/event_types/es.yml +++ b/config/locales/event_types/es.yml @@ -9,6 +9,7 @@ es: authenticator_enabled: App de autenticación permitido eastern_timestamp: "%{timestamp} (hora del Este)" email_changed: Email cambiado + new_personal_key: Clave personal cambiado password_changed: Contraseña cambiada phone_changed: Número de teléfono cambiado phone_confirmed: Teléfono confirmado diff --git a/config/locales/event_types/fr.yml b/config/locales/event_types/fr.yml index 301c2d58503..d37b9f45093 100644 --- a/config/locales/event_types/fr.yml +++ b/config/locales/event_types/fr.yml @@ -9,6 +9,7 @@ fr: authenticator_enabled: Application d'authentification activée eastern_timestamp: "%{timestamp} (Eastern)" email_changed: Adresse courriel modifiée + new_personal_key: Clé personnelle modifié password_changed: Mot de passe modifié phone_changed: Numéro de téléphone modifié phone_confirmed: Numéro de téléphone confirmé diff --git a/spec/controllers/users/personal_keys_controller_spec.rb b/spec/controllers/users/personal_keys_controller_spec.rb index 4850462943b..2eaab123ecd 100644 --- a/spec/controllers/users/personal_keys_controller_spec.rb +++ b/spec/controllers/users/personal_keys_controller_spec.rb @@ -121,6 +121,8 @@ expect(generator).to receive(:create) expect(@analytics).to receive(:track_event).with(Analytics::PROFILE_PERSONAL_KEY_CREATE) + expect(Event).to receive(:create). + with(user_id: subject.current_user.id, event_type: :new_personal_key) post :create diff --git a/spec/features/account_history_spec.rb b/spec/features/account_history_spec.rb index 0d36e2ea4c9..cfae7579584 100644 --- a/spec/features/account_history_spec.rb +++ b/spec/features/account_history_spec.rb @@ -32,6 +32,8 @@ let(:identity_with_link_timestamp) { identity_with_link.decorate.happened_at_in_words } let(:usps_mail_sent_again_timestamp) { usps_mail_sent_again_event.decorate.happened_at_in_words } let(:identity_without_link_timestamp) { identity_without_link.decorate.happened_at_in_words } + let(:new_personal_key_event) { create(:event, event_type: :new_personal_key, + user: user, created_at: Time.zone.now - 40.days) } before do sign_in_and_2fa_user(user) @@ -40,7 +42,7 @@ end scenario 'viewing account history' do - [account_created_event, usps_mail_sent_event, usps_mail_sent_again_event].each do |event| + [account_created_event, usps_mail_sent_event, usps_mail_sent_again_event, new_personal_key_event].each do |event| decorated_event = event.decorate expect(page).to have_content(decorated_event.event_type) expect(page).to have_content(decorated_event.happened_at_in_words) @@ -70,5 +72,6 @@ def build_account_history usps_mail_sent_again_event identity_with_link identity_without_link + new_personal_key_event end end From 50c7945916f9600ccc13957d7a144de3c1fad292 Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Mon, 4 Jun 2018 10:07:32 -0500 Subject: [PATCH 03/40] Fix check for hub installation in release script (#2218) **Why**: So that hub will be installed if it isn't as expected --- bin/release | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/release b/bin/release index f186f313120..ab0ebc620ed 100755 --- a/bin/release +++ b/bin/release @@ -132,7 +132,7 @@ def deploy_to_prod end def open_pr_for_int - run "brew install hub" unless `brew list -1 | grep -Fqx hub` + run "brew install hub" if `brew list -1 | grep -Fqx hub`.empty? run "hub pull-request -m \"Deploy #{RC_BRANCH} to int\" -b stages/int" end From 1f886f3fecd5b246b8267123313d378abbcbab12 Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Mon, 4 Jun 2018 15:42:55 -0500 Subject: [PATCH 04/40] Write to encrypted personal key digest (#2220) **Why**: So that we can start migrating personal keys to a system that works like passwords --- app/services/personal_key_generator.rb | 22 ++++++++++++++++---- spec/services/personal_key_generator_spec.rb | 16 ++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/app/services/personal_key_generator.rb b/app/services/personal_key_generator.rb index 81f9920f691..8c9d7b188bd 100644 --- a/app/services/personal_key_generator.rb +++ b/app/services/personal_key_generator.rb @@ -9,10 +9,8 @@ def initialize(user, length: 4) end def create - user.recovery_salt = Devise.friendly_token[0, 20] - user.recovery_cost = Figaro.env.scrypt_cost - @user_access_key = make_user_access_key(raw_personal_key) - user.personal_key = hashed_code + create_recovery_code + create_encrypted_recovery_code_digest user.save! raw_personal_key.tr(' ', '-') end @@ -42,6 +40,22 @@ def normalize(plaintext_code) attr_reader :user + def create_recovery_code + user.recovery_salt = Devise.friendly_token[0, 20] + user.recovery_cost = Figaro.env.scrypt_cost + @user_access_key = make_user_access_key(raw_personal_key) + user.personal_key = hashed_code + end + + def create_encrypted_recovery_code_digest + user.encrypted_recovery_code_digest = { + encryption_key: user_access_key.encryption_key, + encrypted_password: user_access_key.encrypted_password, + password_cost: user.recovery_cost, + password_salt: user.recovery_salt, + }.to_json + end + def encode_code(code:, length:, split:) decoded = Base32::Crockford.decode(code) Base32::Crockford.encode(decoded, length: length, split: split).tr('-', ' ') diff --git a/spec/services/personal_key_generator_spec.rb b/spec/services/personal_key_generator_spec.rb index a66b7afd046..d51acb649cd 100644 --- a/spec/services/personal_key_generator_spec.rb +++ b/spec/services/personal_key_generator_spec.rb @@ -40,6 +40,22 @@ def stub_random_phrase fourteen_letters_and_spaces_start_end_with_letter = /\A(\w+\-){13}\w+\z/ expect(generator.create).to match(fourteen_letters_and_spaces_start_end_with_letter) end + + it 'sets the encrypted recovery code digest' do + user = create(:user) + generator = PersonalKeyGenerator.new(user) + generator.create + + encrypted_recovery_code_data = JSON.parse(user.encrypted_recovery_code_digest, symbolize_names: true) + expect( + encrypted_recovery_code_data[:encryption_key] + ).to eq(user.personal_key.split('.').first) + expect( + encrypted_recovery_code_data[:encrypted_password] + ).to eq(user.personal_key.split('.').second) + expect(encrypted_recovery_code_data[:password_cost]).to eq(user.recovery_cost) + expect(encrypted_recovery_code_data[:password_salt]).to eq(user.recovery_salt) + end end describe '#verify' do From de7bcdde716b11360ce448eb43467f98dcc10a99 Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Wed, 6 Jun 2018 14:55:34 -0400 Subject: [PATCH 05/40] LG-351 Rate limiting does not work as expected in all cases of an email address **Why**: We strip and downcase emails upon account creation but we do not do the same when passing emails to the rate limiter (rack attack). **How**: Strip and downcase all emails before passing it to rack attack. Write a spec that tests for padding and mixed case. --- config/initializers/rack_attack.rb | 2 +- spec/requests/rack_attack_spec.rb | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index a2a39ec9380..c5d8931e4d8 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -111,7 +111,7 @@ def headers # increments the count), so requests below the limit are not blocked until # they hit the limit. At that point, `filter` will return true and block. user = req.params.fetch('user', {}) - email = user['email'].to_s + email = user['email'].to_s.downcase.strip email_fingerprint = Pii::Fingerprinter.fingerprint(email) if email.present? email_and_ip = "#{email_fingerprint}-#{req.remote_ip}" maxretry = Figaro.env.logins_per_email_and_ip_limit.to_i diff --git a/spec/requests/rack_attack_spec.rb b/spec/requests/rack_attack_spec.rb index f2172ff52c5..af52c0ed728 100644 --- a/spec/requests/rack_attack_spec.rb +++ b/spec/requests/rack_attack_spec.rb @@ -205,15 +205,15 @@ end end - context 'when the number of logins per email and ip is higher than the limit per period' do + context 'when number of logins per stripped/downcased email + ip is higher than limit per period' do it 'throttles with a custom response' do analytics = instance_double(Analytics) allow(Analytics).to receive(:new).and_return(analytics) allow(analytics).to receive(:track_event) - (logins_per_email_and_ip_limit + 1).times do + (logins_per_email_and_ip_limit + 1).times do |index| post '/', params: { - user: { email: 'test@example.com' }, + user: { email: index % 2 == 0 ? 'test@example.com' : ' test@EXAMPLE.com ' }, }, headers: { REMOTE_ADDR: '1.2.3.4' } end From 948bd2915e50581b62cdaea5adef917931a1b5f9 Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Sun, 3 Jun 2018 22:34:12 -0400 Subject: [PATCH 06/40] LG-311 Prevent bypassing account lockout when sending the SMS verification code **Why**: An attacker can abuse the SMS system and spam users **How**: Create an atomic increment on OtpRequestsTracker. Increment count before checking rate limit. Don't memoize the OtpRequestsTracker returned from the update. (3 fixes total). Testing will need to be done with Burp's repeater to hit idp with parallel requests. --- .../two_factor_authentication_controller.rb | 19 ++++++------- app/models/otp_requests_tracker.rb | 11 ++++++++ app/services/otp_rate_limiter.rb | 7 ++--- ...o_factor_authentication_controller_spec.rb | 2 +- .../two_factor_authentication/sign_in_spec.rb | 2 +- spec/models/otp_requests_tracker_spec.rb | 28 +++++++++++++++++-- spec/services/otp_rate_limiter_spec.rb | 4 +-- 7 files changed, 52 insertions(+), 21 deletions(-) diff --git a/app/controllers/users/two_factor_authentication_controller.rb b/app/controllers/users/two_factor_authentication_controller.rb index 10ca5e35c09..be08ca09c88 100644 --- a/app/controllers/users/two_factor_authentication_controller.rb +++ b/app/controllers/users/two_factor_authentication_controller.rb @@ -85,26 +85,25 @@ def reauthn_param def handle_valid_otp_delivery_preference(method) otp_rate_limiter.reset_count_and_otp_last_sent_at if decorated_user.no_longer_locked_out? - if otp_rate_limiter.exceeded_otp_send_limit? - otp_rate_limiter.lock_out_user - - return handle_too_many_otp_sends - end + return handle_too_many_otp_sends if exceeded_otp_send_limit? + otp_rate_limiter.increment + return handle_too_many_otp_sends if exceeded_otp_send_limit? send_user_otp(method) redirect_to login_two_factor_url(otp_delivery_preference: method, reauthn: reauthn?) end + def exceeded_otp_send_limit? + return otp_rate_limiter.lock_out_user if otp_rate_limiter.exceeded_otp_send_limit? + end + def send_user_otp(method) - otp_rate_limiter.increment current_user.create_direct_otp job = "#{method.capitalize}OtpSenderJob".constantize job_priority = confirmation_context? ? :perform_now : :perform_later - job.send(job_priority, - code: current_user.direct_otp, - phone: phone_to_deliver_to, - otp_created_at: current_user.direct_otp_sent_at.to_s) + job.send(job_priority, code: current_user.direct_otp, phone: phone_to_deliver_to, + otp_created_at: current_user.direct_otp_sent_at.to_s) end def user_selected_otp_delivery_preference diff --git a/app/models/otp_requests_tracker.rb b/app/models/otp_requests_tracker.rb index 2a9f263a218..166f67c5304 100644 --- a/app/models/otp_requests_tracker.rb +++ b/app/models/otp_requests_tracker.rb @@ -10,4 +10,15 @@ def self.find_or_create_with_phone(phone) retry unless (tries -= 1).zero? raise end + + def self.atomic_increment(id) + now = Time.zone.now + # The following sql offers superior db performance with one write and no locking overhead + query = sanitize_sql_array(['UPDATE otp_requests_trackers ' \ + 'SET otp_send_count = otp_send_count + 1,' \ + 'otp_last_sent_at = ?, updated_at = ? ' \ + 'WHERE id = ?', now, now, id]) + OtpRequestsTracker.connection.execute(query) + OtpRequestsTracker.find(id) + end end diff --git a/app/services/otp_rate_limiter.rb b/app/services/otp_rate_limiter.rb index b0287467a20..26e9b2dd9ae 100644 --- a/app/services/otp_rate_limiter.rb +++ b/app/services/otp_rate_limiter.rb @@ -16,7 +16,7 @@ def exceeded_otp_send_limit? end def max_requests_reached? - entry_for_current_phone.otp_send_count >= otp_maxretry_times + entry_for_current_phone.otp_send_count > otp_maxretry_times end def rate_limit_period_expired? @@ -32,9 +32,8 @@ def lock_out_user end def increment - entry_for_current_phone.otp_send_count += 1 - entry_for_current_phone.otp_last_sent_at = Time.zone.now - entry_for_current_phone.save! + # DO NOT MEMOIZE + @entry = OtpRequestsTracker.atomic_increment(entry_for_current_phone.id) end private diff --git a/spec/controllers/users/two_factor_authentication_controller_spec.rb b/spec/controllers/users/two_factor_authentication_controller_spec.rb index a190a83877c..a6cf11b3872 100644 --- a/spec/controllers/users/two_factor_authentication_controller_spec.rb +++ b/spec/controllers/users/two_factor_authentication_controller_spec.rb @@ -158,7 +158,7 @@ def index allow(OtpRateLimiter).to receive(:new).with(phone: @user.phone, user: @user). and_return(otp_rate_limiter) - expect(otp_rate_limiter).to receive(:exceeded_otp_send_limit?) + expect(otp_rate_limiter).to receive(:exceeded_otp_send_limit?).twice expect(otp_rate_limiter).to receive(:increment) get :send_code, params: { otp_delivery_selection_form: { otp_delivery_preference: 'sms' } } diff --git a/spec/features/two_factor_authentication/sign_in_spec.rb b/spec/features/two_factor_authentication/sign_in_spec.rb index 062d8ad7e0a..74ef66e1742 100644 --- a/spec/features/two_factor_authentication/sign_in_spec.rb +++ b/spec/features/two_factor_authentication/sign_in_spec.rb @@ -387,7 +387,7 @@ def submit_prefilled_otp_code rate_limited_phone = OtpRequestsTracker.find_by(phone_fingerprint: phone_fingerprint) expect(current_path).to eq otp_send_path - expect(rate_limited_phone.otp_send_count).to eq max_attempts + expect(rate_limited_phone.otp_send_count).to eq max_attempts + 1 visit account_path diff --git a/spec/models/otp_requests_tracker_spec.rb b/spec/models/otp_requests_tracker_spec.rb index 596f800a690..d4c59dfb49e 100644 --- a/spec/models/otp_requests_tracker_spec.rb +++ b/spec/models/otp_requests_tracker_spec.rb @@ -1,10 +1,10 @@ require 'rails_helper' describe OtpRequestsTracker do - describe '.find_or_create_with_phone' do - let(:phone) { '+1 703 555 1212' } - let(:phone_fingerprint) { Pii::Fingerprinter.fingerprint(phone) } + let(:phone) { '+1 703 555 1212' } + let(:phone_fingerprint) { Pii::Fingerprinter.fingerprint(phone) } + describe '.find_or_create_with_phone' do context 'match found' do it 'returns the existing record and does not change it' do OtpRequestsTracker.create( @@ -48,4 +48,26 @@ end end end + + describe '.atomic_increment' do + it 'updates otp_last_sent_at' do + old_ort = OtpRequestsTracker.create( + phone_fingerprint: phone_fingerprint, + otp_send_count: 3, + otp_last_sent_at: Time.zone.now - 1.hour + ) + new_ort = OtpRequestsTracker.atomic_increment(old_ort.id) + expect(new_ort.otp_last_sent_at).to be > old_ort.otp_last_sent_at + end + + it 'increments the otp_send_count' do + old_ort = OtpRequestsTracker.create( + phone_fingerprint: phone_fingerprint, + otp_send_count: 3, + otp_last_sent_at: Time.zone.now + ) + new_ort = OtpRequestsTracker.atomic_increment(old_ort.id) + expect(new_ort.otp_send_count - 1).to eq(old_ort.otp_send_count) + end + end end diff --git a/spec/services/otp_rate_limiter_spec.rb b/spec/services/otp_rate_limiter_spec.rb index f2e6b2eb349..481448a53ac 100644 --- a/spec/services/otp_rate_limiter_spec.rb +++ b/spec/services/otp_rate_limiter_spec.rb @@ -11,10 +11,10 @@ expect(otp_rate_limiter.exceeded_otp_send_limit?).to eq(false) end - it 'is true after maxretry_times attemps in findtime minutes' do + it 'is true after maxretry_times attemps +1 in findtime minutes' do expect(otp_rate_limiter.exceeded_otp_send_limit?).to eq(false) - Figaro.env.otp_delivery_blocklist_maxretry.to_i.times do + (Figaro.env.otp_delivery_blocklist_maxretry.to_i + 1).times do otp_rate_limiter.increment end From d1e78093c83d17e1e1f95b0581f1f15982ebfe31 Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Sun, 3 Jun 2018 17:56:40 -0400 Subject: [PATCH 07/40] LG-315 Can't submit personal key after typo **Why**: When a user attempts to confirm a personal key and mistypes the key, they receive an error and the continue button is disabled. **How**: The logic for form validation is setting the input box to invalid after a bad key is entered and the form remains invalid even after changing the input (hence why the button stays disabled). To have it behave as before we can simply remove the validation check that was added for enabling the button. --- app/javascript/app/form-validation.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/app/form-validation.js b/app/javascript/app/form-validation.js index 628eb58a4bd..69896083384 100644 --- a/app/javascript/app/form-validation.js +++ b/app/javascript/app/form-validation.js @@ -23,7 +23,7 @@ document.addEventListener('DOMContentLoaded', () => { if (elements.length !== 0) { [].forEach.call(elements, function(input) { input.addEventListener('input', function () { - if (buttons.length !== 0 && input.valid) { + if (buttons.length !== 0 && input.checkValidity()) { [].forEach.call(buttons, function(button) { if (button.disabled) { button.disabled = false; From 0d75e1615e3ef8a7db68246e2cd46523bdefd7d3 Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Thu, 7 Jun 2018 08:23:36 -0500 Subject: [PATCH 08/40] Upgrade sinatra (#2224) **Why**: To fix an XSS issue in previous version --- Gemfile.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index d7b1ca1e185..7d228f03869 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -415,7 +415,7 @@ GEM rack-headers_filter (0.0.1) rack-mini-profiler (1.0.0) rack (>= 1.2.0) - rack-protection (2.0.1) + rack-protection (2.0.2) rack rack-proxy (0.6.4) rack @@ -565,10 +565,10 @@ GEM json (>= 1.8, < 3) simplecov-html (~> 0.10.0) simplecov-html (0.10.0) - sinatra (2.0.1) + sinatra (2.0.2) mustermann (~> 1.0) rack (~> 2.0) - rack-protection (= 2.0.1) + rack-protection (= 2.0.2) tilt (~> 2.0) slim (3.0.9) temple (>= 0.7.6, < 0.9) From a4e00722d5c113934192102f4d2e02df89ed3272 Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Thu, 7 Jun 2018 08:25:15 -0500 Subject: [PATCH 09/40] Read Pii encryption salt/cost from ciphertext (#2223) **Why**: We are moving the salt/cost attributes into the ciphertexts for pii encryption and password and decoupling password verification from Pii encryption. This is the first step in this process. Up next is moving these into the password digest. Then we can drop these columns. --- .reek | 1 - app/models/profile.rb | 20 ++----- .../encryption/encryptors/pii_encryptor.rb | 57 ++++++++++++++----- app/services/pii/attributes.rb | 16 ++---- saml_20180607094309.txt | 0 .../users/sessions_controller_spec.rb | 4 +- .../encryptors/pii_encryptor_spec.rb | 23 ++++++-- spec/services/pii/attributes_spec.rb | 16 ++---- 8 files changed, 76 insertions(+), 61 deletions(-) create mode 100644 saml_20180607094309.txt diff --git a/.reek b/.reek index 2eb24e1fbf1..cd2da766a4a 100644 --- a/.reek +++ b/.reek @@ -69,7 +69,6 @@ LongParameterList: - Idv::ProoferJob#perform - Idv::VendorResult#initialize - JWT - - Pii::Attributes#self.new_from_encrypted RepeatedConditional: exclude: - Users::ResetPasswordsController diff --git a/app/models/profile.rb b/app/models/profile.rb index 4dea5a1b66c..98aeff5be39 100644 --- a/app/models/profile.rb +++ b/app/models/profile.rb @@ -33,39 +33,27 @@ def deactivate(reason) def decrypt_pii(password) Pii::Attributes.new_from_encrypted( encrypted_pii, - password: password, - salt: user.password_salt, - cost: user.password_cost + password: password ) end def recover_pii(personal_key) Pii::Attributes.new_from_encrypted( encrypted_pii_recovery, - password: personal_key, - salt: user.recovery_salt, - cost: user.recovery_cost + password: personal_key ) end def encrypt_pii(pii, password) ssn = pii.ssn self.ssn_signature = Pii::Fingerprinter.fingerprint(ssn) if ssn - self.encrypted_pii = pii.encrypted( - password: password, - salt: user.password_salt, - cost: user.password_cost - ) + self.encrypted_pii = pii.encrypted(password) encrypt_recovery_pii(pii) end def encrypt_recovery_pii(pii) personal_key = personal_key_generator.create - self.encrypted_pii_recovery = pii.encrypted( - password: personal_key_generator.normalize(personal_key), - salt: user.recovery_salt, - cost: user.recovery_cost - ) + self.encrypted_pii_recovery = pii.encrypted(personal_key_generator.normalize(personal_key)) @personal_key = personal_key end diff --git a/app/services/encryption/encryptors/pii_encryptor.rb b/app/services/encryption/encryptors/pii_encryptor.rb index d6cffd611d6..2dcb2ab529a 100644 --- a/app/services/encryption/encryptors/pii_encryptor.rb +++ b/app/services/encryption/encryptors/pii_encryptor.rb @@ -1,39 +1,66 @@ module Encryption module Encryptors class PiiEncryptor - include Pii::Encodable + Ciphertext = Struct.new(:encrypted_data, :salt, :cost) do + include Pii::Encodable + class << self + include Pii::Encodable + end - def initialize(password:, salt:, cost: nil) - cost ||= Figaro.env.scrypt_cost + def self.parse_from_string(ciphertext_string) + parsed_json = JSON.parse(ciphertext_string) + new(extract_encrypted_data(parsed_json), parsed_json['salt'], parsed_json['cost']) + rescue JSON::ParserError + raise Pii::EncryptionError, 'ciphertext is not valid JSON' + end + + def to_s + { + encrypted_data: encode(encrypted_data), + salt: salt, + cost: cost, + }.to_json + end + + def self.extract_encrypted_data(parsed_json) + encoded_encrypted_data = parsed_json['encrypted_data'] + raise Pii::EncryptionError, 'ciphertext invalid' unless valid_base64_encoding?( + encoded_encrypted_data + ) + decode(encoded_encrypted_data) + end + end + + def initialize(password) + @password = password @aes_cipher = Pii::Cipher.new @kms_client = KmsClient.new - @scrypt_password_digest = build_scrypt_password(password, salt, cost).digest end def encrypt(plaintext) + salt = Devise.friendly_token[0, 20] + cost = Figaro.env.scrypt_cost + aes_encryption_key = scrypt_password_digest(salt: salt, cost: cost) aes_encrypted_ciphertext = aes_cipher.encrypt(plaintext, aes_encryption_key) kms_encrypted_ciphertext = kms_client.encrypt(aes_encrypted_ciphertext) - encode(kms_encrypted_ciphertext) + Ciphertext.new(kms_encrypted_ciphertext, salt, cost).to_s end - def decrypt(ciphertext) - raise Pii::EncryptionError, 'ciphertext invalid' unless valid_base64_encoding?(ciphertext) - decoded_ciphertext = decode(ciphertext) - aes_encrypted_ciphertext = kms_client.decrypt(decoded_ciphertext) + def decrypt(ciphertext_string) + ciphertext = Ciphertext.parse_from_string(ciphertext_string) + aes_encrypted_ciphertext = kms_client.decrypt(ciphertext.encrypted_data) + aes_encryption_key = scrypt_password_digest(salt: ciphertext.salt, cost: ciphertext.cost) aes_cipher.decrypt(aes_encrypted_ciphertext, aes_encryption_key) end private - attr_reader :aes_cipher, :kms_client, :scrypt_password_digest + attr_reader :password, :aes_cipher, :kms_client - def build_scrypt_password(password, salt, cost) + def scrypt_password_digest(salt:, cost:) scrypt_salt = cost + OpenSSL::Digest::SHA256.hexdigest(salt) scrypted = SCrypt::Engine.hash_secret password, scrypt_salt, 32 - SCrypt::Password.new(scrypted) - end - - def aes_encryption_key + scrypt_password_digest = SCrypt::Password.new(scrypted).digest scrypt_password_digest[0...32] end end diff --git a/app/services/pii/attributes.rb b/app/services/pii/attributes.rb index 02bb5823631..0a4778b98b3 100644 --- a/app/services/pii/attributes.rb +++ b/app/services/pii/attributes.rb @@ -18,12 +18,8 @@ def self.new_from_hash(hash) attrs end - def self.new_from_encrypted(encrypted, password:, salt:, cost:) - encryptor = Encryption::Encryptors::PiiEncryptor.new( - password: password, - salt: salt, - cost: cost - ) + def self.new_from_encrypted(encrypted, password:) + encryptor = Encryption::Encryptors::PiiEncryptor.new(password) decrypted = encryptor.decrypt(encrypted) new_from_json(decrypted) end @@ -39,12 +35,8 @@ def initialize(*args) assign_all_members end - def encrypted(password:, salt:, cost:) - encryptor = Encryption::Encryptors::PiiEncryptor.new( - password: password, - salt: salt, - cost: cost - ) + def encrypted(password) + encryptor = Encryption::Encryptors::PiiEncryptor.new(password) encryptor.encrypt(to_json) end diff --git a/saml_20180607094309.txt b/saml_20180607094309.txt new file mode 100644 index 00000000000..e69de29bb2d diff --git a/spec/controllers/users/sessions_controller_spec.rb b/spec/controllers/users/sessions_controller_spec.rb index 84bf4734687..5414e70873b 100644 --- a/spec/controllers/users/sessions_controller_spec.rb +++ b/spec/controllers/users/sessions_controller_spec.rb @@ -271,7 +271,9 @@ it 'deactivates profile if not de-cryptable' do user = create(:user, :signed_up) profile = create(:profile, :active, :verified, user: user, pii: { ssn: '1234' }) - profile.update!(encrypted_pii: Base64.strict_encode64('nonsense')) + profile.update!( + encrypted_pii: { encrypted_data: Base64.strict_encode64('nonsense') }.to_json + ) stub_analytics analytics_hash = { diff --git a/spec/services/encryption/encryptors/pii_encryptor_spec.rb b/spec/services/encryption/encryptors/pii_encryptor_spec.rb index 033b8be58a8..6caa35066f6 100644 --- a/spec/services/encryption/encryptors/pii_encryptor_spec.rb +++ b/spec/services/encryption/encryptors/pii_encryptor_spec.rb @@ -2,10 +2,9 @@ describe Encryption::Encryptors::PiiEncryptor do let(:password) { 'password' } - let(:salt) { 'n-pepa' } let(:plaintext) { 'Oooh baby baby' } - subject { described_class.new(password: password, salt: salt) } + subject { described_class.new(password) } describe '#encrypt' do it 'returns encrypted text' do @@ -15,6 +14,9 @@ end it 'uses the user access key encryptor to encrypt the plaintext' do + salt = '0' * 20 + allow(Devise).to receive(:friendly_token).and_return(salt) + scrypt_digest = '1' * 64 scrypt_password = instance_double(SCrypt::Password) @@ -37,7 +39,11 @@ ciphertext = subject.encrypt(plaintext) - expect(ciphertext).to eq(expected_ciphertext) + expect(ciphertext).to eq({ + encrypted_data: expected_ciphertext, + salt: salt, + cost: '800$8$1$', + }.to_json) end end @@ -51,12 +57,15 @@ it 'requires the same password used for encrypt' do ciphertext = subject.encrypt(plaintext) - new_encryptor = described_class.new(password: 'This is not the passowrd', salt: salt) + new_encryptor = described_class.new('This is not the passowrd') expect { new_encryptor.decrypt(ciphertext) }.to raise_error Pii::EncryptionError end it 'uses layered AES and KMS to decrypt the contents' do + salt = '0' * 20 + allow(Devise).to receive(:friendly_token).and_return(salt) + scrypt_digest = '1' * 64 scrypt_password = instance_double(SCrypt::Password) @@ -75,7 +84,11 @@ with('aes_ciphertext', scrypt_digest[0...32]). and_return(plaintext) - result = subject.decrypt(Base64.strict_encode64('kms_ciphertext')) + result = subject.decrypt({ + encrypted_data: Base64.strict_encode64('kms_ciphertext'), + salt: salt, + cost: '800$8$1$', + }.to_json) expect(result).to eq(plaintext) end diff --git a/spec/services/pii/attributes_spec.rb b/spec/services/pii/attributes_spec.rb index 3a0e0e78850..2bc19b365fb 100644 --- a/spec/services/pii/attributes_spec.rb +++ b/spec/services/pii/attributes_spec.rb @@ -3,8 +3,6 @@ describe Pii::Attributes do # let(:user_access_key) { Encryption::UserAccessKey.new(password: 'sekrit', salt: SecureRandom.uuid) } let(:password) { 'I am the password' } - let(:salt) { 'I am the salt' } - let(:cost) { '800$8$1$' } describe '#new_from_hash' do it 'initializes from plain Hash' do @@ -34,20 +32,16 @@ describe '#new_from_encrypted' do it 'inflates from encrypted string' do orig_attrs = described_class.new_from_hash(first_name: 'Jane') - encrypted_pii = orig_attrs.encrypted(password: password, salt: salt, cost: cost) - pii_attrs = described_class.new_from_encrypted( - encrypted_pii, password: password, salt: salt, cost: cost - ) + encrypted_pii = orig_attrs.encrypted(password) + pii_attrs = described_class.new_from_encrypted(encrypted_pii, password: password) expect(pii_attrs.first_name).to eq 'Jane' end it 'allows deprecated attributes that are no longer added to the hash schema' do deprecated_atts = described_class.new_from_hash(otp: '123abc') - encrypted_pii = deprecated_atts.encrypted(password: password, salt: salt, cost: cost) - pii_attrs = described_class.new_from_encrypted( - encrypted_pii, password: password, salt: salt, cost: cost - ) + encrypted_pii = deprecated_atts.encrypted(password) + pii_attrs = described_class.new_from_encrypted(encrypted_pii, password: password) expect(pii_attrs[:otp]).to eq('123abc') end @@ -71,7 +65,7 @@ it 'returns the object as encrypted string' do pii_attrs = described_class.new_from_hash(first_name: 'Jane') - encrypted = pii_attrs.encrypted(password: password, salt: salt, cost: cost) + encrypted = pii_attrs.encrypted(password) expect(encrypted).to_not match 'Jane' end end From 14af7bc66d0286a08f2af4ebab158e98afe9870e Mon Sep 17 00:00:00 2001 From: Scott Weber Date: Thu, 12 Apr 2018 11:54:30 -0400 Subject: [PATCH 10/40] LG-162: Multiple 2FA options during registration **Why**: To give users more choice during account creation. The current options are: SMS, Voice, Authentication app. PIV/CAC will be added later in the menu, although it is already available if you know the URL. --- app/assets/images/2FA-sms.svg | 1 + app/assets/images/2FA-voice.svg | 1 + app/controllers/application_controller.rb | 2 +- .../concerns/unconfirmed_user_concern.rb | 2 +- .../users/phone_setup_controller.rb | 45 ++++ app/controllers/users/phones_controller.rb | 3 +- .../two_factor_authentication_controller.rb | 2 +- ..._factor_authentication_setup_controller.rb | 33 +-- app/forms/two_factor_options_form.rb | 42 ++++ app/forms/user_phone_form.rb | 12 +- app/javascript/app/form-field-format.js | 2 - .../modules/international-phone-formatter.js | 77 ------ .../app/phone-internationalization.js | 6 +- app/presenters/phone_setup_presenter.rb | 25 ++ app/services/analytics.rb | 2 + .../otp_delivery_preference_validator.rb | 16 +- .../_cancel_or_back_to_options.html.slim | 5 + app/views/users/phone_setup/index.html.slim | 27 +++ app/views/users/phones/edit.html.slim | 15 +- .../new.html.slim | 2 +- app/views/users/shared/_phone_input.html.slim | 16 -- app/views/users/totp_setup/new.html.slim | 3 +- .../index.html.slim | 48 +++- config/locales/devise/en.yml | 20 +- config/locales/devise/es.yml | 21 +- config/locales/devise/fr.yml | 21 +- config/locales/forms/en.yml | 2 + config/locales/forms/es.yml | 2 + config/locales/forms/fr.yml | 2 + config/locales/titles/en.yml | 3 + config/locales/titles/es.yml | 3 + config/locales/titles/fr.yml | 3 + config/routes.rb | 6 +- .../application_controller_spec.rb | 2 +- .../users/phone_setup_controller_spec.rb | 194 +++++++++++++++ ...o_factor_authentication_controller_spec.rb | 2 +- ...or_authentication_setup_controller_spec.rb | 220 ++++++++---------- .../features/accessibility/user_pages_spec.rb | 8 + spec/features/saml/loa1_sso_spec.rb | 2 +- spec/features/saml/saml_spec.rb | 5 +- .../remember_device_spec.rb | 1 + .../two_factor_authentication/sign_in_spec.rb | 111 ++++----- .../users/regenerate_personal_key_spec.rb | 3 +- spec/features/users/sign_up_spec.rb | 6 +- spec/features/users/user_edit_spec.rb | 4 +- .../visitors/email_confirmation_spec.rb | 2 +- .../visitors/password_recovery_spec.rb | 4 +- .../visitors/phone_confirmation_spec.rb | 6 +- spec/forms/two_factor_options_form_spec.rb | 55 +++++ spec/forms/user_phone_form_spec.rb | 83 ++++++- spec/support/features/session_helper.rb | 11 +- .../index.html.slim_spec.rb | 11 +- .../views/users/phones/edit.html.slim_spec.rb | 1 + .../new.html.slim_spec.rb | 32 +++ .../users/totp_setup/new.html.slim_spec.rb | 48 +++- 55 files changed, 913 insertions(+), 368 deletions(-) create mode 100644 app/assets/images/2FA-sms.svg create mode 100644 app/assets/images/2FA-voice.svg create mode 100644 app/controllers/users/phone_setup_controller.rb create mode 100644 app/forms/two_factor_options_form.rb delete mode 100644 app/javascript/app/modules/international-phone-formatter.js create mode 100644 app/presenters/phone_setup_presenter.rb create mode 100644 app/views/shared/_cancel_or_back_to_options.html.slim create mode 100644 app/views/users/phone_setup/index.html.slim delete mode 100644 app/views/users/shared/_phone_input.html.slim create mode 100644 spec/controllers/users/phone_setup_controller_spec.rb create mode 100644 spec/forms/two_factor_options_form_spec.rb rename spec/views/{two_factor_authentication_setup => phone_setup}/index.html.slim_spec.rb (50%) create mode 100644 spec/views/users/piv_cac_authentication_setup/new.html.slim_spec.rb diff --git a/app/assets/images/2FA-sms.svg b/app/assets/images/2FA-sms.svg new file mode 100644 index 00000000000..07817c9c190 --- /dev/null +++ b/app/assets/images/2FA-sms.svg @@ -0,0 +1 @@ +2FA-text-message \ No newline at end of file diff --git a/app/assets/images/2FA-voice.svg b/app/assets/images/2FA-voice.svg new file mode 100644 index 00000000000..951120fb53a --- /dev/null +++ b/app/assets/images/2FA-voice.svg @@ -0,0 +1 @@ +2FA-phone-call \ No newline at end of file diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 27a4cd2ea96..aa469a2e397 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -164,7 +164,7 @@ def confirm_two_factor_authenticated end def prompt_to_set_up_2fa - redirect_to phone_setup_url + redirect_to two_factor_options_url end def prompt_to_enter_otp diff --git a/app/controllers/concerns/unconfirmed_user_concern.rb b/app/controllers/concerns/unconfirmed_user_concern.rb index 544dd5b4a47..432c1ee418e 100644 --- a/app/controllers/concerns/unconfirmed_user_concern.rb +++ b/app/controllers/concerns/unconfirmed_user_concern.rb @@ -43,7 +43,7 @@ def after_confirmation_url_for(user) elsif user.two_factor_enabled? account_url else - phone_setup_url + two_factor_options_url end end diff --git a/app/controllers/users/phone_setup_controller.rb b/app/controllers/users/phone_setup_controller.rb new file mode 100644 index 00000000000..4fd8c903220 --- /dev/null +++ b/app/controllers/users/phone_setup_controller.rb @@ -0,0 +1,45 @@ +module Users + class PhoneSetupController < ApplicationController + include UserAuthenticator + include PhoneConfirmation + + before_action :authenticate_user + before_action :authorize_phone_setup + + def index + @user_phone_form = UserPhoneForm.new(current_user) + @presenter = PhoneSetupPresenter.new(current_user.otp_delivery_preference) + analytics.track_event(Analytics::USER_REGISTRATION_PHONE_SETUP_VISIT) + end + + def create + @user_phone_form = UserPhoneForm.new(current_user) + @presenter = PhoneSetupPresenter.new(current_user.otp_delivery_preference) + result = @user_phone_form.submit(user_phone_form_params) + analytics.track_event(Analytics::MULTI_FACTOR_AUTH_PHONE_SETUP, result.to_h) + + if result.success? + prompt_to_confirm_phone(phone: @user_phone_form.phone) + else + render :index + end + end + + private + + def authorize_phone_setup + if user_fully_authenticated? + redirect_to account_url + elsif current_user.two_factor_enabled? + redirect_to user_two_factor_authentication_url + end + end + + def user_phone_form_params + params.require(:user_phone_form).permit( + :international_code, + :phone + ) + end + end +end diff --git a/app/controllers/users/phones_controller.rb b/app/controllers/users/phones_controller.rb index d6723c4a15d..5db18b67791 100644 --- a/app/controllers/users/phones_controller.rb +++ b/app/controllers/users/phones_controller.rb @@ -6,11 +6,12 @@ class PhonesController < ReauthnRequiredController def edit @user_phone_form = UserPhoneForm.new(current_user) + @presenter = PhoneSetupPresenter.new(current_user.otp_delivery_preference) end def update @user_phone_form = UserPhoneForm.new(current_user) - + @presenter = PhoneSetupPresenter.new(current_user) if @user_phone_form.submit(user_params).success? process_updates bypass_sign_in current_user diff --git a/app/controllers/users/two_factor_authentication_controller.rb b/app/controllers/users/two_factor_authentication_controller.rb index be08ca09c88..1947ab7941c 100644 --- a/app/controllers/users/two_factor_authentication_controller.rb +++ b/app/controllers/users/two_factor_authentication_controller.rb @@ -10,7 +10,7 @@ def show elsif current_user.two_factor_enabled? validate_otp_delivery_preference_and_send_code else - redirect_to phone_setup_url + redirect_to two_factor_options_url end end diff --git a/app/controllers/users/two_factor_authentication_setup_controller.rb b/app/controllers/users/two_factor_authentication_setup_controller.rb index a6e1d294e3b..9501da62289 100644 --- a/app/controllers/users/two_factor_authentication_setup_controller.rb +++ b/app/controllers/users/two_factor_authentication_setup_controller.rb @@ -1,21 +1,19 @@ module Users class TwoFactorAuthenticationSetupController < ApplicationController include UserAuthenticator - include PhoneConfirmation - before_action :authorize_otp_setup before_action :authenticate_user + before_action :authorize_2fa_setup def index - @user_phone_form = UserPhoneForm.new(current_user) - analytics.track_event(Analytics::USER_REGISTRATION_PHONE_SETUP_VISIT) + @two_factor_options_form = TwoFactorOptionsForm.new(current_user) + analytics.track_event(Analytics::USER_REGISTRATION_2FA_SETUP_VISIT) end - def set - @user_phone_form = UserPhoneForm.new(current_user) - result = @user_phone_form.submit(params[:user_phone_form]) - - analytics.track_event(Analytics::MULTI_FACTOR_AUTH_PHONE_SETUP, result.to_h) + def create + @two_factor_options_form = TwoFactorOptionsForm.new(current_user) + result = @two_factor_options_form.submit(two_factor_options_form_params) + analytics.track_event(Analytics::USER_REGISTRATION_2FA_SETUP, result.to_h) if result.success? process_valid_form @@ -26,16 +24,25 @@ def set private - def authorize_otp_setup + def authorize_2fa_setup if user_fully_authenticated? - redirect_to(request.referer || root_url) - elsif current_user&.two_factor_enabled? + redirect_to account_url + elsif current_user.two_factor_enabled? redirect_to user_two_factor_authentication_url end end def process_valid_form - prompt_to_confirm_phone(phone: @user_phone_form.phone) + case @two_factor_options_form.selection + when 'sms', 'voice' + redirect_to phone_setup_url + when 'auth_app' + redirect_to authenticator_setup_url + end + end + + def two_factor_options_form_params + params.require(:two_factor_options_form).permit(:selection) end end end diff --git a/app/forms/two_factor_options_form.rb b/app/forms/two_factor_options_form.rb new file mode 100644 index 00000000000..5473549adb1 --- /dev/null +++ b/app/forms/two_factor_options_form.rb @@ -0,0 +1,42 @@ +class TwoFactorOptionsForm + include ActiveModel::Model + + attr_reader :selection + + validates :selection, inclusion: { in: %w[voice sms auth_app piv_cac] } + + def initialize(user) + self.user = user + end + + def submit(params) + self.selection = params[:selection] + + success = valid? + + update_otp_delivery_preference_for_user if success && user_needs_updating? + + FormResponse.new(success: success, errors: errors.messages, extra: extra_analytics_attributes) + end + + private + + attr_accessor :user + attr_writer :selection + + def extra_analytics_attributes + { + selection: selection, + } + end + + def user_needs_updating? + return false unless %w[voice sms].include?(selection) + selection != user.otp_delivery_preference + end + + def update_otp_delivery_preference_for_user + user_attributes = { otp_delivery_preference: selection } + UpdateUser.new(user: user, attributes: user_attributes).call + end +end diff --git a/app/forms/user_phone_form.rb b/app/forms/user_phone_form.rb index 8a3c0632fd3..fca174eebc3 100644 --- a/app/forms/user_phone_form.rb +++ b/app/forms/user_phone_form.rb @@ -3,6 +3,8 @@ class UserPhoneForm include FormPhoneValidator include OtpDeliveryPreferenceValidator + validates :otp_delivery_preference, inclusion: { in: %w[voice sms] } + attr_accessor :phone, :international_code, :otp_delivery_preference def initialize(user) @@ -16,9 +18,10 @@ def submit(params) ingest_submitted_params(params) success = valid? - self.phone = submitted_phone unless success - update_otp_delivery_preference_for_user if otp_delivery_preference_changed? && success + + update_otp_delivery_preference_for_user if + success && otp_delivery_preference.present? && otp_delivery_preference_changed? FormResponse.new(success: success, errors: errors.messages, extra: extra_analytics_attributes) end @@ -44,7 +47,10 @@ def ingest_submitted_params(params) submitted_phone, country_code: international_code ) - self.otp_delivery_preference = params[:otp_delivery_preference] + + tfa_prefs = params[:otp_delivery_preference] + + self.otp_delivery_preference = tfa_prefs if tfa_prefs end def otp_delivery_preference_changed? diff --git a/app/javascript/app/form-field-format.js b/app/javascript/app/form-field-format.js index 8f13ce3e031..b28edc7f89e 100644 --- a/app/javascript/app/form-field-format.js +++ b/app/javascript/app/form-field-format.js @@ -1,6 +1,5 @@ import { SocialSecurityNumberFormatter, TextField } from 'field-kit'; import DateFormatter from './modules/date-formatter'; -import InternationalPhoneFormatter from './modules/international-phone-formatter'; import NumericFormatter from './modules/numeric-formatter'; import PersonalKeyFormatter from './modules/personal-key-formatter'; import USPhoneFormatter from './modules/us-phone-formatter'; @@ -11,7 +10,6 @@ function formatForm() { const formats = [ ['.dob', new DateFormatter()], ['.mfa', new NumericFormatter()], - ['.phone', new InternationalPhoneFormatter()], ['.us-phone', new USPhoneFormatter()], ['.personal-key', new PersonalKeyFormatter()], ['.ssn', new SocialSecurityNumberFormatter()], diff --git a/app/javascript/app/modules/international-phone-formatter.js b/app/javascript/app/modules/international-phone-formatter.js deleted file mode 100644 index 6f4376a3a46..00000000000 --- a/app/javascript/app/modules/international-phone-formatter.js +++ /dev/null @@ -1,77 +0,0 @@ -import { Formatter } from 'field-kit'; -import { asYouType as AsYouType } from 'libphonenumber-js'; - -const INTERNATIONAL_CODE_REGEX = /^\+\d{1,3} /; - -const fixCountryCodeSpacing = (text, countryCode) => { - // If the text is `+123456`, make it `+123 456` - if (text[countryCode.length + 1] !== ' ') { - return text.replace(`+${countryCode}`, `+${countryCode} `); - } - return text; -}; - -const getFormattedTextData = (text) => { - if (text === '1') { - text = '+1'; - } - - const asYouType = new AsYouType('US'); - let formattedText = asYouType.input(text); - const countryCode = asYouType.country_phone_code; - - if (asYouType.country_phone_code) { - formattedText = fixCountryCodeSpacing(formattedText, countryCode); - } - - return { - text: formattedText, - template: asYouType.template, - countryCode, - }; -}; - -const changeRemovesInternationalCode = (current, previous) => { - if (previous.text.match(INTERNATIONAL_CODE_REGEX) && - !current.text.match(INTERNATIONAL_CODE_REGEX) - ) { - return true; - } - return false; -}; - -const cursorPosition = (formattedTextData) => { - // If the text is `(23 )` the cursor goes after the 3 - const match = formattedTextData.text.match(/\d[^\d]*$/); - if (match) { - return match.index + 1; - } - return formattedTextData.text.length + 1; -}; - -class InternationalPhoneFormatter extends Formatter { - format(text) { - const formattedTextData = getFormattedTextData(text); - return super.format(formattedTextData.text); - } - - // eslint-disable-next-line class-methods-use-this - parse(text) { - return text.replace(/[^\d+]/g, ''); - } - - isChangeValid(change, error) { - const formattedTextData = getFormattedTextData(change.proposed.text); - const previousFormattedTextData = getFormattedTextData(change.current.text); - - if (changeRemovesInternationalCode(formattedTextData, previousFormattedTextData)) { - return false; - } - - change.proposed.text = formattedTextData.text; - change.proposed.selectedRange.start = cursorPosition(formattedTextData); - return super.isChangeValid(change, error); - } -} - -export default InternationalPhoneFormatter; diff --git a/app/javascript/app/phone-internationalization.js b/app/javascript/app/phone-internationalization.js index 167b2c3c618..5aa75d8ab21 100644 --- a/app/javascript/app/phone-internationalization.js +++ b/app/javascript/app/phone-internationalization.js @@ -76,7 +76,7 @@ const updateOTPDeliveryMethods = () => { return; } - const phoneInput = document.querySelector('[data-international-phone-form] .phone') || document.querySelector('[data-international-phone-form] .new-phone'); + const phoneInput = document.querySelector('[data-international-phone-form] .phone'); const phoneLabel = phoneRadio.parentNode.parentNode; const deliveryMethodHint = document.querySelector('#otp_delivery_preference_instruction'); const optPhoneLabelInfo = document.querySelector('#otp_phone_label_info'); @@ -111,7 +111,7 @@ const updateInternationalCodeInPhone = (phone, newCode) => { }; const updateInternationalCodeInput = () => { - const phoneInput = document.querySelector('[data-international-phone-form] .phone') || document.querySelector('[data-international-phone-form] .new-phone'); + const phoneInput = document.querySelector('[data-international-phone-form] .phone'); const phone = phoneInput.value; const inputInternationalCode = internationalCodeFromPhone(phone); const selectedInternationalCode = selectedInternationCodeOption().dataset.countryCode; @@ -122,7 +122,7 @@ const updateInternationalCodeInput = () => { }; document.addEventListener('DOMContentLoaded', () => { - const phoneInput = document.querySelector('[data-international-phone-form] .phone') || document.querySelector('[data-international-phone-form] .new-phone'); + const phoneInput = document.querySelector('[data-international-phone-form] .phone'); const codeInput = document.querySelector('[data-international-phone-form] .international-code'); if (phoneInput) { phoneInput.addEventListener('countryChange', updateOTPDeliveryMethods); diff --git a/app/presenters/phone_setup_presenter.rb b/app/presenters/phone_setup_presenter.rb new file mode 100644 index 00000000000..f6b55f8db5b --- /dev/null +++ b/app/presenters/phone_setup_presenter.rb @@ -0,0 +1,25 @@ +class PhoneSetupPresenter + include ActionView::Helpers::TranslationHelper + + attr_reader :otp_delivery_preference + + def initialize(otp_delivery_preference) + @otp_delivery_preference = otp_delivery_preference + end + + def heading + t("titles.phone_setup.#{otp_delivery_preference}") + end + + def label + t("devise.two_factor_authentication.phone_#{otp_delivery_preference}_label") + end + + def info + t("devise.two_factor_authentication.phone_#{otp_delivery_preference}_info_html") + end + + def image + "2FA-#{otp_delivery_preference}.svg" + end +end diff --git a/app/services/analytics.rb b/app/services/analytics.rb index 273416409d4..4bbd34ba5fe 100644 --- a/app/services/analytics.rb +++ b/app/services/analytics.rb @@ -109,6 +109,8 @@ def browser USER_REGISTRATION_EMAIL_CONFIRMATION_RESEND = 'User Registration: Email Confirmation requested due to invalid token'.freeze USER_REGISTRATION_ENTER_EMAIL_VISIT = 'User Registration: enter email visited'.freeze USER_REGISTRATION_INTRO_VISIT = 'User Registration: intro visited'.freeze + USER_REGISTRATION_2FA_SETUP = 'User Registration: 2FA Setup'.freeze + USER_REGISTRATION_2FA_SETUP_VISIT = 'User Registration: 2FA Setup visited'.freeze USER_REGISTRATION_PHONE_SETUP_VISIT = 'User Registration: phone setup visited'.freeze USER_REGISTRATION_PERSONAL_KEY_VISIT = 'User Registration: personal key visited'.freeze USER_REGISTRATION_PIV_CAC_DISABLED = 'User Registration: piv cac disabled'.freeze diff --git a/app/validators/otp_delivery_preference_validator.rb b/app/validators/otp_delivery_preference_validator.rb index b6f8ae643e2..42a22dc404f 100644 --- a/app/validators/otp_delivery_preference_validator.rb +++ b/app/validators/otp_delivery_preference_validator.rb @@ -5,16 +5,26 @@ module OtpDeliveryPreferenceValidator validate :otp_delivery_preference_supported end + def otp_delivery_preference_supported? + return true unless otp_delivery_preference == 'voice' + !phone_number_capabilities.sms_only? + end + def otp_delivery_preference_supported - capabilities = PhoneNumberCapabilities.new(phone) - return unless otp_delivery_preference == 'voice' && capabilities.sms_only? + return if otp_delivery_preference_supported? errors.add( :phone, I18n.t( 'devise.two_factor_authentication.otp_delivery_preference.phone_unsupported', - location: capabilities.unsupported_location + location: phone_number_capabilities.unsupported_location ) ) end + + private + + def phone_number_capabilities + @phone_number_capabilities ||= PhoneNumberCapabilities.new(phone) + end end diff --git a/app/views/shared/_cancel_or_back_to_options.html.slim b/app/views/shared/_cancel_or_back_to_options.html.slim new file mode 100644 index 00000000000..82652316b46 --- /dev/null +++ b/app/views/shared/_cancel_or_back_to_options.html.slim @@ -0,0 +1,5 @@ +.mt2.pt1.border-top +- if user_fully_authenticated? + = link_to cancel_link_text, account_path, class: 'h5' +- else + = link_to t('devise.two_factor_authentication.two_factor_choice_cancel'), two_factor_options_path diff --git a/app/views/users/phone_setup/index.html.slim b/app/views/users/phone_setup/index.html.slim new file mode 100644 index 00000000000..f6ddb4f5c43 --- /dev/null +++ b/app/views/users/phone_setup/index.html.slim @@ -0,0 +1,27 @@ +- title @presenter.heading += image_tag asset_url(@presenter.image), width: 200, class: 'mb2' + +h1.h3.my0 = @presenter.heading +p.mt-tiny.mb0 = @presenter.info += simple_form_for(@user_phone_form, + html: { autocomplete: 'off', role: 'form' }, + data: { unsupported_area_codes: unsupported_area_codes, + international_phone_form: true }, + method: :patch, + url: phone_setup_path) do |f| + .sm-col-8.js-intl-tel-code-select + = f.input :international_code, + collection: international_phone_codes, + include_blank: false, + input_html: { class: 'international-code' } + .sm-col-8.mb3 + = f.label :phone + strong.left = @presenter.label + = f.input :phone, as: :tel, label: false, required: true, + input_html: { class: 'phone col-8 mb4' } + = f.button :submit, t('forms.buttons.send_security_code') +.mt2.pt1.border-top + = link_to t('devise.two_factor_authentication.two_factor_choice_cancel'), two_factor_options_path + + = stylesheet_link_tag 'intl-tel-number/intlTelInput' + = javascript_pack_tag 'intl-tel-input' diff --git a/app/views/users/phones/edit.html.slim b/app/views/users/phones/edit.html.slim index 9099011ff55..bd922912786 100644 --- a/app/views/users/phones/edit.html.slim +++ b/app/views/users/phones/edit.html.slim @@ -6,6 +6,19 @@ h1.h3.my0 = t('headings.edit_info.phone') data: { unsupported_area_codes: unsupported_area_codes, international_phone_form: true }, url: manage_phone_path) do |f| - = render 'users/shared/phone_input', f: f + .sm-col-8.js-intl-tel-code-select + = f.input :international_code, + collection: international_phone_codes, + include_blank: false, + input_html: { class: 'international-code' } + .sm-col-8.mb3 + = f.label :phone + strong.left = @presenter.label + = f.input :phone, as: :tel, label: false, required: true, + input_html: { class: 'phone col-8 mb4' } + = render 'users/shared/otp_delivery_preference_selection' = f.button :submit, t('forms.buttons.submit.confirm_change') = render 'shared/cancel', link: account_path + += stylesheet_link_tag 'intl-tel-number/intlTelInput' += javascript_pack_tag 'intl-tel-input' diff --git a/app/views/users/piv_cac_authentication_setup/new.html.slim b/app/views/users/piv_cac_authentication_setup/new.html.slim index c62a521abdf..51f1aba2252 100644 --- a/app/views/users/piv_cac_authentication_setup/new.html.slim +++ b/app/views/users/piv_cac_authentication_setup/new.html.slim @@ -6,6 +6,6 @@ p.mt-tiny.mb3 = @presenter.description = link_to @presenter.piv_cac_capture_text, @presenter.piv_cac_service_link, class: 'btn btn-primary' -= render 'shared/cancel', link: account_path += render 'shared/cancel_or_back_to_options' == javascript_pack_tag 'clipboard' diff --git a/app/views/users/shared/_phone_input.html.slim b/app/views/users/shared/_phone_input.html.slim deleted file mode 100644 index 7e6cea23aa5..00000000000 --- a/app/views/users/shared/_phone_input.html.slim +++ /dev/null @@ -1,16 +0,0 @@ -.sm-col-8.js-intl-tel-code-select - = f.input :international_code, - collection: international_phone_codes, - include_blank: false, - input_html: { class: 'international-code' } -.sm-col-8.mb3 - = f.label :phone - strong.left = t('devise.two_factor_authentication.otp_phone_label') - span#otp_phone_label_info.ml1.italic - = t('devise.two_factor_authentication.otp_phone_label_info') - = f.input :phone, as: :tel, label: false, required: true, - input_html: { class: 'new-phone col-8 mb4' } -= render 'users/shared/otp_delivery_preference_selection' - -= stylesheet_link_tag 'intl-tel-number/intlTelInput' -= javascript_pack_tag 'intl-tel-input' diff --git a/app/views/users/totp_setup/new.html.slim b/app/views/users/totp_setup/new.html.slim index 0cf0000d630..b282b17db51 100644 --- a/app/views/users/totp_setup/new.html.slim +++ b/app/views/users/totp_setup/new.html.slim @@ -42,6 +42,7 @@ ul.list-reset .col.col-6.sm-col-5.px1 = submit_tag t('forms.buttons.submit.default'), class: 'col-12 btn btn-primary align-top' -= render 'shared/cancel', link: account_path + += render 'shared/cancel_or_back_to_options' == javascript_pack_tag 'clipboard' diff --git a/app/views/users/two_factor_authentication_setup/index.html.slim b/app/views/users/two_factor_authentication_setup/index.html.slim index fef9c4a4c6e..c490b3e1f8f 100644 --- a/app/views/users/two_factor_authentication_setup/index.html.slim +++ b/app/views/users/two_factor_authentication_setup/index.html.slim @@ -1,14 +1,40 @@ - title t('titles.two_factor_setup') -h1.h3.my0 = t('devise.two_factor_authentication.two_factor_setup') -p.mt-tiny.mb0 - = t('devise.two_factor_authentication.otp_setup_html') -= simple_form_for(@user_phone_form, - html: { autocomplete: 'off', role: 'form' }, - data: { unsupported_area_codes: unsupported_area_codes, - international_phone_form: true }, - method: :patch, - url: phone_setup_path) do |f| - = render 'users/shared/phone_input', f: f - = f.button :submit, t('forms.buttons.send_security_code') +h1.h3.my0 = t('devise.two_factor_authentication.two_factor_choice') +p.mt-tiny.mb3 + = t('devise.two_factor_authentication.two_factor_choice_intro') + += simple_form_for(@two_factor_options_form, + html: { autocomplete: 'off', role: 'form' }, + method: :patch, + url: two_factor_options_path) do |f| + .mb3 + fieldset.m0.p0.border-none. + legend.mb1.h4.serif.bold = t('forms.two_factor_choice.legend') + ':' + label.btn-border.col-12.mb1 for="two_factor_options_form_selection_sms" + .radio + = radio_button_tag 'two_factor_options_form[selection]', :sms, true + span.indicator.mt-tiny + span.blue.bold.fs-20p + = t('devise.two_factor_authentication.two_factor_choice_options.sms') + .regular.gray-dark.fs-10p.mb-tiny + = t('devise.two_factor_authentication.two_factor_choice_options.sms_info') + label.btn-border.col-12.mb1 for="two_factor_options_form_selection_voice" + .radio + = radio_button_tag 'two_factor_options_form[selection]', :voice, false + span.indicator.mt-tiny + span.blue.bold.fs-20p + = t('devise.two_factor_authentication.two_factor_choice_options.voice') + .regular.gray-dark.fs-10p.mb-tiny + = t('devise.two_factor_authentication.two_factor_choice_options.voice_info') + label.btn-border.col-12.mb1 for="two_factor_options_form_selection_auth_app" + .radio + = radio_button_tag 'two_factor_options_form[selection]', :auth_app, false + span.indicator.mt-tiny + span.blue.bold.fs-20p + = t('devise.two_factor_authentication.two_factor_choice_options.auth_app') + .regular.gray-dark.fs-10p.mb-tiny + = t('devise.two_factor_authentication.two_factor_choice_options.auth_app_info') + = f.button :submit, t('forms.buttons.continue') + = render 'shared/cancel', link: destroy_user_session_path diff --git a/config/locales/devise/en.yml b/config/locales/devise/en.yml index 911015e3a45..4e0f88be735 100644 --- a/config/locales/devise/en.yml +++ b/config/locales/devise/en.yml @@ -118,7 +118,6 @@ en: sms: Text message (SMS) title: How should we send you a code? voice: Phone call - otp_phone_label: Phone number otp_phone_label_info: Mobile phone or landline otp_phone_label_info_mobile_only: Mobile phone otp_setup_html: "Every time you log in, we will send you a @@ -131,6 +130,11 @@ en: personal_key_header_text: Enter your personal key personal_key_prompt: You can use this personal key once. If you still need a code after signing in, go to your account settings page to get a new one. + phone_sms_label: Mobile phone number + phone_sms_info_html: We'll text a security code each time you sign in. + phone_voice_label: Phone number + phone_voice_info_html: We'll call you with a security code each time + you sign in. piv_cac_fallback: link: Use your PIV/CAC instead text_html: Do you have your PIV/CAC? %{link} @@ -145,6 +149,20 @@ en: voice_link_text: Receive a code via phone call totp_header_text: Enter your authentication app code totp_info: Use any authenticator app to scan the QR code below. + two_factor_choice: Secure your account + two_factor_choice_options: + voice: Phone call + voice_info: Get your security code via phone call. + sms: Text message / SMS + sms_info: Get your security code via text message / SMS. + auth_app: Authentication application + auth_app_info: Set up an authentication application to get your security code + without providing a phone number. + piv_cac: Government employees + piv_cac_info: Use your PIV/CAC card to secure your account. + two_factor_choice_intro: login.gov makes sure you can access your account by + adding a second layer of security. + two_factor_choice_cancel: "‹ Choose another option" two_factor_setup: Add a phone number user: new_otp_sent: We sent you a new one-time security code. diff --git a/config/locales/devise/es.yml b/config/locales/devise/es.yml index d282144cc3a..98e82ea11d7 100644 --- a/config/locales/devise/es.yml +++ b/config/locales/devise/es.yml @@ -122,7 +122,6 @@ es: sms: Mensaje de texto (SMS, sigla en inglés) title: "¿Cómo deberíamos enviarle un código?" voice: Llamada telefónica - otp_phone_label: Número de teléfono otp_phone_label_info: El móvil o teléfono fijo. Si tiene un teléfono fijo, seleccione "Llamada telefónica" en la siguiente pregunta. otp_phone_label_info_mobile_only: NOT TRANSLATED YET @@ -137,6 +136,12 @@ es: personal_key_prompt: Puede usar esta clave personal una vez. Si todavía necesita un código después de iniciar una sesión, vaya a la página de configuración de su cuenta para obtener una clave nueva. + phone_sms_label: Número de teléfono móvil + phone_sms_info_html: Le enviaremos un mensaje de texto con un código de seguridad + cada vez que inicie sesión. + phone_voice_label: Número de teléfono + phone_voice_info_html: Te llamaremos con un código de seguridad cada + vez que inicies sesión. piv_cac_fallback: link: Use su PIV/CAC en su lugar text_html: "¿Tiene usted PIV/CAC? %{link}" @@ -152,6 +157,20 @@ es: totp_header_text: Ingrese su código de la app de autenticación totp_info: Use cualquier app de autenticación para escanear el código QR que aparece a continuación. + two_factor_choice: Asegure su cuenta + two_factor_choice_options: + voice: Llamada telefónica + voice_info: Obtenga su código de seguridad a través de una llamada telefónica. + sms: Mensaje de texto / SMS + sms_info: Obtenga su código de seguridad a través de mensajes de texto / SMS. + auth_app: Aplicación de autenticación + auth_app_info: Configure una aplicación de autenticación para obtener su código + de seguridad sin proporcionar un número de teléfono. + piv_cac: Empleados del Gobierno + piv_cac_info: Use su tarjeta PIV / CAC para asegurar su cuenta. + two_factor_choice_intro: login.gov se asegura de que pueda acceder a su cuenta + agregando una segunda capa de seguridad. + two_factor_choice_cancel: "‹ Elige otra opción" two_factor_setup: Añada un número de teléfono user: new_otp_sent: Le enviamos un nuevo código de sólo un uso diff --git a/config/locales/devise/fr.yml b/config/locales/devise/fr.yml index 36afb68776c..2fbb9fe4b2d 100644 --- a/config/locales/devise/fr.yml +++ b/config/locales/devise/fr.yml @@ -130,7 +130,6 @@ fr: sms: Message texte (SMS) title: Comment devrions-nous vous envoyer un code? voice: Appel téléphonique - otp_phone_label: Numéro de téléphone otp_phone_label_info: Cellulaire ou ligne fixe. Si vous entrez une ligne fixe, veuillez choisir l'option "Appel téléphonique" ci-dessous. otp_phone_label_info_mobile_only: NOT TRANSLATED YET @@ -145,6 +144,12 @@ fr: personal_key_prompt: Vous pouvez utiliser cette clé personnelle une fois seulement. Si vous avez toujours besoin d'un code après votre connexion, allez à la page des réglages de votre compte pour en obtenir un nouveau. + phone_sms_label: Numéro de téléphone portable + phone_sms_info_html: Nous vous enverrons un code de sécurité chaque + fois que vous vous connectez. + phone_voice_label: Numéro de téléphone + phone_voice_info_html: Nous vous appellerons avec un code de sécurité chaque + fois que vous vous connectez. piv_cac_fallback: link: Utilisez plutôt votre PIV/CAC text_html: Avez-vous votre PIV/CAC? %{link} @@ -160,6 +165,20 @@ fr: totp_header_text: Entrez votre code d'application d'authentification totp_info: Utilisez n'importe quelle application d'authentification pour balayer le code QR ci-dessous. + two_factor_choice: Sécurise ton compte + two_factor_choice_options: + voice: Appel téléphonique + voice_info: Obtenez votre code de sécurité par appel téléphonique. + sms: SMS + sms_info: Obtenez votre code de sécurité par SMS + auth_app: Application d'authentification + auth_app_info: Configurez une application d'authentification pour obtenir + votre code de sécurité sans fournir de numéro de téléphone. + piv_cac: Employés du gouvernement + piv_cac_info: Utilisez votre carte PIV / CAC pour sécuriser votre compte. + two_factor_choice_intro: login.gov s'assure que vous pouvez accéder à votre + compte en ajoutant une deuxième couche de sécurité. + two_factor_choice_cancel: "‹ Choisissez une autre option" two_factor_setup: Ajoutez un numéro de téléphone user: new_otp_sent: Nous vous avons envoyé un code de sécurité à utilisation unique. diff --git a/config/locales/forms/en.yml b/config/locales/forms/en.yml index c633a271cef..7b0286d8c45 100644 --- a/config/locales/forms/en.yml +++ b/config/locales/forms/en.yml @@ -72,6 +72,8 @@ en: code: One-time security code personal_key: Personal key try_again: Use another phone number + two_factor_choice: + legend: Select an option to secure your account verify_profile: instructions: Enter the ten-character code in the letter we sent you. name: Confirmation code diff --git a/config/locales/forms/es.yml b/config/locales/forms/es.yml index 7eb41249384..5f45b02309d 100644 --- a/config/locales/forms/es.yml +++ b/config/locales/forms/es.yml @@ -72,6 +72,8 @@ es: code: Código de seguridad de sólo un uso personal_key: Clave personal try_again: Use otro número de teléfono. + two_factor_choice: + legend: Seleccione una opción para proteger su cuenta verify_profile: instructions: Ingrese el código de 10 caracteres que le enviamos en la carta. name: Código de confirmación diff --git a/config/locales/forms/fr.yml b/config/locales/forms/fr.yml index 16930194f9b..463332376ff 100644 --- a/config/locales/forms/fr.yml +++ b/config/locales/forms/fr.yml @@ -75,6 +75,8 @@ fr: code: Code de sécurité personal_key: Clé personnelle try_again: Utilisez un autre numéro de téléphone + two_factor_choice: + legend: Sélectionnez une option pour sécuriser votre compte verify_profile: instructions: Entrez le code à dix caractères qui se trouve dans la lettre que nous vous avons envoyée. diff --git a/config/locales/titles/en.yml b/config/locales/titles/en.yml index cca277124b0..0170ca9833c 100644 --- a/config/locales/titles/en.yml +++ b/config/locales/titles/en.yml @@ -16,6 +16,9 @@ en: confirm: Confirm the password for your account forgot: Reset the password for your account personal_key: Just in case + phone_setup: + voice: Send your security code via phone call + sms: Send your security code via text message piv_cac_setup: new: Use your PIV/CAC card to secure your account certificate: diff --git a/config/locales/titles/es.yml b/config/locales/titles/es.yml index 619a8351add..2bd2bedf56f 100644 --- a/config/locales/titles/es.yml +++ b/config/locales/titles/es.yml @@ -16,6 +16,9 @@ es: confirm: Confirme la contraseña de su cuenta forgot: Restablezca la contraseña de su cuenta personal_key: Por si acaso + phone_setup: + voice: Envíe su código de seguridad a través de una llamada telefónica + sms: Envíe su código de seguridad a través de un mensaje de texto piv_cac_setup: new: Use su tarjeta PIV/CAC para asegurar su cuenta certificate: diff --git a/config/locales/titles/fr.yml b/config/locales/titles/fr.yml index bc4e3dfaaeb..bec817df8ea 100644 --- a/config/locales/titles/fr.yml +++ b/config/locales/titles/fr.yml @@ -16,6 +16,9 @@ fr: confirm: Confirmez le mot de passe de votre compte forgot: Réinitialisez le mot de passe de votre compte personal_key: Juste au cas + phone_setup: + voice: Envoyez votre code de sécurité par appel téléphonique + sms: Envoyer votre code de sécurité par SMS piv_cac_setup: new: Utilisez votre carte PIV/CAC pour sécuriser votre compte certificate: diff --git a/config/routes.rb b/config/routes.rb index dc8e7c96299..cea7e3dee93 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -123,8 +123,10 @@ post '/manage/personal_key' => 'users/personal_keys#update' get '/otp/send' => 'users/two_factor_authentication#send_code' - get '/phone_setup' => 'users/two_factor_authentication_setup#index' - patch '/phone_setup' => 'users/two_factor_authentication_setup#set' + get '/two_factor_options' => 'users/two_factor_authentication_setup#index' + patch '/two_factor_options' => 'users/two_factor_authentication_setup#create' + get '/phone_setup' => 'users/phone_setup#index' + patch '/phone_setup' => 'users/phone_setup#create' get '/users/two_factor_authentication' => 'users/two_factor_authentication#show', as: :user_two_factor_authentication # route name is used by two_factor_authentication gem diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index f6284e64ebf..13f5d1437e7 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -113,7 +113,7 @@ def index get :index - expect(response).to redirect_to phone_setup_url + expect(response).to redirect_to two_factor_options_url end end diff --git a/spec/controllers/users/phone_setup_controller_spec.rb b/spec/controllers/users/phone_setup_controller_spec.rb new file mode 100644 index 00000000000..f09ee2c3aef --- /dev/null +++ b/spec/controllers/users/phone_setup_controller_spec.rb @@ -0,0 +1,194 @@ +require 'rails_helper' + +describe Users::PhoneSetupController do + describe 'GET index' do + context 'when signed out' do + it 'redirects to sign in page' do + expect(PhoneSetupPresenter).to_not receive(:new) + + get :index + + expect(response).to redirect_to(new_user_session_url) + end + end + + context 'when signed in' do + it 'renders the index view' do + stub_analytics + user = build(:user, otp_delivery_preference: 'voice') + stub_sign_in_before_2fa(user) + + expect(@analytics).to receive(:track_event). + with(Analytics::USER_REGISTRATION_PHONE_SETUP_VISIT) + expect(PhoneSetupPresenter).to receive(:new).with(user.otp_delivery_preference) + expect(UserPhoneForm).to receive(:new).with(user) + + get :index + + expect(response).to render_template(:index) + end + end + end + + describe 'PATCH create' do + let(:user) { create(:user) } + + it 'tracks an event when the number is invalid' do + sign_in(user) + + stub_analytics + result = { + success: false, + errors: { phone: [t('errors.messages.improbable_phone')] }, + otp_delivery_preference: 'sms', + } + + expect(@analytics).to receive(:track_event). + with(Analytics::MULTI_FACTOR_AUTH_PHONE_SETUP, result) + + patch :create, params: { + user_phone_form: { + phone: '703-555-010', + international_code: 'US', + }, + } + + expect(response).to render_template(:index) + end + + context 'with voice' do + let(:user) { create(:user, otp_delivery_preference: 'voice') } + + it 'prompts to confirm the number' do + sign_in(user) + + stub_analytics + result = { + success: true, + errors: {}, + otp_delivery_preference: 'voice', + } + + expect(@analytics).to receive(:track_event). + with(Analytics::MULTI_FACTOR_AUTH_PHONE_SETUP, result) + + patch( + :create, + params: { + user_phone_form: { phone: '703-555-0100', + # otp_delivery_preference: 'voice', + international_code: 'US' }, + } + ) + + expect(response).to redirect_to( + otp_send_path( + otp_delivery_selection_form: { otp_delivery_preference: 'voice' } + ) + ) + + expect(subject.user_session[:context]).to eq 'confirmation' + end + end + + context 'with SMS' do + it 'prompts to confirm the number' do + sign_in(user) + + stub_analytics + + result = { + success: true, + errors: {}, + otp_delivery_preference: 'sms', + } + + expect(@analytics).to receive(:track_event). + with(Analytics::MULTI_FACTOR_AUTH_PHONE_SETUP, result) + + patch( + :create, + params: { + user_phone_form: { phone: '703-555-0100', + # otp_delivery_preference: :sms, + international_code: 'US' }, + } + ) + + expect(response).to redirect_to( + otp_send_path( + otp_delivery_selection_form: { otp_delivery_preference: 'sms' } + ) + ) + + expect(subject.user_session[:context]).to eq 'confirmation' + end + end + + context 'without selection' do + it 'prompts to confirm via SMS by default' do + sign_in(user) + + stub_analytics + result = { + success: true, + errors: {}, + otp_delivery_preference: 'sms', + } + + expect(@analytics).to receive(:track_event). + with(Analytics::MULTI_FACTOR_AUTH_PHONE_SETUP, result) + + patch( + :create, + params: { + user_phone_form: { phone: '703-555-0100', + # otp_delivery_preference: :sms, + international_code: 'US' }, + } + ) + + expect(response).to redirect_to( + otp_send_path( + otp_delivery_selection_form: { otp_delivery_preference: 'sms' } + ) + ) + + expect(subject.user_session[:context]).to eq 'confirmation' + end + end + end + + describe 'before_actions' do + it 'includes the appropriate before_actions' do + expect(subject).to have_actions( + :before, + :authenticate_user, + :authorize_phone_setup + ) + end + end + + describe '#authorize_otp_setup' do + context 'when the user is fully authenticated' do + it 'redirects to account url' do + stub_sign_in + + get :index + + expect(response).to redirect_to(account_url) + end + end + + context 'when the user is two_factor_enabled but not fully authenticated' do + it 'prompts to enter OTP' do + user = build(:user, :signed_up) + stub_sign_in_before_2fa(user) + + get :index + + expect(response).to redirect_to(user_two_factor_authentication_url) + end + end + end +end diff --git a/spec/controllers/users/two_factor_authentication_controller_spec.rb b/spec/controllers/users/two_factor_authentication_controller_spec.rb index a6cf11b3872..efb4febbae0 100644 --- a/spec/controllers/users/two_factor_authentication_controller_spec.rb +++ b/spec/controllers/users/two_factor_authentication_controller_spec.rb @@ -105,7 +105,7 @@ def index stub_sign_in_before_2fa(build(:user)) get :show - expect(response).to redirect_to phone_setup_url + expect(response).to redirect_to two_factor_options_url end end end diff --git a/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb b/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb index 3ac54eedb50..5f7323249a2 100644 --- a/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb +++ b/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb @@ -2,6 +2,16 @@ describe Users::TwoFactorAuthenticationSetupController do describe 'GET index' do + it 'tracks the visit in analytics' do + stub_sign_in_before_2fa + stub_analytics + + expect(@analytics).to receive(:track_event). + with(Analytics::USER_REGISTRATION_2FA_SETUP_VISIT) + + get :index + end + context 'when signed out' do it 'redirects to sign in page' do get :index @@ -9,165 +19,125 @@ expect(response).to redirect_to(new_user_session_url) end end + + context 'when fully authenticated' do + it 'redirects to account page' do + stub_sign_in + + get :index + + expect(response).to redirect_to(account_url) + end + end + + context 'already two factor enabled but not fully authenticated' do + it 'prompts for 2FA' do + user = build(:user, :signed_up) + stub_sign_in_before_2fa(user) + + get :index + + expect(response).to redirect_to(user_two_factor_authentication_url) + end + end end - describe 'PATCH set' do - let(:user) { create(:user) } + describe 'PATCH create' do + it 'submits the TwoFactorOptionsForm' do + user = build(:user) + stub_sign_in_before_2fa(user) + + voice_params = { + two_factor_options_form: { + selection: 'voice', + } + } + params = ActionController::Parameters.new(voice_params) + response = FormResponse.new(success: true, errors: {}, extra: { selection: 'voice' }) - it 'tracks an event when the number is invalid' do - sign_in(user) + form = instance_double(TwoFactorOptionsForm) + allow(TwoFactorOptionsForm).to receive(:new).with(user).and_return(form) + expect(form).to receive(:submit). + with(params.require(:two_factor_options_form).permit(:selection)). + and_return(response) + expect(form).to receive(:selection).and_return('voice') + patch :create, params: voice_params + end + + it 'tracks analytics event' do + stub_sign_in_before_2fa stub_analytics + result = { - success: false, - errors: { phone: [t('errors.messages.improbable_phone')] }, - otp_delivery_preference: 'sms', + selection: 'voice', + success: true, + errors: {}, } expect(@analytics).to receive(:track_event). - with(Analytics::MULTI_FACTOR_AUTH_PHONE_SETUP, result) + with(Analytics::USER_REGISTRATION_2FA_SETUP, result) - patch :set, params: { - user_phone_form: { - phone: '703-555-010', - otp_delivery_preference: :sms, - international_code: 'US', + patch :create, params: { + two_factor_options_form: { + selection: 'voice', }, } - - expect(response).to render_template(:index) end - context 'with voice' do - it 'prompts to confirm the number' do - sign_in(user) + context 'when the selection is sms' do + it 'redirects to phone setup page' do + stub_sign_in_before_2fa - stub_analytics - result = { - success: true, - errors: {}, - otp_delivery_preference: 'voice', + patch :create, params: { + two_factor_options_form: { + selection: 'sms', + }, } - expect(@analytics).to receive(:track_event). - with(Analytics::MULTI_FACTOR_AUTH_PHONE_SETUP, result) - - patch( - :set, - params: { - user_phone_form: { phone: '703-555-0100', - otp_delivery_preference: 'voice', - international_code: 'US' }, - } - ) - - expect(response).to redirect_to( - otp_send_path( - otp_delivery_selection_form: { otp_delivery_preference: 'voice' } - ) - ) - - expect(subject.user_session[:context]).to eq 'confirmation' + expect(response).to redirect_to phone_setup_url end end - context 'with SMS' do - it 'prompts to confirm the number' do - sign_in(user) - - stub_analytics + context 'when the selection is voice' do + it 'redirects to phone setup page' do + stub_sign_in_before_2fa - result = { - success: true, - errors: {}, - otp_delivery_preference: 'sms', + patch :create, params: { + two_factor_options_form: { + selection: 'voice', + }, } - expect(@analytics).to receive(:track_event). - with(Analytics::MULTI_FACTOR_AUTH_PHONE_SETUP, result) - - patch( - :set, - params: { - user_phone_form: { phone: '703-555-0100', - otp_delivery_preference: :sms, - international_code: 'US' }, - } - ) - - expect(response).to redirect_to( - otp_send_path( - otp_delivery_selection_form: { otp_delivery_preference: 'sms' } - ) - ) - - expect(subject.user_session[:context]).to eq 'confirmation' + expect(response).to redirect_to phone_setup_url end end - context 'without selection' do - it 'prompts to confirm via SMS by default' do - sign_in(user) + context 'when the selection is auth_app' do + it 'redirects to authentication app setup page' do + stub_sign_in_before_2fa - stub_analytics - result = { - success: true, - errors: {}, - otp_delivery_preference: 'sms', + patch :create, params: { + two_factor_options_form: { + selection: 'auth_app', + }, } - expect(@analytics).to receive(:track_event). - with(Analytics::MULTI_FACTOR_AUTH_PHONE_SETUP, result) - - patch( - :set, - params: { - user_phone_form: { phone: '703-555-0100', - otp_delivery_preference: :sms, - international_code: 'US' }, - } - ) - - expect(response).to redirect_to( - otp_send_path( - otp_delivery_selection_form: { otp_delivery_preference: 'sms' } - ) - ) - - expect(subject.user_session[:context]).to eq 'confirmation' + expect(response).to redirect_to authenticator_setup_url end end - end - describe 'before_actions' do - it 'includes the appropriate before_actions' do - expect(subject).to have_actions( - :before, - :authenticate_user, - :authorize_otp_setup - ) - end - end + context 'when the selection is not valid' do + it 'renders index page' do + stub_sign_in_before_2fa - describe '#authorize_otp_setup' do - context 'when the user is fully authenticated' do - it 'redirects to root url' do - user = create(:user, :signed_up) - sign_in(user) - - get :index - - expect(response).to redirect_to(root_url) - end - end - - context 'when the user is two_factor_enabled but not fully authenticated' do - it 'prompts to enter OTP' do - sign_in_before_2fa - - get :index + patch :create, params: { + two_factor_options_form: { + selection: 'foo', + }, + } - expect(response).to redirect_to(user_two_factor_authentication_path) + expect(response).to render_template(:index) end end end diff --git a/spec/features/accessibility/user_pages_spec.rb b/spec/features/accessibility/user_pages_spec.rb index 38367466aff..9db6fc614b6 100644 --- a/spec/features/accessibility/user_pages_spec.rb +++ b/spec/features/accessibility/user_pages_spec.rb @@ -28,8 +28,16 @@ end describe '2FA pages' do + scenario 'two factor options page' do + sign_up_and_set_password + + expect(current_path).to eq(two_factor_options_path) + expect(page).to be_accessible + end + scenario 'phone setup page' do sign_up_and_set_password + click_button t('forms.buttons.continue') expect(current_path).to eq(phone_setup_path) expect(page).to be_accessible diff --git a/spec/features/saml/loa1_sso_spec.rb b/spec/features/saml/loa1_sso_spec.rb index dc593198337..4e0b80685e7 100644 --- a/spec/features/saml/loa1_sso_spec.rb +++ b/spec/features/saml/loa1_sso_spec.rb @@ -182,7 +182,7 @@ saml_authn_request = auth_request.create(saml_settings) visit saml_authn_request - expect(current_path).to eq phone_setup_path + expect(current_path).to eq two_factor_options_path end end diff --git a/spec/features/saml/saml_spec.rb b/spec/features/saml/saml_spec.rb index 08df6bab357..48e95366c7c 100644 --- a/spec/features/saml/saml_spec.rb +++ b/spec/features/saml/saml_spec.rb @@ -35,11 +35,12 @@ class MockSession; end end it 'prompts the user to set up 2FA' do - expect(current_path).to eq phone_setup_path + expect(current_path).to eq two_factor_options_path end it 'prompts the user to confirm phone after setting up 2FA' do - fill_in 'Phone', with: '202-555-1212' + select_2fa_option('sms') + fill_in 'user_phone_form_phone', with: '202-555-1212' click_send_security_code expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'sms') diff --git a/spec/features/two_factor_authentication/remember_device_spec.rb b/spec/features/two_factor_authentication/remember_device_spec.rb index 92f5812d935..0e965c28f49 100644 --- a/spec/features/two_factor_authentication/remember_device_spec.rb +++ b/spec/features/two_factor_authentication/remember_device_spec.rb @@ -28,6 +28,7 @@ def remember_device_and_sign_out_user def remember_device_and_sign_out_user user = sign_up_and_set_password user.password = Features::SessionHelper::VALID_PASSWORD + select_2fa_option('sms') fill_in :user_phone_form_phone, with: '5551231234' click_send_security_code check :remember_device diff --git a/spec/features/two_factor_authentication/sign_in_spec.rb b/spec/features/two_factor_authentication/sign_in_spec.rb index 74ef66e1742..3b2b58adef3 100644 --- a/spec/features/two_factor_authentication/sign_in_spec.rb +++ b/spec/features/two_factor_authentication/sign_in_spec.rb @@ -9,9 +9,14 @@ attempt_to_bypass_2fa_setup - expect(current_path).to eq phone_setup_path + expect(current_path).to eq two_factor_options_path + + select_2fa_option('sms') + + click_continue + expect(page). - to have_content t('devise.two_factor_authentication.two_factor_setup') + to have_content t('titles.phone_setup.sms') send_security_code_without_entering_phone_number @@ -25,19 +30,20 @@ expect(page).to have_content invalid_phone_message - submit_2fa_setup_form_with_valid_phone_and_choose_phone_call_delivery + submit_2fa_setup_form_with_valid_phone expect(page).to_not have_content invalid_phone_message - expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'voice') + expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'sms') expect(user.reload.phone).to_not eq '+1 (555) 555-1212' - expect(user.voice?).to eq true + expect(user.sms?).to eq true end context 'user enters OTP incorrectly 3 times' do it 'locks the user out' do sign_in_before_2fa - submit_2fa_setup_form_with_valid_phone_and_choose_phone_call_delivery + select_2fa_option('sms') + submit_2fa_setup_form_with_valid_phone 3.times do fill_in('code', with: 'bad-code') click_button t('forms.buttons.submit.default') @@ -47,89 +53,52 @@ end end - context 'with U.S. phone that does not support phone delivery method' do + context 'with U.S. phone that does not support voice delivery method' do let(:unsupported_phone) { '242-555-5555' } - scenario 'renders an error if a user submits with phone selected' do + scenario 'renders an error if a user submits with voice selected' do sign_in_before_2fa + select_2fa_option('voice') fill_in 'Phone', with: unsupported_phone - choose 'Phone call' click_send_security_code - expect(current_path).to eq(phone_setup_path) - expect(page).to have_content t( - 'devise.two_factor_authentication.otp_delivery_preference.phone_unsupported', - location: 'Bahamas' - ) - end - - scenario 'disables the phone option and displays a warning with js', :js do - sign_in_before_2fa - select_country_and_type_phone_number(country: 'bs', number: '7035551212') - phone_radio_button = page.find( - '#user_phone_form_otp_delivery_preference_voice', - visible: :all - ) + expect(current_path).to eq phone_setup_path expect(page).to have_content t( 'devise.two_factor_authentication.otp_delivery_preference.phone_unsupported', location: 'Bahamas' ) - expect(phone_radio_button).to be_disabled - select_country_and_type_phone_number(country: 'us', number: '7035551212') - - expect(page).not_to have_content t( - 'devise.two_factor_authentication.otp_delivery_preference.phone_unsupported', - location: 'Bahamas' - ) - expect(phone_radio_button).to_not be_disabled + click_on t('devise.two_factor_authentication.two_factor_choice_cancel') + + expect(current_path).to eq two_factor_options_path end end - context 'with international phone that does not support phone delivery' do - scenario 'renders an error if a user submits with phone selected' do + context 'with international phone that does not support voice delivery' do + scenario 'updates international code as user types', :js do sign_in_before_2fa + select_2fa_option('voice') + fill_in 'Phone', with: '+81 54 354 3643' - select 'Turkey +90', from: 'International code' - fill_in 'Phone', with: '555-555-5000' - choose 'Phone call' - click_send_security_code + expect(page.find('#user_phone_form_international_code', visible: false).value).to eq 'JP' - expect(current_path).to eq(phone_setup_path) - expect(page).to have_content t( - 'devise.two_factor_authentication.otp_delivery_preference.phone_unsupported', - location: 'Turkey' - ) - end + fill_in 'Phone', with: '' + fill_in 'Phone', with: '+212 5376' - scenario 'disables the phone option and displays a warning with js', :js do - sign_in_before_2fa - select_country_and_type_phone_number(country: 'tr', number: '3122132965') + expect(page.find('#user_phone_form_international_code', visible: false).value).to eq 'MA' - phone_radio_button = page.find( - '#user_phone_form_otp_delivery_preference_voice', - visible: :all - ) + fill_in 'Phone', with: '' + fill_in 'Phone', with: '+81 54354' - expect(page).to have_content t( - 'devise.two_factor_authentication.otp_delivery_preference.phone_unsupported', - location: 'Turkey' - ) - expect(phone_radio_button).to be_disabled - - select_country_and_type_phone_number(country: 'ca', number: '3122132965') - - expect(page).not_to have_content t( - 'devise.two_factor_authentication.otp_delivery_preference.phone_unsupported', - location: 'Turkey' - ) - expect(phone_radio_button).to_not be_disabled + expect(page.find('#user_phone_form_international_code', visible: false).value).to eq 'JP' end scenario 'allows a user to continue typing even if a number is invalid', :js do sign_in_before_2fa + select_2fa_option('voice') + select_country_and_type_phone_number(country: 'us', number: '12345678901234567890') expect(phone_field.value).to eq('12345678901234567890') @@ -156,18 +125,17 @@ def send_security_code_without_entering_phone_number end def submit_2fa_setup_form_with_empty_string_phone - fill_in 'Phone', with: '' + fill_in 'user_phone_form_phone', with: '' click_send_security_code end def submit_2fa_setup_form_with_invalid_phone - fill_in 'Phone', with: 'five one zero five five five four three two one' + fill_in 'user_phone_form_phone', with: 'five one zero five five five four three two one' click_send_security_code end - def submit_2fa_setup_form_with_valid_phone_and_choose_phone_call_delivery - fill_in 'Phone', with: '555-555-1212' - choose 'Phone call' + def submit_2fa_setup_form_with_valid_phone + fill_in 'user_phone_form_phone', with: '555-555-1212' click_send_security_code end @@ -406,15 +374,16 @@ def submit_prefilled_otp_code context 'When setting up 2FA for the first time' do it 'enforces rate limiting only for current phone' do - second_user = create(:user, :signed_up, phone: '+1 202-555-1212') + second_user = create(:user, :signed_up, phone: '202-555-1212') sign_in_before_2fa max_attempts = Figaro.env.otp_delivery_blocklist_maxretry.to_i - submit_2fa_setup_form_with_valid_phone_and_choose_phone_call_delivery + select_2fa_option('sms') + submit_2fa_setup_form_with_valid_phone max_attempts.times do - click_link t('links.two_factor_authentication.resend_code.voice') + click_link t('links.two_factor_authentication.resend_code.sms') end expect(page).to have_content t('titles.account_locked') diff --git a/spec/features/users/regenerate_personal_key_spec.rb b/spec/features/users/regenerate_personal_key_spec.rb index 0503a0367a1..cc66086bda4 100644 --- a/spec/features/users/regenerate_personal_key_spec.rb +++ b/spec/features/users/regenerate_personal_key_spec.rb @@ -145,7 +145,8 @@ def sign_up_and_view_personal_key allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) sign_up_and_set_password - fill_in 'Phone', with: '202-555-1212' + select_2fa_option('sms') + fill_in 'user_phone_form_phone', with: '202-555-1212' click_send_security_code click_submit_default end diff --git a/spec/features/users/sign_up_spec.rb b/spec/features/users/sign_up_spec.rb index 0103e135e9a..a2495ab7652 100644 --- a/spec/features/users/sign_up_spec.rb +++ b/spec/features/users/sign_up_spec.rb @@ -55,7 +55,8 @@ allow(SmsOtpSenderJob).to receive(:perform_now).and_raise(twilio_error) sign_up_and_set_password - fill_in 'Phone', with: '202-555-1212' + select_2fa_option('sms') + fill_in 'user_phone_form_phone', with: '202-555-1212' click_send_security_code expect(current_path).to eq(phone_setup_path) @@ -155,8 +156,7 @@ it_behaves_like 'creating an account using authenticator app for 2FA', :oidc it 'allows a user to choose TOTP as 2FA method during sign up' do - user = create(:user) - sign_in_user(user) + sign_in_user set_up_2fa_with_authenticator_app click_acknowledge_personal_key diff --git a/spec/features/users/user_edit_spec.rb b/spec/features/users/user_edit_spec.rb index cc162c14cd1..bb726d6ab2f 100644 --- a/spec/features/users/user_edit_spec.rb +++ b/spec/features/users/user_edit_spec.rb @@ -31,7 +31,7 @@ end scenario 'user is able to submit with a Puerto Rico phone number as a US number', js: true do - fill_in 'Phone', with: '787 555-1234' + fill_in 'user_phone_form_phone', with: '787 555-1234' expect(page.find('#user_phone_form_international_code', visible: false).value).to eq 'PR' expect(page).to have_button(t('forms.buttons.submit.confirm_change'), disabled: false) @@ -41,7 +41,7 @@ allow(SmsOtpSenderJob).to receive(:perform_later) allow(VoiceOtpSenderJob).to receive(:perform_now) - fill_in 'Phone', with: '555-555-5000' + fill_in 'user_phone_form_phone', with: '555-555-5000' choose 'Phone call' click_button t('forms.buttons.submit.confirm_change') diff --git a/spec/features/visitors/email_confirmation_spec.rb b/spec/features/visitors/email_confirmation_spec.rb index add20a4df54..5084aabc1a4 100644 --- a/spec/features/visitors/email_confirmation_spec.rb +++ b/spec/features/visitors/email_confirmation_spec.rb @@ -17,7 +17,7 @@ fill_in 'password_form_password', with: Features::SessionHelper::VALID_PASSWORD click_button t('forms.buttons.continue') - expect(current_url).to eq phone_setup_url + expect(current_url).to eq two_factor_options_url expect(page).to_not have_content t('devise.confirmations.confirmed_but_must_set_password') end diff --git a/spec/features/visitors/password_recovery_spec.rb b/spec/features/visitors/password_recovery_spec.rb index 4a0db3975b1..a654d2bae7f 100644 --- a/spec/features/visitors/password_recovery_spec.rb +++ b/spec/features/visitors/password_recovery_spec.rb @@ -50,7 +50,7 @@ fill_in_credentials_and_submit(user.email, 'NewVal!dPassw0rd') - expect(current_path).to eq phone_setup_path + expect(current_path).to eq two_factor_options_path end end @@ -87,7 +87,7 @@ it 'prompts user to set up their 2FA options after signing back in' do reset_password_and_sign_back_in(@user) - expect(current_path).to eq phone_setup_path + expect(current_path).to eq two_factor_options_path end end diff --git a/spec/features/visitors/phone_confirmation_spec.rb b/spec/features/visitors/phone_confirmation_spec.rb index 6be62f7c9cd..c9e67734309 100644 --- a/spec/features/visitors/phone_confirmation_spec.rb +++ b/spec/features/visitors/phone_confirmation_spec.rb @@ -6,7 +6,8 @@ allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) allow(SmsOtpSenderJob).to receive(:perform_now) @user = sign_in_before_2fa - fill_in 'Phone', with: '555-555-5555' + select_2fa_option('sms') + fill_in 'user_phone_form_phone', with: '555-555-5555' click_send_security_code expect(SmsOtpSenderJob).to have_received(:perform_now).with( @@ -58,7 +59,8 @@ before do @existing_user = create(:user, :signed_up) @user = sign_in_before_2fa - fill_in 'Phone', with: @existing_user.phone + select_2fa_option('sms') + fill_in 'user_phone_form_phone', with: @existing_user.phone click_send_security_code end diff --git a/spec/forms/two_factor_options_form_spec.rb b/spec/forms/two_factor_options_form_spec.rb new file mode 100644 index 00000000000..d353ca0a48b --- /dev/null +++ b/spec/forms/two_factor_options_form_spec.rb @@ -0,0 +1,55 @@ +require 'rails_helper' + +describe TwoFactorOptionsForm do + let(:user) { build(:user) } + subject { described_class.new(user) } + + describe '#submit' do + it 'is successful if the selection is valid' do + %w[voice sms auth_app piv_cac].each do |selection| + result = subject.submit(selection: selection) + + expect(result.success?).to eq true + end + end + + it 'is unsuccessful if the selection is invalid' do + result = subject.submit(selection: '!!!!') + + expect(result.success?).to eq false + expect(result.errors).to include :selection + end + + context "when the selection is different from the user's otp_delivery_preference" do + it "updates the user's otp_delivery_preference" do + user_updater = instance_double(UpdateUser) + allow(UpdateUser). + to receive(:new). + with( + user: user, + attributes: { otp_delivery_preference: 'voice' } + ). + and_return(user_updater) + expect(user_updater).to receive(:call) + + result = subject.submit(selection: 'voice') + end + end + + context "when the selection is the same as the user's otp_delivery_preference" do + it "does not update the user's otp_delivery_preference" do + expect(UpdateUser).to_not receive(:new) + + result = subject.submit(selection: 'sms') + end + end + + context 'when the selection is not voice or sms' do + it "does not update the user's otp_delivery_preference" do + expect(UpdateUser).to_not receive(:new) + + result = subject.submit(selection: 'auth_app') + end + end + end +end diff --git a/spec/forms/user_phone_form_spec.rb b/spec/forms/user_phone_form_spec.rb index 25139596550..721fdfc48ff 100644 --- a/spec/forms/user_phone_form_spec.rb +++ b/spec/forms/user_phone_form_spec.rb @@ -43,7 +43,7 @@ expect(result.errors).to be_empty end - it 'include otp preference in the form response extra' do + it 'includes otp preference in the form response extra' do result = subject.submit(params) expect(result.extra).to eq( @@ -90,6 +90,87 @@ end end + context 'when otp_delivery_preference is not voice or sms' do + let(:params) do + { + phone: '703-555-1212', + international_code: 'US', + otp_delivery_preference: 'foo', + } + end + + it 'is invalid' do + result = subject.submit(params) + + expect(result.success?).to eq(false) + expect(result.errors[:otp_delivery_preference].first). + to eq 'is not included in the list' + end + end + + context 'when otp_delivery_preference is empty' do + let(:params) do + { + phone: '703-555-1212', + international_code: 'US', + otp_delivery_preference: '', + } + end + + it 'is invalid' do + result = subject.submit(params) + + expect(result.success?).to eq(false) + expect(result.errors[:otp_delivery_preference].first). + to eq 'is not included in the list' + end + end + + context 'when otp_delivery_preference param is not present' do + let(:params) do + { + phone: '703-555-1212', + international_code: 'US', + } + end + + it 'is valid' do + result = subject.submit(params) + + expect(result.success?).to eq(true) + end + end + + context "when the submitted otp_delivery_preference is different from the user's" do + it "updates the user's otp_delivery_preference" do + user_updater = instance_double(UpdateUser) + allow(UpdateUser). + to receive(:new). + with( + user: user, + attributes: { otp_delivery_preference: 'voice' } + ). + and_return(user_updater) + expect(user_updater).to receive(:call) + + params = { + phone: '555-555-5000', + international_code: 'US', + otp_delivery_preference: 'voice', + } + + result = subject.submit(params) + end + end + + context "when the submitted otp_delivery_preference is the same as the user's" do + it "does not update the user's otp_delivery_preference" do + expect(UpdateUser).to_not receive(:new) + + result = subject.submit(params) + end + end + it 'does not raise inclusion errors for Norwegian phone numbers' do # ref: https://github.com/18F/identity-private/issues/2392 params[:phone] = '21 11 11 11' diff --git a/spec/support/features/session_helper.rb b/spec/support/features/session_helper.rb index 84a0b2c871f..f492c2678d0 100644 --- a/spec/support/features/session_helper.rb +++ b/spec/support/features/session_helper.rb @@ -10,10 +10,16 @@ def sign_up_with(email) click_button t('forms.buttons.submit.default') end + def select_2fa_option(option) + find("label[for='two_factor_options_form_selection_#{option}']").click + click_on t('forms.buttons.continue') + end + def sign_up_and_2fa_loa1_user allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) user = sign_up_and_set_password - fill_in 'Phone', with: '202-555-1212' + select_2fa_option('sms') + fill_in 'user_phone_form_phone', with: '202-555-1212' click_send_security_code click_submit_default click_acknowledge_personal_key @@ -367,6 +373,7 @@ def submit_form_with_valid_password(password = VALID_PASSWORD) end def set_up_2fa_with_valid_phone + select_2fa_option('sms') fill_in 'user_phone_form[phone]', with: '202-555-1212' click_send_security_code end @@ -392,7 +399,7 @@ def register_user_with_authenticator_app(email = 'test@test.com') end def set_up_2fa_with_authenticator_app - click_link t('links.two_factor_authentication.app_option') + select_2fa_option('auth_app') expect(page).to have_current_path authenticator_setup_path diff --git a/spec/views/two_factor_authentication_setup/index.html.slim_spec.rb b/spec/views/phone_setup/index.html.slim_spec.rb similarity index 50% rename from spec/views/two_factor_authentication_setup/index.html.slim_spec.rb rename to spec/views/phone_setup/index.html.slim_spec.rb index 6cd6180aae2..94c1c5ebd31 100644 --- a/spec/views/two_factor_authentication_setup/index.html.slim_spec.rb +++ b/spec/views/phone_setup/index.html.slim_spec.rb @@ -1,17 +1,24 @@ require 'rails_helper' -describe 'users/two_factor_authentication_setup/index.html.slim' do +describe 'users/phone_setup/index.html.slim' do before do user = build_stubbed(:user) allow(view).to receive(:current_user).and_return(user) @user_phone_form = UserPhoneForm.new(user) - + @presenter = PhoneSetupPresenter.new('voice') render end it 'sets form autocomplete to off' do expect(rendered).to have_xpath("//form[@autocomplete='off']") end + + it 'renders a link to choose a different option' do + expect(rendered).to have_link( + t('devise.two_factor_authentication.two_factor_choice_cancel'), + href: two_factor_options_path + ) + end end diff --git a/spec/views/users/phones/edit.html.slim_spec.rb b/spec/views/users/phones/edit.html.slim_spec.rb index 25e42529823..a18ee38b7ab 100644 --- a/spec/views/users/phones/edit.html.slim_spec.rb +++ b/spec/views/users/phones/edit.html.slim_spec.rb @@ -6,6 +6,7 @@ user = build_stubbed(:user, :signed_up) allow(view).to receive(:current_user).and_return(user) @user_phone_form = UserPhoneForm.new(user) + @presenter = PhoneSetupPresenter.new('voice') end it 'has a localized title' do diff --git a/spec/views/users/piv_cac_authentication_setup/new.html.slim_spec.rb b/spec/views/users/piv_cac_authentication_setup/new.html.slim_spec.rb new file mode 100644 index 00000000000..0077477a2d3 --- /dev/null +++ b/spec/views/users/piv_cac_authentication_setup/new.html.slim_spec.rb @@ -0,0 +1,32 @@ +require 'rails_helper' + +describe 'users/piv_cac_authentication_setup/new.html.slim' do + before { @presenter = OpenStruct.new(title: 'foo', heading: 'bar', description: 'foobar') } + + context 'user is fully authenticated' do + it 'renders a link to cancel and go back to the account page' do + user = build_stubbed(:user, :signed_up) + allow(view).to receive(:current_user).and_return(user) + allow(view).to receive(:user_fully_authenticated?).and_return(true) + + render + + expect(rendered).to have_link(t('links.cancel'), href: account_path) + end + end + + context 'user is setting up 2FA' do + it 'renders a link to choose a different option' do + user = build_stubbed(:user) + allow(view).to receive(:current_user).and_return(user) + allow(view).to receive(:user_fully_authenticated?).and_return(false) + + render + + expect(rendered).to have_link( + t('devise.two_factor_authentication.two_factor_choice_cancel'), + href: two_factor_options_path + ) + end + end +end diff --git a/spec/views/users/totp_setup/new.html.slim_spec.rb b/spec/views/users/totp_setup/new.html.slim_spec.rb index 91b2437fd8e..84fe4756f0e 100644 --- a/spec/views/users/totp_setup/new.html.slim_spec.rb +++ b/spec/views/users/totp_setup/new.html.slim_spec.rb @@ -3,21 +3,47 @@ describe 'users/totp_setup/new.html.slim' do let(:user) { build_stubbed(:user, :signed_up) } - before do - allow(view).to receive(:current_user).and_return(user) - @code = 'D4C2L47CVZ3JJHD7' - @qrcode = 'qrcode.png' - end + context 'user is fully authenticated' do + before do + allow(view).to receive(:current_user).and_return(user) + allow(view).to receive(:user_fully_authenticated?).and_return(true) + @code = 'D4C2L47CVZ3JJHD7' + @qrcode = 'qrcode.png' + end + + it 'renders the QR code' do + render + + expect(rendered).to have_css('#qr-code', text: 'D4C2L47CVZ3JJHD7') + end - it 'renders the QR code' do - render + it 'renders the QR code image' do + render - expect(rendered).to have_css('#qr-code', text: 'D4C2L47CVZ3JJHD7') + expect(rendered).to have_css('img[src^="/images/qrcode.png"]') + end + + it 'renders a link to cancel and go back to the account page' do + render + + expect(rendered).to have_link(t('links.cancel'), href: account_path) + end end - it 'renders the QR code image' do - render + context 'user is setting up 2FA' do + it 'renders a link to choose a different option' do + user = build_stubbed(:user) + allow(view).to receive(:current_user).and_return(user) + allow(view).to receive(:user_fully_authenticated?).and_return(false) + @code = 'D4C2L47CVZ3JJHD7' + @qrcode = 'qrcode.png' + + render - expect(rendered).to have_css('img[src^="/images/qrcode.png"]') + expect(rendered).to have_link( + t('devise.two_factor_authentication.two_factor_choice_cancel'), + href: two_factor_options_path + ) + end end end From 075f2d9eefa26cd0dec3900c5f506f3c5de4ba08 Mon Sep 17 00:00:00 2001 From: Michael Ryan Date: Fri, 18 May 2018 18:57:37 -0400 Subject: [PATCH 11/40] LG-283 Fix password reset links sent to unconfirmed email address **Why**: User could lose account access if password reset link sent to incorrect or inaccessible address **How**: Remove the Devise mail helper initializer added to keep unencrypted email addresses from log files, now superfluous since all job log entries have standardized output via our custom `ActiveJob::Logging::LogSubscriber` --- app/mailers/user_mailer.rb | 3 +- config/initializers/devise_mailer_helpers.rb | 27 -------------- .../active_job_logger_patch_spec.rb | 36 +++++++++++++++++++ spec/features/users/user_edit_spec.rb | 9 +++++ .../visitors/password_recovery_spec.rb | 25 +++++++++++++ 5 files changed, 72 insertions(+), 28 deletions(-) delete mode 100644 config/initializers/devise_mailer_helpers.rb create mode 100644 spec/config/initializers/active_job_logger_patch_spec.rb diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 1f41ae6dee3..330388e7f38 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -2,7 +2,8 @@ class UserMailer < ActionMailer::Base include Mailable include LocaleHelper before_action :attach_images - default from: email_with_name(Figaro.env.email_from, Figaro.env.email_from) + default from: email_with_name(Figaro.env.email_from, Figaro.env.email_from), + reply_to: email_with_name(Figaro.env.email_from, Figaro.env.email_from) def email_changed(old_email) mail(to: old_email, subject: t('mailer.email_change_notice.subject')) diff --git a/config/initializers/devise_mailer_helpers.rb b/config/initializers/devise_mailer_helpers.rb deleted file mode 100644 index aa1cb85a475..00000000000 --- a/config/initializers/devise_mailer_helpers.rb +++ /dev/null @@ -1,27 +0,0 @@ -# This overrides the Devise mailer headers so that the recipient -# is determined based on whether or not an unconfirmed_email is present, -# as opposed to passing in the email as an argument to the job, which -# might expose it in some logs. -module Devise - module Mailers - module Helpers - def headers_for(action, opts) - headers = { - subject: subject_for(action), - to: recipient, - template_path: template_paths, - template_name: action, - }.merge(opts) - - @email = headers[:to] - headers - end - - private - - def recipient - resource.unconfirmed_email.presence || resource.email - end - end - end -end diff --git a/spec/config/initializers/active_job_logger_patch_spec.rb b/spec/config/initializers/active_job_logger_patch_spec.rb new file mode 100644 index 00000000000..ba9c12866ed --- /dev/null +++ b/spec/config/initializers/active_job_logger_patch_spec.rb @@ -0,0 +1,36 @@ +require 'rails_helper' + +# Covers config/initializers/active_job_logger_patch.rb, which overrides +# ActiveJob::Logging::LogSubscriber to standardize output and prevent sensitive +# user data from being logged. +describe ActiveJob::Logging::LogSubscriber do + it 'overrides the default job logger to output only specified parameters in JSON format' do + class FakeJob < ActiveJob::Base + def perform(sensitive_param:); end + end + + # This list corresponds to the initializer's output + permitted_attributes = %w( + timestamp + event_type + job_class + job_queue + job_id + duration + ) + + # In this case, we need to assert before the action which logs, block-style to + # match the initializer + expect(Rails.logger).to receive(:info) do |&blk| + output = JSON.parse(blk.call) + + # [Sidenote: The nested assertions don't seem to be reflected in the spec + # count--perhaps because of the uncommon block format?--but reversing them + # will show them failing as expected.] + output.keys.each { |k| expect(permitted_attributes).to include(k) } + expect(output.keys).to_not include('sensitive_param') + end + + FakeJob.perform_later(sensitive_param: '111-22-3333') + end +end diff --git a/spec/features/users/user_edit_spec.rb b/spec/features/users/user_edit_spec.rb index cc162c14cd1..e2b9095c8bc 100644 --- a/spec/features/users/user_edit_spec.rb +++ b/spec/features/users/user_edit_spec.rb @@ -15,6 +15,15 @@ expect(page).to have_current_path manage_email_path end + + scenario 'user receives confirmation message at new address' do + fill_in 'Email', with: 'new_email@test.com' + click_button 'Update' + + open_last_email + click_email_link_matching(/confirmation_token/) + expect(page).to have_content('new_email@test.com') + end end context 'editing 2FA phone number' do diff --git a/spec/features/visitors/password_recovery_spec.rb b/spec/features/visitors/password_recovery_spec.rb index 4a0db3975b1..b7a0e753f79 100644 --- a/spec/features/visitors/password_recovery_spec.rb +++ b/spec/features/visitors/password_recovery_spec.rb @@ -253,4 +253,29 @@ to(include('style-src \'self\' \'unsafe-inline\'')) end end + + context 'user resets password with unconfirmed email address edit' do + let(:original_email) { 'original_email@test.com' } + let(:user) { create(:user, :signed_up, email: original_email) } + + before do + sign_in_and_2fa_user(user) + visit manage_email_path + end + + it 'receives password reset message at original address' do + fill_in 'Email', with: 'new_email@test.com' + click_button 'Update' + + expect(page).to have_content t('devise.registrations.email_update_needs_confirmation') + + visit sign_out_url + + click_link t('links.passwords.forgot') + fill_in 'password_reset_email_form_email', with: original_email + click_button t('forms.buttons.continue') + + expect(open_last_email).to be_delivered_to(original_email) + end + end end From 501b14579a5f87f86067ed2d7e2d6622226e67e0 Mon Sep 17 00:00:00 2001 From: Michael Ryan Date: Fri, 8 Jun 2018 09:32:49 -0400 Subject: [PATCH 12/40] LG-283 Fix password reset links sent to unconfirmed email address **Why**: User could lose account access if password reset link sent to incorrect or inaccessible address **How**: Remove the Devise mail helper initializer added to keep unencrypted email addresses from log files, now superfluous since all job log entries have standardized output via our custom `ActiveJob::Logging::LogSubscriber` --- spec/features/users/user_edit_spec.rb | 7 +++++-- spec/features/visitors/password_recovery_spec.rb | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/spec/features/users/user_edit_spec.rb b/spec/features/users/user_edit_spec.rb index e2b9095c8bc..9e80b4fa87d 100644 --- a/spec/features/users/user_edit_spec.rb +++ b/spec/features/users/user_edit_spec.rb @@ -4,6 +4,8 @@ let(:user) { create(:user, :signed_up) } context 'editing email' do + let(:new_email) { 'new_email@test.com' } + before do sign_in_and_2fa_user(user) visit manage_email_path @@ -17,12 +19,13 @@ end scenario 'user receives confirmation message at new address' do - fill_in 'Email', with: 'new_email@test.com' + fill_in 'Email', with: new_email click_button 'Update' open_last_email click_email_link_matching(/confirmation_token/) - expect(page).to have_content('new_email@test.com') + + expect(page).to have_content(new_email) end end diff --git a/spec/features/visitors/password_recovery_spec.rb b/spec/features/visitors/password_recovery_spec.rb index b7a0e753f79..bb7d8bae458 100644 --- a/spec/features/visitors/password_recovery_spec.rb +++ b/spec/features/visitors/password_recovery_spec.rb @@ -256,6 +256,7 @@ context 'user resets password with unconfirmed email address edit' do let(:original_email) { 'original_email@test.com' } + let(:new_email) { 'new_email@test.com' } let(:user) { create(:user, :signed_up, email: original_email) } before do @@ -264,13 +265,12 @@ end it 'receives password reset message at original address' do - fill_in 'Email', with: 'new_email@test.com' + fill_in 'Email', with: new_email click_button 'Update' expect(page).to have_content t('devise.registrations.email_update_needs_confirmation') visit sign_out_url - click_link t('links.passwords.forgot') fill_in 'password_reset_email_form_email', with: original_email click_button t('forms.buttons.continue') From ff8e47acb5168c49c1d1c48b06e657dc1da92cde Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Wed, 16 May 2018 22:39:16 -0400 Subject: [PATCH 13/40] Update secure_headers from 3.7.3 to 6.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous versions of `secure_headers` obscured a bug that should have caused the spec on line 79 of `spec/features/users/sign_up_spec.rb` to fail. Before version 6.0.0, the gem kept track of which CSP directives weren't supported in which browsers, and parsed the user agent and overrode the app's CSP config to remove any directives that it thought were not supported. With the user agent parsing code, the `HeadlessChrome` User Agent was being interpreted by the `secure_headers` gem as a browser that does not support the `form-action` directive, which allowed the aforementioned spec to pass (because the `form-action` directive was removed from the CSP headers). I verified this using the middleware linked to in this [blog post](https://about.gitlab.com/2017/12/19/moving-to-headless-chrome/), which allows us to inspect the response headers (Selenium doesn't have support for `page.response_headers`). In the latest version of `secure_headers`, the `form-action` directive is not removed (which is a good thing), and the reason the test fails is because this is a JS test, and we have configured all JS tests to use `127.0.0.1` as the domain name, but in the last part of the test, when the user clicks on the button in the modal to cancel account creation and delete their account, it tries to go to `http://www.example.com/sign_up/start`. This is because the `UsersController` determines where to redirect based on the `cancel_link_url` of the `decorated_session`, which in this case is the `ServiceProviderDecoratedSession`, whose `cancel_link_url` was constructed using `Rails.application.routes.url_helpers`, which, for some reason, ignores the `default_url_options` defined in `ApplicationController` and defaults the host to `www.example.com`, and since the `form-action` directive doesn't allow `example.com`, the test fails to redirect. Note that the defaulting to `www.example.com` only happens in the test environment. I verified this works fine in production. I verified this by replacing `redirect_to url_after_cancellation` with `redirect_to sign_up_start_url` in `UsersController`, which allowed the test to pass. So then I tried passing in the `host` parameter to `sign_up_start_url` in the decorator, but that didn't work because Capybara uses a port with `127.0.0.1`, but the `sign_up_start_url` in the decorator doesn't include the port, and so the `form-action` directive will not allow this redirect. The port is random each time the test is run, so there's no obvious way to set the port, and `Capybara.always_include_port = false` didn't work. So then I looked at the decorator class some more, and noticed the `view_context` that gets passed to it and it struck me: we should be using `view_context.sign_up_start_url` instead of including `Rails.application.routes.url_helpers`, and 🎉! --- Gemfile | 2 +- Gemfile.lock | 6 ++---- .../service_provider_session_decorator.rb | 5 +---- app/decorators/session_decorator.rb | 11 ++++++++--- app/services/decorated_session.rb | 2 +- .../service_provider_session_decorator_spec.rb | 16 ++++++---------- spec/decorators/session_decorator_spec.rb | 8 ++++++-- spec/features/users/sign_up_spec.rb | 2 +- spec/support/features/session_helper.rb | 2 +- .../views/devise/passwords/new.html.slim_spec.rb | 3 +++ .../sign_up/registrations/new.html.slim_spec.rb | 1 + 11 files changed, 31 insertions(+), 27 deletions(-) diff --git a/Gemfile b/Gemfile index 0443d2b3269..8d05d9dedb1 100644 --- a/Gemfile +++ b/Gemfile @@ -49,7 +49,7 @@ gem 'saml_idp', git: 'https://github.com/18F/saml_idp.git', tag: 'v0.7.0-18f' gem 'sass-rails', '~> 5.0' gem 'savon' gem 'scrypt' -gem 'secure_headers', '~> 3.0' +gem 'secure_headers', '~> 6.0' gem 'sidekiq' gem 'simple_form' gem 'sinatra', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 7d228f03869..05391674335 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -543,8 +543,7 @@ GEM wasabi (~> 3.4) scrypt (3.0.5) ffi-compiler (>= 1.0, < 2.0) - secure_headers (3.7.3) - useragent + secure_headers (6.0.0) selenium-webdriver (3.11.0) childprocess (~> 0.5) rubyzip (~> 1.2) @@ -626,7 +625,6 @@ GEM unicode-display_width (1.3.0) uniform_notifier (1.11.0) user_agent_parser (2.4.1) - useragent (0.16.8) uuid (2.3.9) macaddr (~> 1.0) valid_email (0.1.0) @@ -754,7 +752,7 @@ DEPENDENCIES sass-rails (~> 5.0) savon scrypt - secure_headers (~> 3.0) + secure_headers (~> 6.0) shoulda-matchers (~> 3.0) sidekiq simple_form diff --git a/app/decorators/service_provider_session_decorator.rb b/app/decorators/service_provider_session_decorator.rb index 8ca638d4acf..366a5353906 100644 --- a/app/decorators/service_provider_session_decorator.rb +++ b/app/decorators/service_provider_session_decorator.rb @@ -1,7 +1,4 @@ class ServiceProviderSessionDecorator - include Rails.application.routes.url_helpers - include LocaleHelper - DEFAULT_LOGO = 'generic.svg'.freeze SP_ALERTS = { @@ -97,7 +94,7 @@ def sp_return_url end def cancel_link_url - sign_up_start_url(request_id: sp_session[:request_id], locale: locale_url_param) + view_context.sign_up_start_url(request_id: sp_session[:request_id]) end def sp_alert? diff --git a/app/decorators/session_decorator.rb b/app/decorators/session_decorator.rb index c161d8ddf41..95e1dece91b 100644 --- a/app/decorators/session_decorator.rb +++ b/app/decorators/session_decorator.rb @@ -1,6 +1,7 @@ class SessionDecorator - include Rails.application.routes.url_helpers - include LocaleHelper + def initialize(view_context: nil) + @view_context = view_context + end def return_to_service_provider_partial 'shared/null' @@ -31,7 +32,7 @@ def idv_hardfail4_partial end def cancel_link_url - root_url(locale: locale_url_param) + view_context.root_url end def sp_name; end @@ -51,4 +52,8 @@ def sp_alert?; end def sp_alert_name; end def sp_alert_learn_more; end + + private + + attr_reader :view_context end diff --git a/app/services/decorated_session.rb b/app/services/decorated_session.rb index 5a51d68fee7..4d69b7295e5 100644 --- a/app/services/decorated_session.rb +++ b/app/services/decorated_session.rb @@ -15,7 +15,7 @@ def call service_provider_request: service_provider_request ) else - SessionDecorator.new + SessionDecorator.new(view_context: view_context) end end diff --git a/spec/decorators/service_provider_session_decorator_spec.rb b/spec/decorators/service_provider_session_decorator_spec.rb index 083c52d1f81..8fcb26f208e 100644 --- a/spec/decorators/service_provider_session_decorator_spec.rb +++ b/spec/decorators/service_provider_session_decorator_spec.rb @@ -136,18 +136,14 @@ ) end - it 'returns sign_up_start_url with the request_id as a param' do - expect(decorator.cancel_link_url). - to eq 'http://www.example.com/sign_up/start?request_id=foo' + before do + allow(view_context).to receive(:sign_up_start_url). + and_return('https://www.example.com/sign_up/start') end - context 'in another language' do - before { I18n.locale = :fr } - - it 'keeps the language' do - expect(decorator.cancel_link_url). - to eq 'http://www.example.com/fr/sign_up/start?request_id=foo' - end + it 'returns view_context.sign_up_start_url' do + expect(decorator.cancel_link_url). + to eq 'https://www.example.com/sign_up/start' end end end diff --git a/spec/decorators/session_decorator_spec.rb b/spec/decorators/session_decorator_spec.rb index c032abe3c71..34d145ca63f 100644 --- a/spec/decorators/session_decorator_spec.rb +++ b/spec/decorators/session_decorator_spec.rb @@ -62,8 +62,12 @@ end describe '#cancel_link_url' do - it 'returns root url' do - expect(subject.cancel_link_url).to eq 'http://www.example.com/' + it 'returns view_context.root url' do + view_context = ActionController::Base.new.view_context + allow(view_context).to receive(:root_url).and_return('http://www.example.com') + decorator = SessionDecorator.new(view_context: view_context) + + expect(decorator.cancel_link_url).to eq 'http://www.example.com' end end end diff --git a/spec/features/users/sign_up_spec.rb b/spec/features/users/sign_up_spec.rb index a2495ab7652..68067da0816 100644 --- a/spec/features/users/sign_up_spec.rb +++ b/spec/features/users/sign_up_spec.rb @@ -82,7 +82,7 @@ click_on t('links.cancel') click_on t('sign_up.buttons.cancel') - expect(page).to have_current_path(sign_up_start_path) + expect(page).to have_current_path(sign_up_start_path(request_id: '123')) expect { User.find(user.id) }.to raise_error ActiveRecord::RecordNotFound end end diff --git a/spec/support/features/session_helper.rb b/spec/support/features/session_helper.rb index f492c2678d0..7a5b1ce9c18 100644 --- a/spec/support/features/session_helper.rb +++ b/spec/support/features/session_helper.rb @@ -50,7 +50,7 @@ def begin_sign_up_with_sp_and_loa(loa3:) Warden.on_next_request do |proxy| session = proxy.env['rack.session'] sp = ServiceProvider.from_issuer('http://localhost:3000') - session[:sp] = { loa3: loa3, issuer: sp.issuer } + session[:sp] = { loa3: loa3, issuer: sp.issuer, request_id: '123' } end visit account_path diff --git a/spec/views/devise/passwords/new.html.slim_spec.rb b/spec/views/devise/passwords/new.html.slim_spec.rb index a2ed8630266..cc3521e89b0 100644 --- a/spec/views/devise/passwords/new.html.slim_spec.rb +++ b/spec/views/devise/passwords/new.html.slim_spec.rb @@ -9,6 +9,9 @@ return_to_sp_url: 'www.awesomeness.com' ) view_context = ActionController::Base.new.view_context + allow(view_context).to receive(:sign_up_start_url). + and_return('https://www.example.com/sign_up/start') + @decorated_session = DecoratedSession.new( sp: @sp, view_context: view_context, diff --git a/spec/views/sign_up/registrations/new.html.slim_spec.rb b/spec/views/sign_up/registrations/new.html.slim_spec.rb index e4fbf730107..54ed039d27a 100644 --- a/spec/views/sign_up/registrations/new.html.slim_spec.rb +++ b/spec/views/sign_up/registrations/new.html.slim_spec.rb @@ -12,6 +12,7 @@ sp: nil, view_context: view_context, sp_session: {}, service_provider_request: nil ).call allow(view).to receive(:decorated_session).and_return(@decorated_session) + allow(view_context).to receive(:root_url).and_return('http://www.example.com') end it 'has a localized title' do From 51ee56baae95cb375fe8912013b5475625c3f346 Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Sun, 10 Jun 2018 16:12:26 -0400 Subject: [PATCH 14/40] LG-309 Allow dynamic service provider updates in production **Why**: So we can make changes to existing SP's or add new ones at anytime and not have it coupled to the release process. **How**: Treat our SP configurations and assets as data. Allow urls for public certs and logos and whitelist the static site or our github repo as a CMS. Create a remote settings table that caches the current version of agencies.yml and service_providers.yml seeder files and update the seeders to conditionally use the remote content. Provide a rake task that allows us to update, delete, list, and view the current remote settings. --- .rubocop.yml | 1 + .../service_provider_session_decorator.rb | 9 ++ app/decorators/session_decorator.rb | 2 + app/models/remote_setting.rb | 6 ++ app/models/service_provider.rb | 17 ++-- app/services/agency_seeder.rb | 7 +- app/services/remote_settings_service.rb | 33 +++++++ app/services/service_provider_seeder.rb | 7 +- app/views/shared/_nav_branded.html.slim | 2 +- config/initializers/secure_headers.rb | 2 +- .../20180607144007_create_remote_settings.rb | 11 +++ db/schema.rb | 11 ++- lib/tasks/remote_settings.rake | 26 ++++++ ...service_provider_session_decorator_spec.rb | 49 ++++++++++ spec/models/remote_setting_spec.rb | 23 +++++ spec/models/service_provider_spec.rb | 13 +++ spec/services/agency_seeder_spec.rb | 20 +++++ spec/services/remote_settings_service_spec.rb | 89 +++++++++++++++++++ spec/services/service_provider_seeder_spec.rb | 20 +++++ 19 files changed, 337 insertions(+), 11 deletions(-) create mode 100644 app/models/remote_setting.rb create mode 100644 app/services/remote_settings_service.rb create mode 100644 db/migrate/20180607144007_create_remote_settings.rb create mode 100644 lib/tasks/remote_settings.rake create mode 100644 spec/models/remote_setting_spec.rb create mode 100644 spec/services/remote_settings_service_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index 2b018c90d03..57cd4289ced 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -65,6 +65,7 @@ Metrics/ClassLength: - app/controllers/users/confirmations_controller.rb - app/controllers/users/sessions_controller.rb - app/controllers/devise/two_factor_authentication_controller.rb + - app/decorators/service_provider_session_decorator.rb - app/decorators/user_decorator.rb - app/services/analytics.rb - app/services/idv/session.rb diff --git a/app/decorators/service_provider_session_decorator.rb b/app/decorators/service_provider_session_decorator.rb index 8ca638d4acf..817b2b646f1 100644 --- a/app/decorators/service_provider_session_decorator.rb +++ b/app/decorators/service_provider_session_decorator.rb @@ -40,6 +40,15 @@ def sp_logo sp.logo || DEFAULT_LOGO end + def sp_logo_url + logo = sp_logo + if RemoteSettingsService.remote?(logo) + logo + else + ActionController::Base.helpers.image_path("sp-logos/#{logo}") + end + end + def return_to_service_provider_partial if sp_return_url.present? 'devise/sessions/return_to_service_provider' diff --git a/app/decorators/session_decorator.rb b/app/decorators/session_decorator.rb index c161d8ddf41..03a8074077f 100644 --- a/app/decorators/session_decorator.rb +++ b/app/decorators/session_decorator.rb @@ -40,6 +40,8 @@ def sp_agency; end def sp_logo; end + def sp_logo_url; end + def sp_redirect_uris; end def sp_return_url; end diff --git a/app/models/remote_setting.rb b/app/models/remote_setting.rb new file mode 100644 index 00000000000..968b01960ee --- /dev/null +++ b/app/models/remote_setting.rb @@ -0,0 +1,6 @@ +class RemoteSetting < ApplicationRecord + validates :url, format: { + with: + %r{\A(https://raw.githubusercontent.com/18F/identity-idp/|https://login.gov).+\z}, + } +end diff --git a/app/models/service_provider.rb b/app/models/service_provider.rb index 2135fef4b11..431dc8bb9f9 100644 --- a/app/models/service_provider.rb +++ b/app/models/service_provider.rb @@ -17,12 +17,7 @@ def metadata def ssl_cert @ssl_cert ||= begin return if cert.blank? - - cert_file = Rails.root.join('certs', 'sp', "#{cert}.crt") - - return OpenSSL::X509::Certificate.new(cert) unless File.exist?(cert_file) - - OpenSSL::X509::Certificate.new(File.read(cert_file)) + OpenSSL::X509::Certificate.new(load_cert(cert)) end end @@ -49,6 +44,16 @@ def live? private + def load_cert(cert) + if RemoteSettingsService.remote?(cert) + RemoteSettingsService.load(cert) + else + cert_file = Rails.root.join('certs', 'sp', "#{cert}.crt") + return OpenSSL::X509::Certificate.new(cert) unless File.exist?(cert_file) + File.read(cert_file) + end + end + def redirect_uris_are_parsable return if redirect_uris.blank? diff --git a/app/services/agency_seeder.rb b/app/services/agency_seeder.rb index ac0e325a9a8..53ba4db3eea 100644 --- a/app/services/agency_seeder.rb +++ b/app/services/agency_seeder.rb @@ -21,7 +21,12 @@ def run attr_reader :rails_env, :deploy_env def agencies - content = ERB.new(Rails.root.join('config', 'agencies.yml').read).result + file = remote_setting || Rails.root.join('config', 'agencies.yml').read + content = ERB.new(file).result YAML.safe_load(content).fetch(rails_env, {}) end + + def remote_setting + RemoteSetting.find_by(name: 'agencies.yml')&.contents + end end diff --git a/app/services/remote_settings_service.rb b/app/services/remote_settings_service.rb new file mode 100644 index 00000000000..bd6e48af0da --- /dev/null +++ b/app/services/remote_settings_service.rb @@ -0,0 +1,33 @@ +class RemoteSettingsService + def self.load_yml_erb(location) + result = ERB.new(load(location)).result + begin + YAML.safe_load(result.to_s) + rescue StandardError + raise "Error parsing yml file: #{location}" + end + result + end + + def self.load(location) + raise "Location must begin with 'https://': #{location}" unless remote?(location) + response = HTTParty.get( + location, headers: + { 'User-Agent' => 'Mozilla/5.0 (Windows NT 6.1; WOW64; rv:40.0) Gecko/20100101 Firefox/40.1' } + ) + raise "Error retrieving: #{location}" unless response.code == 200 + response.body + end + + def self.update_setting(name, url) + remote_setting = RemoteSetting.where(name: name).first_or_initialize + remote_setting.url = url + raise "url not whitelisted: #{url}" unless remote_setting.valid? + remote_setting.contents = RemoteSettingsService.load(remote_setting.url) + remote_setting.save + end + + def self.remote?(location) + location.to_s.starts_with?('https://') + end +end diff --git a/app/services/service_provider_seeder.rb b/app/services/service_provider_seeder.rb index 002263edc76..73f21bcb83f 100644 --- a/app/services/service_provider_seeder.rb +++ b/app/services/service_provider_seeder.rb @@ -22,10 +22,15 @@ def run attr_reader :rails_env, :deploy_env def service_providers - content = ERB.new(Rails.root.join('config', 'service_providers.yml').read).result + file = remote_setting || Rails.root.join('config', 'service_providers.yml').read + content = ERB.new(file).result YAML.safe_load(content).fetch(rails_env, {}) end + def remote_setting + RemoteSetting.find_by(name: 'service_providers.yml')&.contents + end + def write_service_provider?(config) return true if rails_env != 'production' diff --git a/app/views/shared/_nav_branded.html.slim b/app/views/shared/_nav_branded.html.slim index 58f2a541a70..e1589e52ec3 100644 --- a/app/views/shared/_nav_branded.html.slim +++ b/app/views/shared/_nav_branded.html.slim @@ -3,5 +3,5 @@ nav.nav-branded.vertical-align.bg-light-blue.center.relative alt: APP_NAME, class: 'inline-block align-middle') .px-12p.inline-block span.absolute.top-0.bottom-0.border-right.my1.sm-my2 - = image_tag(asset_url("sp-logos/#{decorated_session.sp_logo}"), height: 40, + = image_tag(decorated_session.sp_logo_url, height: 40, alt: decorated_session.sp_name, class: 'inline-block align-middle') diff --git a/config/initializers/secure_headers.rb b/config/initializers/secure_headers.rb index acb431f7d09..76c04b56f57 100644 --- a/config/initializers/secure_headers.rb +++ b/config/initializers/secure_headers.rb @@ -19,7 +19,7 @@ '*.google-analytics.com', ], font_src: ["'self'", 'data:'], - img_src: ["'self'", 'data:'], + img_src: ["'self'", 'data:', 'login.gov'], media_src: ["'self'"], object_src: ["'none'"], script_src: [ diff --git a/db/migrate/20180607144007_create_remote_settings.rb b/db/migrate/20180607144007_create_remote_settings.rb new file mode 100644 index 00000000000..06ab5f57446 --- /dev/null +++ b/db/migrate/20180607144007_create_remote_settings.rb @@ -0,0 +1,11 @@ +class CreateRemoteSettings < ActiveRecord::Migration[5.1] + def change + create_table :remote_settings do |t| + t.string "name", null: false + t.string "url", null: false + t.text "contents", null: false + t.timestamps + end + add_index :remote_settings, ["name"], name: "index_remote_settings_on_name", unique: true, using: :btree + end +end diff --git a/db/schema.rb b/db/schema.rb index 36d2cd64559..62ed4fc141c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20180601145643) do +ActiveRecord::Schema.define(version: 20180607144007) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -99,6 +99,15 @@ t.index ["user_id"], name: "index_profiles_on_user_id" end + create_table "remote_settings", force: :cascade do |t| + t.string "name", null: false + t.string "url", null: false + t.text "contents", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["name"], name: "index_remote_settings_on_name", unique: true + end + create_table "service_provider_requests", force: :cascade do |t| t.string "issuer", null: false t.string "loa", null: false diff --git a/lib/tasks/remote_settings.rake b/lib/tasks/remote_settings.rake new file mode 100644 index 00000000000..f43c6db5593 --- /dev/null +++ b/lib/tasks/remote_settings.rake @@ -0,0 +1,26 @@ +namespace :remote_settings do + task :update, [:name, :url] => [:environment] do |task, args| + RemoteSettingsService.update_setting(args[:name], args[:url]) + Kernel.puts "Update successful" + end + + task :view, [:name] => [:environment] do |task, args| + Kernel.puts RemoteSetting.find_by(name: args[:name])&.contents + end + + task list: :environment do + RemoteSetting.all.each do |rec| + Kernel.puts "name=#{rec.name} url=#{rec.url}" + end + end + + task :delete, [:name] => [:environment] do |task, args| + RemoteSetting.where(name: args[:name]).delete_all + Kernel.puts "Delete successful" + end +end + +# example invocations: +# rake "remote_settings:update[agencies.yml,https://raw.githubusercontent.com/18F/identity-idp/master/config/agencies.yml]" +# rake "remote_settings:update[agencies.yml,https://login.gov/assets/idp/config/agencies.yml" +# rake "remote_settings:update[service_providers.yml,https://raw.githubusercontent.com/18F/identity-idp/master/config/service_providers.yml]" diff --git a/spec/decorators/service_provider_session_decorator_spec.rb b/spec/decorators/service_provider_session_decorator_spec.rb index 083c52d1f81..8f2899d4e14 100644 --- a/spec/decorators/service_provider_session_decorator_spec.rb +++ b/spec/decorators/service_provider_session_decorator_spec.rb @@ -126,6 +126,55 @@ end end + describe '#sp_logo_url' do + context 'service provider has a logo' do + it 'returns the logo' do + sp_logo = 'real_logo.svg' + sp = build_stubbed(:service_provider, logo: sp_logo) + + subject = ServiceProviderSessionDecorator.new( + sp: sp, + view_context: view_context, + sp_session: {}, + service_provider_request: ServiceProviderRequest.new + ) + + expect(subject.sp_logo_url).to end_with("/sp-logos/#{sp_logo}") + end + end + + context 'service provider does not have a logo' do + it 'returns the default logo' do + sp = build_stubbed(:service_provider, logo: nil) + + subject = ServiceProviderSessionDecorator.new( + sp: sp, + view_context: view_context, + sp_session: {}, + service_provider_request: ServiceProviderRequest.new + ) + + expect(subject.sp_logo_url).to match(%r{/sp-logos/generic-.+\.svg}) + end + end + + context 'service provider has a remote logo' do + it 'returns the remote logo' do + logo = 'https://raw.githubusercontent.com/18F/identity-idp/master/app/assets/images/sp-logos/generic.svg' + sp = build_stubbed(:service_provider, logo: logo) + + subject = ServiceProviderSessionDecorator.new( + sp: sp, + view_context: view_context, + sp_session: {}, + service_provider_request: ServiceProviderRequest.new + ) + + expect(subject.sp_logo_url).to eq(logo) + end + end + end + describe '#cancel_link_url' do subject(:decorator) do ServiceProviderSessionDecorator.new( diff --git a/spec/models/remote_setting_spec.rb b/spec/models/remote_setting_spec.rb new file mode 100644 index 00000000000..d3dd53de2b9 --- /dev/null +++ b/spec/models/remote_setting_spec.rb @@ -0,0 +1,23 @@ +require 'rails_helper' + +describe RemoteSetting do + describe 'validations' do + it 'validates that our github repo is white listed' do + location = 'https://raw.githubusercontent.com/18F/identity-idp/master/config/agencies.yml' + valid_setting = RemoteSetting.create(name: 'agencies.yml', url: location, contents:'') + expect(valid_setting).to be_valid + end + + it 'validates that the login.gov static site is white listed' do + location = 'https://login.gov/agencies.yml' + valid_setting = RemoteSetting.create(name: 'agencies.yml', url: location, contents:'') + expect(valid_setting).to be_valid + end + + it 'does not accept http' do + location = 'http://login.gov/agencies.yml' + valid_setting = RemoteSetting.create(name: 'agencies.yml', url: location, contents:'') + expect(valid_setting).to_not be_valid + end + end +end diff --git a/spec/models/service_provider_spec.rb b/spec/models/service_provider_spec.rb index 0c3b01aa3ca..f36d6c44f83 100644 --- a/spec/models/service_provider_spec.rb +++ b/spec/models/service_provider_spec.rb @@ -155,4 +155,17 @@ end end end + + describe '#ssl_cert' do + it 'returns the remote setting cert' do + WebMock.allow_net_connect! + sp = create(:service_provider, issuer: 'foo', cert: 'https://raw.githubusercontent.com/18F/identity-idp/master/certs/sp/saml_test_sp.crt') + expect(sp.ssl_cert.class).to be(OpenSSL::X509::Certificate) + end + + it 'returns the local cert' do + sp = create(:service_provider, issuer: 'foo', cert: 'saml_test_sp') + expect(sp.ssl_cert.class).to be(OpenSSL::X509::Certificate) + end + end end diff --git a/spec/services/agency_seeder_spec.rb b/spec/services/agency_seeder_spec.rb index 58ead03fb9d..a02cb3798bb 100644 --- a/spec/services/agency_seeder_spec.rb +++ b/spec/services/agency_seeder_spec.rb @@ -32,5 +32,25 @@ expect(Agency.find_by(id: 1).name).to eq('CBP') end end + + context 'when agencies.yml has a remote setting' do + before do + location = 'https://raw.githubusercontent.com/18F/identity-idp/master/config/agencies.yml' + RemoteSetting.create(name: 'agencies.yml', url:location, contents: "test:\n 1:\n name: 'CBP'") + end + + it 'updates the attributes based on the current value of the yml file' do + Agency.create(id: 1, name: 'FOO') + expect(Agency.find_by(id: 1).name).to eq('FOO') + run + expect(Agency.find_by(id: 1).name).to eq('CBP') + end + + it 'insert the attributes based on the contents of the remote setting' do + run + expect(Agency.find_by(id: 1).name).to eq('CBP') + expect(Agency.count).to eq(1) + end + end end end diff --git a/spec/services/remote_settings_service_spec.rb b/spec/services/remote_settings_service_spec.rb new file mode 100644 index 00000000000..5b499f88dc6 --- /dev/null +++ b/spec/services/remote_settings_service_spec.rb @@ -0,0 +1,89 @@ +require 'rails_helper' + +describe RemoteSettingsService do + subject(:service) { RemoteSettingsService } + before { + WebMock.allow_net_connect! + } + + describe '.load_yml_erb' do + it 'loads the remote location' do + location = 'https://raw.githubusercontent.com/18F/identity-idp/master/config/agencies.yml' + expect { service.load_yml_erb(location) }.to_not raise_error + end + + it 'raises an error if the location is not https://' do + location = 'agencies.yml' + expect do + RemoteSettingsService.load_yml_erb(location) + end.to raise_error(RuntimeError, "Location must begin with 'https://': #{location}") + end + + it 'raises an error if the file is not found' do + location = 'https://raw.githubusercontent.com/18F/identity-idp/master/config/agencies' + expect do + service.load_yml_erb(location) + end.to raise_error(RuntimeError, "Error retrieving: #{location}") + end + + it 'raises an error if the file is not a yml file' do + location = 'https://github.com/18F/identity-idp/blob/master/public/images/logo.svg' + expect do + service.load_yml_erb(location) + end.to raise_error(RuntimeError, "Error parsing yml file: #{location}") + end + end + + + describe '.load' do + it 'loads the remote location' do + expect do + service.load('https://github.com/18F/identity-idp/blob/master/public/images/logo.svg') + end.to_not raise_error + + end + + it 'raises an error if the location is not https://' do + location = 'agencies.yml' + expect do + RemoteSettingsService.load(location) + end.to raise_error(RuntimeError, "Location must begin with 'https://': #{location}") + end + + it 'raises an error if the file is not found' do + location = 'https://github.com/18F/identity-idp/blob/master/public/images/logo' + expect do + service.load(location) + end.to raise_error(RuntimeError, "Error retrieving: #{location}") + end + end + + describe '.update_setting' do + it 'it creates a setting if it does not exist' do + location = 'https://raw.githubusercontent.com/18F/identity-idp/master/config/agencies.yml' + service.update_setting('agencies.yml', location) + expect(RemoteSetting.find_by(name: 'agencies.yml').url).to eq(location) + end + + it 'it updates the setting if it exists' do + location = 'https://raw.githubusercontent.com/18F/identity-idp/master/config/agencies.yml' + agencies = 'agencies.yml' + service.update_setting(agencies, location) + location2 = 'https://raw.githubusercontent.com/18F/identity-idp/master/config/agencies.yml' + service.update_setting(agencies, location2) + expect(RemoteSetting.find_by(name: agencies).url).to eq(location2) + end + end + + describe '.remote?' do + it 'returns true if it is a remote location' do + location = 'https://raw.githubusercontent.com/18F/identity-idp/master/config/agencies.yml' + expect(subject.remote?(location)).to eq(true) + end + + it 'returns false if it is not a remote location' do + location = 'agencies.yml' + expect(subject.remote?(location)).to eq(false) + end + end +end diff --git a/spec/services/service_provider_seeder_spec.rb b/spec/services/service_provider_seeder_spec.rb index 72fcfce0211..a2990dd16b3 100644 --- a/spec/services/service_provider_seeder_spec.rb +++ b/spec/services/service_provider_seeder_spec.rb @@ -112,5 +112,25 @@ end end end + + context 'when service_providers.yml has a remote setting' do + before do + location = 'https://raw.githubusercontent.com/18F/identity-idp/master/config/service_providers.yml' + RemoteSetting.create(name: 'service_providers.yml', url:location, + contents: "test:\n 'issuer1':\n friendly_name: 'name1'") + end + + it 'updates the attributes based on the current value of the yml file' do + ServiceProvider.create(issuer: 'issuer1', friendly_name: 'FOO') + expect(ServiceProvider.find_by(issuer: 'issuer1').friendly_name).to eq('FOO') + run + expect(ServiceProvider.find_by(issuer: 'issuer1').friendly_name).to eq('name1') + end + + it 'insert the service_provider based on the contents of the remote setting' do + run + expect(ServiceProvider.find_by(issuer: 'issuer1').friendly_name).to eq('name1') + end + end end end From b9d791cf7dd666e96b3eda4dbb6a345fd7662545 Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Mon, 11 Jun 2018 14:05:14 -0400 Subject: [PATCH 15/40] Update .rubocop.yml and fix offenses **Why**: Our Rubocop config mistakenly excluded the entire `spec` directory. We want our specs to adhere to our Rubocop rules, and where necessary, we can exclude individual specs. --- .rubocop.yml | 17 +++++----- app/controllers/application_controller.rb | 5 +-- app/controllers/pages_controller.rb | 2 +- .../active_job_logger_patch_spec.rb | 8 ++--- .../idv/confirmations_controller_spec.rb | 2 +- .../sign_up/passwords_controller_spec.rb | 2 +- ...entication_test_subject_controller_spec.rb | 2 +- .../piv_cac_verification_controller_spec.rb | 18 +++++------ .../users/phone_setup_controller_spec.rb | 3 -- ...ac_authentication_setup_controller_spec.rb | 25 +++++++-------- .../users/totp_setup_controller_spec.rb | 2 +- ...or_authentication_setup_controller_spec.rb | 2 +- spec/features/accessibility/idv_pages_spec.rb | 4 +-- spec/features/account_history_spec.rb | 14 ++++++-- .../idv/steps/jurisdiction_step_spec.rb | 3 +- .../openid_connect/openid_connect_spec.rb | 9 ++++-- spec/features/saml/saml_spec.rb | 3 +- .../two_factor_authentication/sign_in_spec.rb | 23 ++++++------- .../features/users/piv_cac_management_spec.rb | 14 ++++---- spec/features/users/sign_up_spec.rb | 5 +-- spec/forms/idv/jurisdiction_form_spec.rb | 8 ++--- spec/forms/two_factor_options_form_spec.rb | 6 ++-- spec/forms/user_phone_form_spec.rb | 4 +-- spec/forms/user_piv_cac_setup_form_spec.rb | 4 +-- .../user_piv_cac_verification_form_spec.rb | 4 +-- spec/lib/cloudhsm_jwt_spec.rb | 10 ++++-- spec/lib/queue_config_spec.rb | 4 +-- spec/lib/random_tools_spec.rb | 16 +++++----- spec/models/profile_spec.rb | 1 - spec/models/user_spec.rb | 4 ++- ...openid_connect_user_info_presenter_spec.rb | 3 +- ...thentication_setup_error_presenter_spec.rb | 4 +-- ...cac_authentication_setup_presenter_spec.rb | 7 ++-- .../piv_cac_authentication_presenter_spec.rb | 2 +- spec/requests/rack_attack_spec.rb | 4 +-- .../user_access_key_encryptor_spec.rb | 18 +++++------ spec/services/idv/agent_spec.rb | 4 +-- spec/services/idv/proofer_spec.rb | 12 ++++--- spec/services/personal_key_generator_spec.rb | 5 ++- spec/services/pii/attributes_spec.rb | 1 - spec/services/pii/cacher_spec.rb | 1 - spec/services/pii/nist_encryption_spec.rb | 32 +++++++++---------- spec/services/piv_cac_service_spec.rb | 26 +++++++-------- spec/services/twilio_service_spec.rb | 3 +- spec/services/x509/attribute_spec.rb | 3 -- spec/services/x509/attributes_spec.rb | 2 +- spec/support/capybara.rb | 6 ++-- spec/support/features/idv_helper.rb | 2 +- spec/support/features/idv_step_helper.rb | 2 ++ spec/support/features/session_helper.rb | 9 +++--- spec/support/idv_examples/failed_idv_job.rb | 7 ++-- .../shared_examples/remember_device.rb | 3 +- spec/views/accounts/show.html.slim_spec.rb | 2 +- .../come_back_later/show.html.slim_spec.rb | 8 ++--- .../views/users/delete/show.html.slim_spec.rb | 4 +-- 55 files changed, 203 insertions(+), 191 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 2b018c90d03..0ce87a28ea3 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -10,16 +10,14 @@ AllCops: - '**/Rakefile' - '**/Capfile' Exclude: + - 'bin/**/*' + - 'db/migrate/*' - 'db/schema.rb' - - 'node_modules/**/*' - 'lib/rspec/user_flow_formatter.rb' + - 'lib/tasks/create_test_accounts.rb' - 'lib/user_flow_exporter.rb' - - 'scripts/load_testing/*' - - 'spec/**/*' + - 'node_modules/**/*' - 'tmp/**/*' - - 'bin/**/*' - - 'db/migrate/*' - - 'lib/tasks/create_test_accounts.rb' TargetRubyVersion: 2.3 TargetRailsVersion: 5.1 UseCache: true @@ -103,10 +101,13 @@ Metrics/ModuleLength: Metrics/ParameterLists: CountKeywordArgs: false -# This is a Rails 5 feature, so it should be disabled until we upgrade +Naming/VariableName: + Exclude: + - 'spec/services/pii/nist_encryption_spec.rb' + Rails/HttpPositionalArguments: Description: 'Use keyword arguments instead of positional arguments in http method calls.' - Enabled: false + Enabled: true Include: - 'spec/**/*' - 'test/**/*' diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index aa469a2e397..9de798a682c 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -184,12 +184,13 @@ def sp_session end def render_not_found - render template: 'pages/page_not_found', layout: false, status: 404, formats: :html + render template: 'pages/page_not_found', layout: false, status: :not_found, formats: :html end def render_timeout(exception) analytics.track_event(Analytics::RESPONSE_TIMED_OUT, analytics_exception_info(exception)) - render template: 'pages/page_took_too_long', layout: false, status: 503, formats: :html + render template: 'pages/page_took_too_long', + layout: false, status: :service_unavailable, formats: :html end def analytics_exception_info(exception) diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 25ef022a016..be64d6dd404 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -4,6 +4,6 @@ class PagesController < ApplicationController skip_before_action :disable_caching def page_not_found - render layout: false, status: 404, formats: :html + render layout: false, status: :not_found, formats: :html end end diff --git a/spec/config/initializers/active_job_logger_patch_spec.rb b/spec/config/initializers/active_job_logger_patch_spec.rb index ba9c12866ed..66e7e054eb3 100644 --- a/spec/config/initializers/active_job_logger_patch_spec.rb +++ b/spec/config/initializers/active_job_logger_patch_spec.rb @@ -5,19 +5,19 @@ # user data from being logged. describe ActiveJob::Logging::LogSubscriber do it 'overrides the default job logger to output only specified parameters in JSON format' do - class FakeJob < ActiveJob::Base + class FakeJob < ApplicationJob def perform(sensitive_param:); end end # This list corresponds to the initializer's output - permitted_attributes = %w( + permitted_attributes = %w[ timestamp event_type job_class job_queue job_id duration - ) + ] # In this case, we need to assert before the action which logs, block-style to # match the initializer @@ -27,7 +27,7 @@ def perform(sensitive_param:); end # [Sidenote: The nested assertions don't seem to be reflected in the spec # count--perhaps because of the uncommon block format?--but reversing them # will show them failing as expected.] - output.keys.each { |k| expect(permitted_attributes).to include(k) } + output.each_key { |k| expect(permitted_attributes).to include(k) } expect(output.keys).to_not include('sensitive_param') end diff --git a/spec/controllers/idv/confirmations_controller_spec.rb b/spec/controllers/idv/confirmations_controller_spec.rb index aeecb8a3034..dd77041d4f0 100644 --- a/spec/controllers/idv/confirmations_controller_spec.rb +++ b/spec/controllers/idv/confirmations_controller_spec.rb @@ -37,7 +37,7 @@ def stub_idv_session address2: 'Ste 456', city: 'Anywhere', state: 'KS', - zipcode: '66666' + zipcode: '66666', } end let(:profile) { subject.idv_session.profile } diff --git a/spec/controllers/sign_up/passwords_controller_spec.rb b/spec/controllers/sign_up/passwords_controller_spec.rb index 78bc85cc484..f36d41c7944 100644 --- a/spec/controllers/sign_up/passwords_controller_spec.rb +++ b/spec/controllers/sign_up/passwords_controller_spec.rb @@ -53,7 +53,7 @@ render_views it 'instructs crawlers to not index this page' do token = 'foo token' - user = create(:user, :unconfirmed, confirmation_token: token, confirmation_sent_at: Time.zone.now) + create(:user, :unconfirmed, confirmation_token: token, confirmation_sent_at: Time.zone.now) get :new, params: { confirmation_token: token } expect(response.body).to match('') diff --git a/spec/controllers/test/piv_cac_authentication_test_subject_controller_spec.rb b/spec/controllers/test/piv_cac_authentication_test_subject_controller_spec.rb index 992e94aa506..c691bd87d70 100644 --- a/spec/controllers/test/piv_cac_authentication_test_subject_controller_spec.rb +++ b/spec/controllers/test/piv_cac_authentication_test_subject_controller_spec.rb @@ -68,7 +68,7 @@ uri.to_s end - let(:expected_token) { {'error' => 'certificate.none', 'nonce' => nonce }} + let(:expected_token) { { 'error' => 'certificate.none', 'nonce' => nonce } } let(:serialized_token) { expected_token.to_json } let(:nonce) { 'nonce' } diff --git a/spec/controllers/two_factor_authentication/piv_cac_verification_controller_spec.rb b/spec/controllers/two_factor_authentication/piv_cac_verification_controller_spec.rb index 9eef2bf379a..b77584f0a17 100644 --- a/spec/controllers/two_factor_authentication/piv_cac_verification_controller_spec.rb +++ b/spec/controllers/two_factor_authentication/piv_cac_verification_controller_spec.rb @@ -3,8 +3,7 @@ describe TwoFactorAuthentication::PivCacVerificationController do let(:user) do create(:user, :signed_up, :with_piv_or_cac, - phone: '+1 (555) 555-0000' - ) + phone: '+1 (555) 555-0000') end let(:nonce) { 'once' } @@ -17,12 +16,12 @@ allow(PivCacService).to receive(:decode_token).with('good-token').and_return( 'uuid' => user.x509_dn_uuid, 'dn' => x509_subject, - 'nonce' => nonce, + 'nonce' => nonce ) allow(PivCacService).to receive(:decode_token).with('good-other-token').and_return( 'uuid' => user.x509_dn_uuid + 'X', 'dn' => x509_subject + 'X', - 'nonce' => nonce, + 'nonce' => nonce ) allow(PivCacService).to receive(:decode_token).with('bad-token').and_return( 'uuid' => 'bad-uuid', @@ -58,7 +57,7 @@ expect(subject.current_user).to receive(:confirm_piv_cac?).and_return(true) expect(subject.current_user.reload.second_factor_attempts_count).to eq 0 - get :show, params: { token: 'good-token' } + get :show, params: { token: 'good-token' } expect(response).to redirect_to account_path expect(subject.user_session[:decrypted_x509]).to eq({ @@ -73,7 +72,7 @@ attributes: { second_factor_attempts_count: 1 } ).call - get :show, params: { token: 'good-token' } + get :show, params: { token: 'good-token' } expect(subject.current_user.reload.second_factor_attempts_count).to eq 0 end @@ -88,7 +87,7 @@ } expect(@analytics).to receive(:track_event).with(Analytics::MULTI_FACTOR_AUTH, attributes) - get :show, params: { token: 'good-token' } + get :show, params: { token: 'good-token' } end end @@ -170,9 +169,8 @@ let(:user) do create(:user, :signed_up, :with_piv_or_cac, - second_factor_locked_at: Time.zone.now - lockout_period - 1.second, - second_factor_attempts_count: 3 - ) + second_factor_locked_at: Time.zone.now - lockout_period - 1.second, + second_factor_attempts_count: 3) end describe 'when user submits an incorrect piv/cac' do diff --git a/spec/controllers/users/phone_setup_controller_spec.rb b/spec/controllers/users/phone_setup_controller_spec.rb index f09ee2c3aef..f8797d1b3e6 100644 --- a/spec/controllers/users/phone_setup_controller_spec.rb +++ b/spec/controllers/users/phone_setup_controller_spec.rb @@ -76,7 +76,6 @@ :create, params: { user_phone_form: { phone: '703-555-0100', - # otp_delivery_preference: 'voice', international_code: 'US' }, } ) @@ -110,7 +109,6 @@ :create, params: { user_phone_form: { phone: '703-555-0100', - # otp_delivery_preference: :sms, international_code: 'US' }, } ) @@ -143,7 +141,6 @@ :create, params: { user_phone_form: { phone: '703-555-0100', - # otp_delivery_preference: :sms, international_code: 'US' }, } ) diff --git a/spec/controllers/users/piv_cac_authentication_setup_controller_spec.rb b/spec/controllers/users/piv_cac_authentication_setup_controller_spec.rb index 6db6814a1e5..bc5c746dba8 100644 --- a/spec/controllers/users/piv_cac_authentication_setup_controller_spec.rb +++ b/spec/controllers/users/piv_cac_authentication_setup_controller_spec.rb @@ -1,7 +1,6 @@ require 'rails_helper' describe Users::PivCacAuthenticationSetupController do - describe 'when not signed in' do describe 'GET index' do it 'redirects to root url' do @@ -33,9 +32,7 @@ describe 'when signing in' do before(:each) { stub_sign_in_before_2fa(user) } let(:user) do - create(:user, :signed_up, :with_piv_or_cac, - phone: '+1 (555) 555-0000' - ) + create(:user, :signed_up, :with_piv_or_cac, phone: '+1 (555) 555-0000') end describe 'GET index' do @@ -58,9 +55,7 @@ context 'without associated piv/cac' do let(:user) do - create(:user, :signed_up, - phone: '+1 (555) 555-0000' - ) + create(:user, :signed_up, phone: '+1 (555) 555-0000') end before(:each) do @@ -83,7 +78,7 @@ let(:bad_token) { 'bad-token' } let(:bad_token_response) do { - 'error' => 'certificate.bad' , + 'error' => 'certificate.bad', 'nonce' => nonce, } end @@ -98,22 +93,24 @@ context 'when redirected with a good token' do it 'redirects to account page' do - get :new, params: {token: good_token} + get :new, params: { token: good_token } expect(response).to redirect_to(account_url) end it 'sets the piv/cac session information' do - get :new, params: {token: good_token} - expect(subject.user_session[:decrypted_x509]).to eq ({ + get :new, params: { token: good_token } + json = { 'subject' => 'some dn', - 'presented' => true - }.to_json) + 'presented' => true, + }.to_json + + expect(subject.user_session[:decrypted_x509]).to eq json end end context 'when redirected with an error token' do it 'renders the error template' do - get :new, params: {token: bad_token} + get :new, params: { token: bad_token } expect(response).to render_template(:error) end diff --git a/spec/controllers/users/totp_setup_controller_spec.rb b/spec/controllers/users/totp_setup_controller_spec.rb index e7139ca04b2..d564f335671 100644 --- a/spec/controllers/users/totp_setup_controller_spec.rb +++ b/spec/controllers/users/totp_setup_controller_spec.rb @@ -6,7 +6,7 @@ expect(subject).to have_actions( :before, :authenticate_user!, - [:confirm_two_factor_authenticated, if: :two_factor_enabled?], + [:confirm_two_factor_authenticated, if: :two_factor_enabled?] ) end end diff --git a/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb b/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb index 5f7323249a2..86159235ad4 100644 --- a/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb +++ b/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb @@ -50,7 +50,7 @@ voice_params = { two_factor_options_form: { selection: 'voice', - } + }, } params = ActionController::Parameters.new(voice_params) response = FormResponse.new(success: true, errors: {}, extra: { selection: 'voice' }) diff --git a/spec/features/accessibility/idv_pages_spec.rb b/spec/features/accessibility/idv_pages_spec.rb index 015fc33a270..e3e1377736d 100644 --- a/spec/features/accessibility/idv_pages_spec.rb +++ b/spec/features/accessibility/idv_pages_spec.rb @@ -43,7 +43,7 @@ end scenario 'review page' do - user = sign_in_and_2fa_user + sign_in_and_2fa_user visit idv_session_path fill_out_idv_form_ok click_idv_continue @@ -55,7 +55,7 @@ end scenario 'personal key / confirmation page' do - user = sign_in_and_2fa_user + sign_in_and_2fa_user visit idv_session_path fill_out_idv_form_ok click_idv_continue diff --git a/spec/features/account_history_spec.rb b/spec/features/account_history_spec.rb index cfae7579584..fe18a17efb2 100644 --- a/spec/features/account_history_spec.rb +++ b/spec/features/account_history_spec.rb @@ -32,8 +32,10 @@ let(:identity_with_link_timestamp) { identity_with_link.decorate.happened_at_in_words } let(:usps_mail_sent_again_timestamp) { usps_mail_sent_again_event.decorate.happened_at_in_words } let(:identity_without_link_timestamp) { identity_without_link.decorate.happened_at_in_words } - let(:new_personal_key_event) { create(:event, event_type: :new_personal_key, - user: user, created_at: Time.zone.now - 40.days) } + let(:new_personal_key_event) do + create(:event, event_type: :new_personal_key, + user: user, created_at: Time.zone.now - 40.days) + end before do sign_in_and_2fa_user(user) @@ -42,7 +44,13 @@ end scenario 'viewing account history' do - [account_created_event, usps_mail_sent_event, usps_mail_sent_again_event, new_personal_key_event].each do |event| + events = [ + account_created_event, + usps_mail_sent_event, + usps_mail_sent_again_event, + new_personal_key_event, + ] + events.each do |event| decorated_event = event.decorate expect(page).to have_content(decorated_event.event_type) expect(page).to have_content(decorated_event.happened_at_in_words) diff --git a/spec/features/idv/steps/jurisdiction_step_spec.rb b/spec/features/idv/steps/jurisdiction_step_spec.rb index af54d9a3451..fae498190c5 100644 --- a/spec/features/idv/steps/jurisdiction_step_spec.rb +++ b/spec/features/idv/steps/jurisdiction_step_spec.rb @@ -29,7 +29,8 @@ select 'Alabama', from: 'jurisdiction_state' click_idv_continue - expect(page).to have_current_path(idv_jurisdiction_fail_path(reason: :unsupported_jurisdiction)) + expect(page). + to have_current_path(idv_jurisdiction_fail_path(reason: :unsupported_jurisdiction)) expect(page).to have_content(t('idv.titles.unsupported_jurisdiction', state: 'Alabama')) end end diff --git a/spec/features/openid_connect/openid_connect_spec.rb b/spec/features/openid_connect/openid_connect_spec.rb index d70992dfa04..498f6e57f2d 100644 --- a/spec/features/openid_connect/openid_connect_spec.rb +++ b/spec/features/openid_connect/openid_connect_spec.rb @@ -574,12 +574,15 @@ def enable_cloudhsm(is_enabled) allow(Figaro.env).to receive(:cloudhsm_enabled).and_return('true') SamlIdp.configure { |config| SamlIdpEncryptionConfigurator.configure(config, true) } allow(PKCS11).to receive(:open).and_return('true') - allow_any_instance_of(SamlIdp::Configurator).to receive_message_chain(:pkcs11, :active_slots, :first, :open).and_yield(MockSession) + allow_any_instance_of(SamlIdp::Configurator). + to receive_message_chain(:pkcs11, :active_slots, :first, :open).and_yield(MockSession) allow(MockSession).to receive(:login).and_return(true) allow(MockSession).to receive(:logout).and_return(true) allow(MockSession).to receive_message_chain(:find_objects, :first).and_return(true) - allow(MockSession).to receive(:sign) do |algorithm, key, input| - JWT::Algos::Rsa.sign(JWT::Signature::ToSign.new('RS256', input, RequestKeyManager.private_key)) + allow(MockSession).to receive(:sign) do |_algorithm, _key, input| + JWT::Algos::Rsa.sign( + JWT::Signature::ToSign.new('RS256', input, RequestKeyManager.private_key) + ) end end end diff --git a/spec/features/saml/saml_spec.rb b/spec/features/saml/saml_spec.rb index 48e95366c7c..8fd216d08b5 100644 --- a/spec/features/saml/saml_spec.rb +++ b/spec/features/saml/saml_spec.rb @@ -283,7 +283,8 @@ def enable_cloudhsm(is_enabled) allow(Figaro.env).to receive(:cloudhsm_enabled).and_return('true') SamlIdp.configure { |config| SamlIdpEncryptionConfigurator.configure(config, true) } allow(PKCS11).to receive(:open).and_return('true') - allow_any_instance_of(SamlIdp::Configurator).to receive_message_chain(:pkcs11, :active_slots, :first, :open).and_yield(MockSession) + allow_any_instance_of(SamlIdp::Configurator). + to receive_message_chain(:pkcs11, :active_slots, :first, :open).and_yield(MockSession) allow(MockSession).to receive(:login).and_return(true) allow(MockSession).to receive(:logout).and_return(true) allow(MockSession).to receive_message_chain(:find_objects, :first).and_return(true) diff --git a/spec/features/two_factor_authentication/sign_in_spec.rb b/spec/features/two_factor_authentication/sign_in_spec.rb index 3b2b58adef3..ab18e115208 100644 --- a/spec/features/two_factor_authentication/sign_in_spec.rb +++ b/spec/features/two_factor_authentication/sign_in_spec.rb @@ -62,7 +62,6 @@ fill_in 'Phone', with: unsupported_phone click_send_security_code - expect(current_path).to eq phone_setup_path expect(page).to have_content t( @@ -111,7 +110,7 @@ def phone_field end def select_country_and_type_phone_number(country:, number:) - find(".selected-flag").click + find('.selected-flag').click find(".country[data-country-code='#{country}']:not(.preferred)").click phone_field.send_keys(number) end @@ -494,11 +493,10 @@ def submit_prefilled_otp_code nonce = visit_login_two_factor_piv_cac_and_get_nonce - visit_piv_cac_service(login_two_factor_piv_cac_path, { - uuid: user.x509_dn_uuid, - dn: "C=US, O=U.S. Government, OU=DoD, OU=PKI, CN=DOE.JOHN.1234", - nonce: nonce - }) + visit_piv_cac_service(login_two_factor_piv_cac_path, + uuid: user.x509_dn_uuid, + dn: 'C=US, O=U.S. Government, OU=DoD, OU=PKI, CN=DOE.JOHN.1234', + nonce: nonce) expect(current_path).to eq account_path end @@ -510,13 +508,12 @@ def submit_prefilled_otp_code nonce = visit_login_two_factor_piv_cac_and_get_nonce - visit_piv_cac_service(login_two_factor_piv_cac_path, { - uuid: user.x509_dn_uuid + 'X', - dn: "C=US, O=U.S. Government, OU=DoD, OU=PKI, CN=DOE.JOHN.12345", - nonce: nonce - }) + visit_piv_cac_service(login_two_factor_piv_cac_path, + uuid: user.x509_dn_uuid + 'X', + dn: 'C=US, O=U.S. Government, OU=DoD, OU=PKI, CN=DOE.JOHN.12345', + nonce: nonce) expect(current_path).to eq login_two_factor_piv_cac_path - expect(page).to have_content(t("devise.two_factor_authentication.invalid_piv_cac")) + expect(page).to have_content(t('devise.two_factor_authentication.invalid_piv_cac')) end end diff --git a/spec/features/users/piv_cac_management_spec.rb b/spec/features/users/piv_cac_management_spec.rb index 00c7f9f77b2..7e8c77b3d42 100644 --- a/spec/features/users/piv_cac_management_spec.rb +++ b/spec/features/users/piv_cac_management_spec.rb @@ -1,7 +1,6 @@ require 'rails_helper' feature 'PIV/CAC Management' do - def find_form(page, attributes) page.all('form').detect do |form| attributes.all? { |key, value| form[key] == value } @@ -21,7 +20,7 @@ def find_form(page, attributes) Identity.create( user_id: user.id, service_provider: 'http://localhost:3000', - last_authenticated_at: Time.now, + last_authenticated_at: Time.zone.now ) end @@ -44,11 +43,10 @@ def find_form(page, attributes) expect(page).to have_link(t('forms.piv_cac_setup.submit')) nonce = get_piv_cac_nonce_from_link(find_link(t('forms.piv_cac_setup.submit'))) - visit_piv_cac_service(setup_piv_cac_url, { - nonce: nonce, - uuid: uuid, - subject: 'SomeIgnoredSubject' - }) + visit_piv_cac_service(setup_piv_cac_url, + nonce: nonce, + uuid: uuid, + subject: 'SomeIgnoredSubject') expect(current_path).to eq account_path @@ -75,7 +73,7 @@ def find_form(page, attributes) Identity.create( user_id: user.id, service_provider: 'http://localhost:3000', - last_authenticated_at: Time.now, + last_authenticated_at: Time.zone.now ) end diff --git a/spec/features/users/sign_up_spec.rb b/spec/features/users/sign_up_spec.rb index 68067da0816..6da1222952d 100644 --- a/spec/features/users/sign_up_spec.rb +++ b/spec/features/users/sign_up_spec.rb @@ -168,11 +168,12 @@ sign_in_user(user) visit authenticator_setup_path - expect(page).to have_current_path login_two_factor_path(otp_delivery_preference: 'sms', reauthn: false) + expect(page). + to have_current_path login_two_factor_path(otp_delivery_preference: 'sms', reauthn: false) end it 'prompts to sign in when accessing authenticator_setup_path before signing in' do - user = create(:user, :signed_up) + create(:user, :signed_up) visit authenticator_setup_path expect(page).to have_current_path root_path diff --git a/spec/forms/idv/jurisdiction_form_spec.rb b/spec/forms/idv/jurisdiction_form_spec.rb index 0b13d29868f..836758edd8e 100644 --- a/spec/forms/idv/jurisdiction_form_spec.rb +++ b/spec/forms/idv/jurisdiction_form_spec.rb @@ -9,7 +9,7 @@ describe '#submit' do context 'when the form is valid' do it 'returns a successful form response' do - result = subject.submit({ state: supported_jurisdiction }) + result = subject.submit(state: supported_jurisdiction) expect(result).to be_kind_of(FormResponse) expect(result.success?).to eq(true) @@ -19,7 +19,7 @@ context 'when the form is invalid' do it 'returns an unsuccessful form response' do - result = subject.submit({ state: unsupported_jurisdiction }) + result = subject.submit(state: unsupported_jurisdiction) expect(result).to be_kind_of(FormResponse) expect(result.success?).to eq(false) @@ -30,7 +30,7 @@ describe 'presence validations' do it 'is invalid when required attribute is not present' do - subject.submit({ state: nil }) + subject.submit(state: nil) expect(subject).to_not be_valid end @@ -38,7 +38,7 @@ describe 'jurisdiction validity' do it 'populates error for unsupported jurisdiction ' do - subject.submit({ state: unsupported_jurisdiction }) + subject.submit(state: unsupported_jurisdiction) expect(subject.valid?).to eq false expect(subject.errors[:state]).to eq [I18n.t('idv.errors.unsupported_jurisdiction')] end diff --git a/spec/forms/two_factor_options_form_spec.rb b/spec/forms/two_factor_options_form_spec.rb index d353ca0a48b..44bba21f229 100644 --- a/spec/forms/two_factor_options_form_spec.rb +++ b/spec/forms/two_factor_options_form_spec.rb @@ -32,7 +32,7 @@ and_return(user_updater) expect(user_updater).to receive(:call) - result = subject.submit(selection: 'voice') + subject.submit(selection: 'voice') end end @@ -40,7 +40,7 @@ it "does not update the user's otp_delivery_preference" do expect(UpdateUser).to_not receive(:new) - result = subject.submit(selection: 'sms') + subject.submit(selection: 'sms') end end @@ -48,7 +48,7 @@ it "does not update the user's otp_delivery_preference" do expect(UpdateUser).to_not receive(:new) - result = subject.submit(selection: 'auth_app') + subject.submit(selection: 'auth_app') end end end diff --git a/spec/forms/user_phone_form_spec.rb b/spec/forms/user_phone_form_spec.rb index 721fdfc48ff..67a72c864e2 100644 --- a/spec/forms/user_phone_form_spec.rb +++ b/spec/forms/user_phone_form_spec.rb @@ -159,7 +159,7 @@ otp_delivery_preference: 'voice', } - result = subject.submit(params) + subject.submit(params) end end @@ -167,7 +167,7 @@ it "does not update the user's otp_delivery_preference" do expect(UpdateUser).to_not receive(:new) - result = subject.submit(params) + subject.submit(params) end end diff --git a/spec/forms/user_piv_cac_setup_form_spec.rb b/spec/forms/user_piv_cac_setup_form_spec.rb index 1b6b8aea083..aa36dd2f055 100644 --- a/spec/forms/user_piv_cac_setup_form_spec.rb +++ b/spec/forms/user_piv_cac_setup_form_spec.rb @@ -19,7 +19,7 @@ { 'uuid' => x509_dn_uuid, 'subject' => 'x509-subject', - 'nonce' => nonce + 'nonce' => nonce, } end @@ -122,7 +122,7 @@ end context 'when token is missing' do - let(:token) { } + let(:token) {} it 'returns FormResponse with success: false' do result = instance_double(FormResponse) diff --git a/spec/forms/user_piv_cac_verification_form_spec.rb b/spec/forms/user_piv_cac_verification_form_spec.rb index 61ef993bc2d..684b4874f98 100644 --- a/spec/forms/user_piv_cac_verification_form_spec.rb +++ b/spec/forms/user_piv_cac_verification_form_spec.rb @@ -18,7 +18,7 @@ { 'uuid' => x509_dn_uuid, 'subject' => 'x509-subject', - 'nonce' => nonce + 'nonce' => nonce, } end @@ -96,7 +96,7 @@ end context 'when token is missing' do - let(:token) { } + let(:token) {} it 'returns FormResponse with success: false' do result = instance_double(FormResponse) diff --git a/spec/lib/cloudhsm_jwt_spec.rb b/spec/lib/cloudhsm_jwt_spec.rb index aa65f39c326..c271847e3b7 100644 --- a/spec/lib/cloudhsm_jwt_spec.rb +++ b/spec/lib/cloudhsm_jwt_spec.rb @@ -54,10 +54,14 @@ def mock_cloudhsm allow(MockSession).to receive(:login).and_return(true) allow(MockSession).to receive(:logout).and_return(true) allow(MockSession).to receive_message_chain(:find_objects, :first).and_return(true) - allow(MockSession).to receive(:sign) do |algorithm, key, input| - JWT::Algos::Rsa.sign(JWT::Signature::ToSign.new('RS256', input, RequestKeyManager.private_key)) + allow(MockSession).to receive(:sign) do |_algorithm, _key, input| + JWT::Algos::Rsa.sign( + JWT::Signature::ToSign.new('RS256', input, RequestKeyManager.private_key) + ) end - allow(SamlIdp).to receive_message_chain(:config, :pkcs11, :active_slots, :first, :open).and_yield(MockSession) + allow(SamlIdp). + to receive_message_chain(:config, :pkcs11, :active_slots, :first, :open). + and_yield(MockSession) allow(SamlIdp).to receive_message_chain(:config, :cloudhsm_pin).and_return(true) end end diff --git a/spec/lib/queue_config_spec.rb b/spec/lib/queue_config_spec.rb index 78cb1a3b4a1..6078057307c 100644 --- a/spec/lib/queue_config_spec.rb +++ b/spec/lib/queue_config_spec.rb @@ -4,9 +4,9 @@ describe '.choose_queue_adapter' do it 'raises ArgumentError given invalid choice' do expect(Figaro.env).to receive(:queue_adapter_weights).and_return('{"invalid": 1}') - expect { + expect do Upaya::QueueConfig.choose_queue_adapter - }.to raise_error(ArgumentError, /Unknown queue adapter/) + end.to raise_error(ArgumentError, /Unknown queue adapter/) end it 'handles sidekiq' do diff --git a/spec/lib/random_tools_spec.rb b/spec/lib/random_tools_spec.rb index 1bd9deb5361..108da33e5b3 100644 --- a/spec/lib/random_tools_spec.rb +++ b/spec/lib/random_tools_spec.rb @@ -3,9 +3,9 @@ RSpec.describe Upaya::RandomTools do describe '#random_weighted_sample' do it 'raises ArgumentError given empty choices' do - expect { + expect do Upaya::RandomTools.random_weighted_sample({}) - }.to raise_error(ArgumentError, /empty choices/) + end.to raise_error(ArgumentError, /empty choices/) end it 'handles equal weights -- 0' do @@ -39,21 +39,21 @@ end it 'rejects non-integer weights' do - expect { + expect do Upaya::RandomTools.random_weighted_sample(a: 1.5) - }.to raise_error(ArgumentError, /integer/) + end.to raise_error(ArgumentError, /integer/) end it 'rejects negative weights' do - expect { + expect do Upaya::RandomTools.random_weighted_sample(a: 10, b: -1) - }.to raise_error(ArgumentError, />= 0/) + end.to raise_error(ArgumentError, />= 0/) end it 'rejects weights sum to zero' do - expect { + expect do Upaya::RandomTools.random_weighted_sample(a: 0) - }.to raise_error(ArgumentError, /non-zero/) + end.to raise_error(ArgumentError, /non-zero/) end end end diff --git a/spec/models/profile_spec.rb b/spec/models/profile_spec.rb index 0e65db1dd67..a64f522c80c 100644 --- a/spec/models/profile_spec.rb +++ b/spec/models/profile_spec.rb @@ -12,7 +12,6 @@ last_name: 'Doe' ) end - #let(:user_access_key) { user.unlock_user_access_key(user.password) } it { is_expected.to belong_to(:user) } it { is_expected.to have_many(:usps_confirmation_codes).dependent(:destroy) } diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index a45d41b2901..aeb6c42389b 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -324,7 +324,9 @@ describe 'deleting identities' do it 'does not delete identities when the user is destroyed preventing uuid reuse' do user = create(:user, :signed_up) - user.identities << Identity.create(service_provider: 'entity_id', session_uuid: SecureRandom.uuid) + user.identities << Identity.create( + service_provider: 'entity_id', session_uuid: SecureRandom.uuid + ) user_id = user.id user.destroy! expect(Identity.where(user_id: user_id).length).to eq 1 diff --git a/spec/presenters/openid_connect_user_info_presenter_spec.rb b/spec/presenters/openid_connect_user_info_presenter_spec.rb index 83e1634ca8f..9b722fc34d4 100644 --- a/spec/presenters/openid_connect_user_info_presenter_spec.rb +++ b/spec/presenters/openid_connect_user_info_presenter_spec.rb @@ -29,7 +29,7 @@ context 'when a piv/cac was used as second factor' do let(:x509) do { - subject: x509_subject + subject: x509_subject, } end @@ -71,7 +71,6 @@ end end end - end context 'when there is decrypted loa3 session data in redis' do diff --git a/spec/presenters/piv_cac_authentication_setup_error_presenter_spec.rb b/spec/presenters/piv_cac_authentication_setup_error_presenter_spec.rb index 121595efaa6..b952156ed1b 100644 --- a/spec/presenters/piv_cac_authentication_setup_error_presenter_spec.rb +++ b/spec/presenters/piv_cac_authentication_setup_error_presenter_spec.rb @@ -37,13 +37,13 @@ end describe '#title' do - let(:expected_title) { t('titles.piv_cac_setup.' + error ) } + let(:expected_title) { t('titles.piv_cac_setup.' + error) } it { expect(presenter.title).to eq expected_title } end describe '#heading' do - let(:expected_heading) { t('headings.piv_cac_setup.' + error ) } + let(:expected_heading) { t('headings.piv_cac_setup.' + error) } it { expect(presenter.heading).to eq expected_heading } end diff --git a/spec/presenters/piv_cac_authentication_setup_presenter_spec.rb b/spec/presenters/piv_cac_authentication_setup_presenter_spec.rb index ecbb20ff8fa..b33c28cf626 100644 --- a/spec/presenters/piv_cac_authentication_setup_presenter_spec.rb +++ b/spec/presenters/piv_cac_authentication_setup_presenter_spec.rb @@ -3,18 +3,17 @@ describe PivCacAuthenticationSetupPresenter do let(:presenter) { described_class.new(form) } let(:form) do - OpenStruct.new( - ) + OpenStruct.new end describe '#title' do - let(:expected_title) { t('titles.piv_cac_setup.new' ) } + let(:expected_title) { t('titles.piv_cac_setup.new') } it { expect(presenter.title).to eq expected_title } end describe '#heading' do - let(:expected_heading) { t('headings.piv_cac_setup.new' ) } + let(:expected_heading) { t('headings.piv_cac_setup.new') } it { expect(presenter.heading).to eq expected_heading } end diff --git a/spec/presenters/two_factor_auth_code/piv_cac_authentication_presenter_spec.rb b/spec/presenters/two_factor_auth_code/piv_cac_authentication_presenter_spec.rb index 5ad624c5fef..a3d8a1f2181 100644 --- a/spec/presenters/two_factor_auth_code/piv_cac_authentication_presenter_spec.rb +++ b/spec/presenters/two_factor_auth_code/piv_cac_authentication_presenter_spec.rb @@ -9,7 +9,7 @@ def presenter_with(arguments = {}, view = ActionController::Base.new.view_contex end let(:user_email) { 'user@example.com' } - let(:reauthn) { } + let(:reauthn) {} let(:presenter) { presenter_with(reauthn: reauthn, user_email: user_email) } describe '#header' do diff --git a/spec/requests/rack_attack_spec.rb b/spec/requests/rack_attack_spec.rb index af52c0ed728..8597ca7d97d 100644 --- a/spec/requests/rack_attack_spec.rb +++ b/spec/requests/rack_attack_spec.rb @@ -205,7 +205,7 @@ end end - context 'when number of logins per stripped/downcased email + ip is higher than limit per period' do + context 'when number of logins per email + ip is higher than limit per period' do it 'throttles with a custom response' do analytics = instance_double(Analytics) allow(Analytics).to receive(:new).and_return(analytics) @@ -213,7 +213,7 @@ (logins_per_email_and_ip_limit + 1).times do |index| post '/', params: { - user: { email: index % 2 == 0 ? 'test@example.com' : ' test@EXAMPLE.com ' }, + user: { email: index.even? ? 'test@example.com' : ' test@EXAMPLE.com ' }, }, headers: { REMOTE_ADDR: '1.2.3.4' } end diff --git a/spec/services/encryption/encryptors/user_access_key_encryptor_spec.rb b/spec/services/encryption/encryptors/user_access_key_encryptor_spec.rb index 3f83443cbdd..f5cef70078c 100644 --- a/spec/services/encryption/encryptors/user_access_key_encryptor_spec.rb +++ b/spec/services/encryption/encryptors/user_access_key_encryptor_spec.rb @@ -57,20 +57,20 @@ end it 'can decrypt contents created by different user access keys if the password is the same' do - uak_1 = Encryption::UserAccessKey.new(password: password, salt: salt) - uak_2 = Encryption::UserAccessKey.new(password: password, salt: salt) - payload_1 = described_class.new(uak_1).encrypt(plaintext) - payload_2 = described_class.new(uak_2).encrypt(plaintext) + uak1 = Encryption::UserAccessKey.new(password: password, salt: salt) + uak2 = Encryption::UserAccessKey.new(password: password, salt: salt) + payload1 = described_class.new(uak1).encrypt(plaintext) + payload2 = described_class.new(uak2).encrypt(plaintext) - expect(payload_1).to_not eq(payload_2) + expect(payload1).to_not eq(payload2) expect(user_access_key).to receive(:unlock).twice.and_call_original - result_1 = subject.decrypt(payload_1) - result_2 = subject.decrypt(payload_2) + result1 = subject.decrypt(payload1) + result2 = subject.decrypt(payload2) - expect(result_1).to eq(plaintext) - expect(result_2).to eq(plaintext) + expect(result1).to eq(plaintext) + expect(result2).to eq(plaintext) end end end diff --git a/spec/services/idv/agent_spec.rb b/spec/services/idv/agent_spec.rb index 0d6e7944972..6a5ced0f146 100644 --- a/spec/services/idv/agent_spec.rb +++ b/spec/services/idv/agent_spec.rb @@ -86,7 +86,7 @@ errors: {}, messages: [resolution_message, state_id_message], success: true, - exception: nil, + exception: nil ) end end @@ -99,7 +99,7 @@ errors: { bad: ['stuff'] }, messages: [failed_message], success: false, - exception: nil, + exception: nil ) end end diff --git a/spec/services/idv/proofer_spec.rb b/spec/services/idv/proofer_spec.rb index 43a643254cf..93c0d841666 100644 --- a/spec/services/idv/proofer_spec.rb +++ b/spec/services/idv/proofer_spec.rb @@ -163,7 +163,7 @@ let(:vendors) { { bar: class_double('Proofer::Base') } } it 'does raises an error' do - expect { subject }.to raise_error("No proofer vendor configured for stage(s): foo") + expect { subject }.to raise_error('No proofer vendor configured for stage(s): foo') end end end @@ -200,20 +200,21 @@ before do expect(config).to receive(:mock_fallback).and_return(false) expect(config).to receive(:raise_on_missing_proofers).and_return(true) - expect(described_class).to receive(:loaded_vendors).and_return(loaded_vendors, loaded_vendors) + expect(described_class). + to receive(:loaded_vendors).and_return(loaded_vendors, loaded_vendors) end context 'when a stage is missing an external vendor' do let(:stages) { %i[foo baz] } it 'raises' do - expect { subject }.to raise_error("No proofer vendor configured for stage(s): baz") + expect { subject }.to raise_error('No proofer vendor configured for stage(s): baz') end end context 'when all stages have vendors' do it 'maps the vendors, ignoring non-configured ones' do - expect(subject).to eq({ foo: loaded_vendors.second }) + expect(subject).to eq(foo: loaded_vendors.second) end end end @@ -242,7 +243,8 @@ before do expect(config).to receive(:mock_fallback).and_return(false) expect(config).to receive(:raise_on_missing_proofers).and_return(false) - expect(described_class).to receive(:loaded_vendors).and_return(loaded_vendors, loaded_vendors) + expect(described_class). + to receive(:loaded_vendors).and_return(loaded_vendors, loaded_vendors) end context 'when a stage is missing an external vendor' do diff --git a/spec/services/personal_key_generator_spec.rb b/spec/services/personal_key_generator_spec.rb index d51acb649cd..0740657cb84 100644 --- a/spec/services/personal_key_generator_spec.rb +++ b/spec/services/personal_key_generator_spec.rb @@ -46,7 +46,10 @@ def stub_random_phrase generator = PersonalKeyGenerator.new(user) generator.create - encrypted_recovery_code_data = JSON.parse(user.encrypted_recovery_code_digest, symbolize_names: true) + encrypted_recovery_code_data = JSON.parse( + user.encrypted_recovery_code_digest, symbolize_names: true + ) + expect( encrypted_recovery_code_data[:encryption_key] ).to eq(user.personal_key.split('.').first) diff --git a/spec/services/pii/attributes_spec.rb b/spec/services/pii/attributes_spec.rb index 2bc19b365fb..6baa45322ee 100644 --- a/spec/services/pii/attributes_spec.rb +++ b/spec/services/pii/attributes_spec.rb @@ -1,7 +1,6 @@ require 'rails_helper' describe Pii::Attributes do - # let(:user_access_key) { Encryption::UserAccessKey.new(password: 'sekrit', salt: SecureRandom.uuid) } let(:password) { 'I am the password' } describe '#new_from_hash' do diff --git a/spec/services/pii/cacher_spec.rb b/spec/services/pii/cacher_spec.rb index f0d9e8f94b3..36b0e0d3ca3 100644 --- a/spec/services/pii/cacher_spec.rb +++ b/spec/services/pii/cacher_spec.rb @@ -45,7 +45,6 @@ # Create a new user object to drop the memoized encrypted attributes user_id = user.id reloaded_user = User.find(user_id) - reloaded_profile = user.profiles.first described_class.new(reloaded_user, user_session).save(password) diff --git a/spec/services/pii/nist_encryption_spec.rb b/spec/services/pii/nist_encryption_spec.rb index e95190f7d58..cddea5dc114 100644 --- a/spec/services/pii/nist_encryption_spec.rb +++ b/spec/services/pii/nist_encryption_spec.rb @@ -3,22 +3,22 @@ # duplicated code in order to explicitly show the algorithm at work. describe 'NIST Encryption Model' do -# Generate and store a 128-bit salt S. -# Z1, Z2 = scrypt(S, password) # split 256-bit output into two halves -# Generate random R. -# D = KMS_GCM_Encrypt(key=server_secret, plaintext=R) ^ Z1 -# E = hash( Z2 + R ) -# F = hash(E) -# C = GCM_Encrypt(key = E, plaintext=PII) #occurs outside AWS-KMS -# Store F in password file, and store C and D. -# -# To decrypt PII and to verify passwords: -# Compute Z1’, Z2’ = scrypt(S, password’) -# R’ = KMS_GCM_Decrypt(key=server_secret, ciphertext=(D ^ Z1*)). -# E’ = hash( Z2’ + R’) -# F’ = hash(E’) -# Check to see if F’ matches the entry in the password file; if so, allow the login. -# plaintext_PII = GCM_Decrypt(key=E’, ciphertext = C) + # Generate and store a 128-bit salt S. + # Z1, Z2 = scrypt(S, password) # split 256-bit output into two halves + # Generate random R. + # D = KMS_GCM_Encrypt(key=server_secret, plaintext=R) ^ Z1 + # E = hash( Z2 + R ) + # F = hash(E) + # C = GCM_Encrypt(key = E, plaintext=PII) #occurs outside AWS-KMS + # Store F in password file, and store C and D. + # + # To decrypt PII and to verify passwords: + # Compute Z1, Z2 = scrypt(S, password) + # R = KMS_GCM_Decrypt(key=server_secret, ciphertext=(D ^ Z1)). + # E = hash(Z2 + R) + # F = hash(E) + # Check to see if F matches the entry in the password file; if so, allow the login. + # plaintext_PII = GCM_Decrypt(key=E, ciphertext = C) before do allow(FeatureManagement).to receive(:use_kms?).and_return(true) diff --git a/spec/services/piv_cac_service_spec.rb b/spec/services/piv_cac_service_spec.rb index 9032ec6aa52..be793758788 100644 --- a/spec/services/piv_cac_service_spec.rb +++ b/spec/services/piv_cac_service_spec.rb @@ -10,17 +10,17 @@ end it 'raises an error if no token provided' do - expect { + expect do PivCacService.decode_token - }.to raise_error ArgumentError + end.to raise_error ArgumentError end it 'returns the test data' do token = 'TEST:{"uuid":"hijackedUUID","dn":"hijackedDN"}' - expect(PivCacService.decode_token(token)).to eq({ + expect(PivCacService.decode_token(token)).to eq( 'uuid' => 'hijackedUUID', 'dn' => 'hijackedDN' - }) + ) end end @@ -30,7 +30,7 @@ end it 'returns an error' do - expect(PivCacService.decode_token('foo')).to eq({ 'error' => 'service.disabled' }) + expect(PivCacService.decode_token('foo')).to eq('error' => 'service.disabled') end end @@ -41,9 +41,9 @@ end it 'raises an error if no token provided' do - expect { + expect do PivCacService.decode_token - }.to raise_error ArgumentError + end.to raise_error ArgumentError end describe 'when configured with a user-facing endpoint' do @@ -96,18 +96,18 @@ end it 'returns the decoded JSON from the target service' do - expect(PivCacService.decode_token('foo')).to eq({ + expect(PivCacService.decode_token('foo')).to eq( 'dn' => 'dn', 'uuid' => 'uuid' - }) + ) end describe 'with test data' do it 'returns an error' do token = 'TEST:{"uuid":"hijackedUUID","dn":"hijackedDN"}' - expect(PivCacService.decode_token(token)).to eq({ + expect(PivCacService.decode_token(token)).to eq( 'error' => 'token.bad' - }) + ) end end end @@ -130,9 +130,9 @@ it 'returns an error' do token = 'foo' - expect(PivCacService.decode_token(token)).to eq({ + expect(PivCacService.decode_token(token)).to eq( 'error' => 'token.bad' - }) + ) end end end diff --git a/spec/services/twilio_service_spec.rb b/spec/services/twilio_service_spec.rb index 4902f7b82e9..f1a72e9c66d 100644 --- a/spec/services/twilio_service_spec.rb +++ b/spec/services/twilio_service_spec.rb @@ -78,7 +78,8 @@ raw_message = 'Unable to create record: Account not authorized to call +123456789012.' error_code = '21215' status_code = 400 - sanitized_message = "[HTTP #{status_code}] #{error_code} : Unable to create record: Account " \ + sanitized_message = "[HTTP #{status_code}] #{error_code} : " \ + "Unable to create record: Account " \ "not authorized to call +12345#######.\n\n" service = TwilioService.new diff --git a/spec/services/x509/attribute_spec.rb b/spec/services/x509/attribute_spec.rb index f23062645b6..6dac3e9611f 100644 --- a/spec/services/x509/attribute_spec.rb +++ b/spec/services/x509/attribute_spec.rb @@ -4,8 +4,6 @@ let(:x509_subject) { 'O=US, OU=DoD, CN=John.Doe.1234' } subject { described_class.new(raw: x509_subject) } - - # rubocop:disable UnneededInterpolation describe 'delegation' do it 'delegates to raw' do expect(subject.blank?).to eq false @@ -15,5 +13,4 @@ expect(subject).to eq x509_subject end end - # rubocop:enable UnneededInterpolation end diff --git a/spec/services/x509/attributes_spec.rb b/spec/services/x509/attributes_spec.rb index f4b22cade00..80df613441a 100644 --- a/spec/services/x509/attributes_spec.rb +++ b/spec/services/x509/attributes_spec.rb @@ -12,7 +12,7 @@ it 'initializes from complex Hash' do x509 = described_class.new_from_hash( - subject: { raw: 'O=US, OU=DoD, CN=José', norm: 'O=US, OU=DoD, CN=Jose' }, + subject: { raw: 'O=US, OU=DoD, CN=José', norm: 'O=US, OU=DoD, CN=Jose' } ) expect(x509.subject.to_s).to eq 'O=US, OU=DoD, CN=José' diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb index 649b34e2dcd..a8205e5f44f 100644 --- a/spec/support/capybara.rb +++ b/spec/support/capybara.rb @@ -1,7 +1,7 @@ require 'capybara/rspec' require 'capybara-screenshot/rspec' require 'rack_session_access/capybara' -require "selenium/webdriver" +require 'selenium/webdriver' Capybara.register_driver :headless_chrome do |app| capabilities = Selenium::WebDriver::Remote::Capabilities.chrome( @@ -9,8 +9,8 @@ ) Capybara::Selenium::Driver.new app, - browser: :chrome, - desired_capabilities: capabilities + browser: :chrome, + desired_capabilities: capabilities end Capybara.javascript_driver = :headless_chrome diff --git a/spec/support/features/idv_helper.rb b/spec/support/features/idv_helper.rb index 40284773e87..74c5a877e9e 100644 --- a/spec/support/features/idv_helper.rb +++ b/spec/support/features/idv_helper.rb @@ -88,7 +88,7 @@ def click_idv_cancel click_on t('idv.buttons.cancel') end - def complete_idv_profile_ok(user, password = user_password) + def complete_idv_profile_ok(_user, password = user_password) fill_out_idv_form_ok click_idv_continue click_idv_continue diff --git a/spec/support/features/idv_step_helper.rb b/spec/support/features/idv_step_helper.rb index 2a3b1fd164f..fb6484a75a1 100644 --- a/spec/support/features/idv_step_helper.rb +++ b/spec/support/features/idv_step_helper.rb @@ -71,7 +71,9 @@ def complete_idv_steps_with_phone_before_confirmation_step(user = user_with_2fa) end alias complete_idv_steps_before_review_step complete_idv_steps_with_phone_before_review_step + # rubocop:disable Metrics/LineLength alias complete_idv_steps_before_confirmation_step complete_idv_steps_with_phone_before_confirmation_step + # rubocop:enable Metrics/LineLength def complete_idv_steps_with_usps_before_review_step(user = user_with_2fa) complete_idv_steps_before_usps_step(user) diff --git a/spec/support/features/session_helper.rb b/spec/support/features/session_helper.rb index 7a5b1ce9c18..d3beb46f646 100644 --- a/spec/support/features/session_helper.rb +++ b/spec/support/features/session_helper.rb @@ -107,9 +107,8 @@ def user_with_2fa def user_with_piv_cac create(:user, :signed_up, :with_piv_or_cac, - phone: '+1 (555) 555-0000', - password: VALID_PASSWORD - ) + phone: '+1 (555) 555-0000', + password: VALID_PASSWORD) end def confirm_last_user @@ -142,8 +141,8 @@ def sign_in_live_with_piv_cac(user = user_with_piv_cac) visit login_two_factor_piv_cac_path stub_piv_cac_service visit_piv_cac_service( - dn: "C=US, O=U.S. Government, OU=DoD, OU=PKI, CN=DOE.JOHN.1234", - uuid: user.x509_dn_uuid, + dn: 'C=US, O=U.S. Government, OU=DoD, OU=PKI, CN=DOE.JOHN.1234', + uuid: user.x509_dn_uuid ) end diff --git a/spec/support/idv_examples/failed_idv_job.rb b/spec/support/idv_examples/failed_idv_job.rb index 8355165469b..e9efa5bb055 100644 --- a/spec/support/idv_examples/failed_idv_job.rb +++ b/spec/support/idv_examples/failed_idv_job.rb @@ -50,7 +50,8 @@ fill_out_phone_form_ok('5202691958') if step == :phone click_idv_continue - Timecop.travel (Figaro.env.async_job_refresh_max_wait_seconds.to_i + 1).seconds + seconds_to_travel = (Figaro.env.async_job_refresh_max_wait_seconds.to_i + 1).seconds + Timecop.travel seconds_to_travel visit current_path end @@ -81,16 +82,18 @@ end end + # rubocop:disable Lint/HandleExceptions def stub_idv_job_to_raise_error_in_background(idv_job_class) allow(Idv::Agent).to receive(:new).and_raise('this is a test error') allow(idv_job_class).to receive(:perform_now).and_wrap_original do |perform_now, *args| begin perform_now.call(*args) - rescue StandardError => err + rescue StandardError # Swallow the error so it does not get re-raised by the job end end end + # rubocop:enable Lint/HandleExceptions def stub_idv_job_to_timeout_in_background(idv_job_class) allow(idv_job_class).to receive(:perform_now) diff --git a/spec/support/shared_examples/remember_device.rb b/spec/support/shared_examples/remember_device.rb index d45f13b64e3..25f1d717911 100644 --- a/spec/support/shared_examples/remember_device.rb +++ b/spec/support/shared_examples/remember_device.rb @@ -9,7 +9,8 @@ it 'requires 2FA on sign in after expiration' do user = remember_device_and_sign_out_user - Timecop.travel (Figaro.env.remember_device_expiration_days.to_i + 1).days.from_now do + days_to_travel = (Figaro.env.remember_device_expiration_days.to_i + 1).days.from_now + Timecop.travel days_to_travel do sign_in_user(user) expect(current_path).to eq(login_two_factor_path(otp_delivery_preference: :sms)) diff --git a/spec/views/accounts/show.html.slim_spec.rb b/spec/views/accounts/show.html.slim_spec.rb index 9be748282d7..d380e69ef7e 100644 --- a/spec/views/accounts/show.html.slim_spec.rb +++ b/spec/views/accounts/show.html.slim_spec.rb @@ -32,7 +32,7 @@ expect(rendered).to have_content t('account.items.delete_your_account', app: APP_NAME) expect(rendered). - to have_link(t('account.links.delete_account'), href: account_delete_path ) + to have_link(t('account.links.delete_account'), href: account_delete_path) end end diff --git a/spec/views/idv/come_back_later/show.html.slim_spec.rb b/spec/views/idv/come_back_later/show.html.slim_spec.rb index 8936f8ac829..91559aa8fe7 100644 --- a/spec/views/idv/come_back_later/show.html.slim_spec.rb +++ b/spec/views/idv/come_back_later/show.html.slim_spec.rb @@ -24,8 +24,8 @@ render expect(rendered).to have_content( strip_tags(t( - 'idv.messages.come_back_later_sp_html', - sp: @decorated_session.sp_name + 'idv.messages.come_back_later_sp_html', + sp: @decorated_session.sp_name )) ) end @@ -59,8 +59,8 @@ render expect(rendered).to have_content( strip_tags(t( - 'idv.messages.come_back_later_no_sp_html', - app: APP_NAME + 'idv.messages.come_back_later_no_sp_html', + app: APP_NAME )) ) end diff --git a/spec/views/users/delete/show.html.slim_spec.rb b/spec/views/users/delete/show.html.slim_spec.rb index e2d39889709..5fb0bcbfa52 100644 --- a/spec/views/users/delete/show.html.slim_spec.rb +++ b/spec/views/users/delete/show.html.slim_spec.rb @@ -1,8 +1,8 @@ require 'rails_helper' describe 'users/delete/show.html.slim' do - let(:user) {build_stubbed(:user, :signed_up)} - let(:decorated_user) {user.decorate} + let(:user) { build_stubbed(:user, :signed_up) } + let(:decorated_user) { user.decorate } before do allow(user).to receive(:decorate).and_return(decorated_user) From 2bba1df4db56ea13fb2e1c818da0fe238e1bb169 Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Tue, 12 Jun 2018 20:54:51 -0400 Subject: [PATCH 16/40] LG-367 Update certificate for Secret Service PIX sp in production **Why**: USSS has issued a new cert. **How**: Replace the old cert. --- certs/sp/usss_prod.crt | 44 +++++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/certs/sp/usss_prod.crt b/certs/sp/usss_prod.crt index dfe346617e9..80b3181a517 100644 --- a/certs/sp/usss_prod.crt +++ b/certs/sp/usss_prod.crt @@ -1,22 +1,26 @@ -----BEGIN CERTIFICATE----- -MIIDnjCCAoagAwIBAgIQjLwoT+vtBa9Ktbn99+V7sjANBgkqhkiG9w0BAQwFADA9 -MQ0wCwYDVQQKEwRVU1NTMQwwCgYDVQQLEwNVQVQxHjAcBgNVBAMTFXBpeC5zZWNy -ZXRzZXJ2aWNlLmdvdjAeFw0xODA0MjMxODAxMjJaFw0yODEyMzEwNDAwMDBaMD0x -DTALBgNVBAoTBFVTU1MxDDAKBgNVBAsTA1VBVDEeMBwGA1UEAxMVcGl4LnNlY3Jl -dHNlcnZpY2UuZ292MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv/ET -i8nRC/v3lkRSrgst6b7NWDpZ7dezjKIv6tjXz96OsovtT49KgI4RSGqgVowLN1j8 -nkhfj8leSHju5P6HkME8//HgZB9LAPyokj7hbUwmOH1wHFVf+W7RvuWCd9dE+WdF -FoysRsuaJmtPbz/9e+37FE/gWpu5ZCLXqDuoskTw13F30DBQDBtckT3VAf5mO+IA -YIkUnj+0RsZtvrmuTyfSitHHHzAVPRcyAv18w84WEcb2Rhu5LQmL8jUmUpCMRw8T -nKJYNnRoLgPL/Rec9swB286WtbTHJ8CAPBhfcr2TBQLGgIAu+z1d+S4zRyW2Ud5e -OJ39RpxojddB6vXrKQIDAQABo4GZMIGWMA8GA1UdEwEB/wQFMAMCAQAwEwYDVR0l -BAwwCgYIKwYBBQUHAwEwbgYDVR0BBGcwZYAQwkVmKJAsLAg6HeZIoZZwM6E/MD0x -DTALBgNVBAoTBFVTU1MxDDAKBgNVBAsTA1VBVDEeMBwGA1UEAxMVcGl4LnNlY3Jl -dHNlcnZpY2UuZ292ghCMvChP6+0Fr0q1uf335XuyMA0GCSqGSIb3DQEBDAUAA4IB -AQBhfLsBJBlzc0G19SfAYd30QmkrHW8cGtGaYdHA5QLahxhXWLx2tCh/RmYRbiOM -FCV8fvutGqqS9xZk5hWrkXTpogHQgPQu2b/emv6bmxR+o2cfxmFkMqP4T/fTAcW3 -JWGX5DGliO7+lnK0lQA68mt7DTSsJC70C6YYJqNAfUwKWsm+t4zH/p/HgcbGQB5k -rhsEiTTXzzVqq5v0nVGkVqp9Ha1ptbC203Mz1t23LU5dlh6HpkeKkmQ2Zlqkx4MV -OKQzsDbN7LPm1WXfApYsNm8rIjCDOtinH437GTG+/531IfmpgOT8glK8s1hV165G -NwbrX52CYX4TR+/I7nVFynOG +MIIEUDCCA9egAwIBAgITawAErwFberaAczUHmgAAAASvATAKBggqhkjOPQQDAzBr +MRMwEQYKCZImiZPyLGQBGRYDR09WMRMwEQYKCZImiZPyLGQBGRYDREhTMRQwEgYK +CZImiZPyLGQBGRYEVVNTUzEVMBMGCgmSJomT8ixkARkWBVNTTkVUMRIwEAYDVQQD +EwlTU05FVC1DQTIwHhcNMTgwNTAyMTQ1MDQ5WhcNMjAwNTAxMTQ1MDQ5WjByMQsw +CQYDVQQGEwJVUzELMAkGA1UECBMCREMxEzARBgNVBAcTCldhc2hpbmd0b24xFjAU +BgNVBAoTDVVTIEdvdmVybm1lbnQxKTAnBgNVBAMTIFByb3RlY3RpdmUgSW50ZWxs +aWdlbmNlIEV4Y2hhbmdlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA +wyClYaClJIQMLvnTVzXHRBarJFl+majYhRO80Fhd/4AKoDOOTxN9TRNhLMwy5Icz +M0OD4EB/ldQKuKKm8YzTL8gpxeOUv/RCk6CFBAvuEvhNIlQ1RAMeD98U6pKbNI0o +hP5k6j6URh0EAUjQih7B59J2cNN5IoTdqMU8Mj7el5boabum3iIX9fB3WH83Q68H +rtaKadMiY4MgPgzv+VMxTeTUUiXeNUbvEbqRxBoQGNC9elIBY7Nz7XZcQ+A09Gr9 +ZRDdIZRMdgiGGeQMmflnN/Q5/2BHDOLCufTAiI968PBwnxiDObaUAji5izqQX7QL +SbuP6wM7ocn9CajClug2/QIDAQABo4IBhjCCAYIwPgYJKwYBBAGCNxUHBDEwLwYn +KwYBBAGCNxUIg5mdbIHhp0OE/ZsZgo+DX4Ozrx6BVoX3/zqEvL0/AgFkAgENMBMG +A1UdJQQMMAoGCCsGAQUFBwMBMA4GA1UdDwEB/wQEAwIFoDAbBgkrBgEEAYI3FQoE +DjAMMAoGCCsGAQUFBwMBMB0GA1UdDgQWBBQ6SpdMHI1fVx1qeRg4Dxe3G786pzAg +BgNVHREEGTAXghVwaXguc2VjcmV0c2VydmljZS5nb3YwHwYDVR0jBBgwFoAUQdrX +SUGY7WYr29K6AKWWlTXrZaUwUQYDVR0fBEowSDBGoESgQoZAaHR0cDovLzE1d2Fz +MDItc3NuZXQuU1NORVQuVVNTUy5ESFMuR09WL0NlcnRFbnJvbGwvU1NORVQtQ0Ey +LmNybDBJBggrBgEFBQcBAQQ9MDswOQYIKwYBBQUHMAGGLWh0dHBzOi8vMTV3YXMw +Mi1zc25ldC5zc25ldC51c3NzLmRocy5nb3Yvb2NzcDAKBggqhkjOPQQDAwNnADBk +AjAsgIw22KiDgW//2eHmWscNtM+fTh1bdbY7OvA6dgfqv0JjECM5tz3wun8FVHgp +b1UCME9nVmEDgwiALy0xau8n7/LkL8GaBp9q4qIylP1e2UID+swlChDI5L7LcQMC +VNrUjw== -----END CERTIFICATE----- From eb5046493d852f8c3d36456c575f2f0cf3934e24 Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Tue, 12 Jun 2018 22:22:48 -0400 Subject: [PATCH 17/40] LG-368 Account History should log when password changed from reset Why: A user should be aware of sensitive account changes How: Create a new password change event when a user resets their password. --- app/controllers/users/reset_passwords_controller.rb | 4 +++- spec/controllers/users/reset_passwords_controller_spec.rb | 2 ++ spec/features/account_history_spec.rb | 6 ++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/controllers/users/reset_passwords_controller.rb b/app/controllers/users/reset_passwords_controller.rb index 348989fe823..77777f833b1 100644 --- a/app/controllers/users/reset_passwords_controller.rb +++ b/app/controllers/users/reset_passwords_controller.rb @@ -111,8 +111,8 @@ def build_user end def handle_successful_password_reset + create_user_event(:password_changed, resource) update_user - mark_profile_inactive flash[:notice] = t('devise.passwords.updated_not_active') if is_flashing_format? @@ -137,6 +137,8 @@ def update_user attributes = { password: user_params[:password] } attributes[:confirmed_at] = Time.zone.now unless resource.confirmed? UpdateUser.new(user: resource, attributes: attributes).call + + mark_profile_inactive end def mark_profile_inactive diff --git a/spec/controllers/users/reset_passwords_controller_spec.rb b/spec/controllers/users/reset_passwords_controller_spec.rb index d31353be89d..4cbcbda5b0a 100644 --- a/spec/controllers/users/reset_passwords_controller_spec.rb +++ b/spec/controllers/users/reset_passwords_controller_spec.rb @@ -183,6 +183,8 @@ expect(@analytics).to have_received(:track_event). with(Analytics::PASSWORD_RESET_PASSWORD, analytics_hash) + expect(user.events.password_changed.size).to be 1 + expect(response).to redirect_to new_user_session_path expect(flash[:notice]).to eq t('devise.passwords.updated_not_active') expect(user.reload.confirmed_at).to eq old_confirmed_at diff --git a/spec/features/account_history_spec.rb b/spec/features/account_history_spec.rb index fe18a17efb2..80959fc4ba1 100644 --- a/spec/features/account_history_spec.rb +++ b/spec/features/account_history_spec.rb @@ -36,6 +36,10 @@ create(:event, event_type: :new_personal_key, user: user, created_at: Time.zone.now - 40.days) end + let(:password_changed_event) do + create(:event, event_type: :password_changed, + user: user, created_at: Time.zone.now - 30.days) + end before do sign_in_and_2fa_user(user) @@ -49,6 +53,7 @@ usps_mail_sent_event, usps_mail_sent_again_event, new_personal_key_event, + password_changed_event, ] events.each do |event| decorated_event = event.decorate @@ -81,5 +86,6 @@ def build_account_history identity_with_link identity_without_link new_personal_key_event + password_changed_event end end From b55f65f3d688e12f8489bd51d6abaf301cc61d4a Mon Sep 17 00:00:00 2001 From: James Smith Date: Wed, 13 Jun 2018 09:41:02 -0400 Subject: [PATCH 18/40] [LG-358] Allow piv/cac as 2fa for account creation **Why**: Some of our users may only have a piv/cac available. We want them able to create accounts with piv/cac as their primary 2fa option. **How**: We add piv/cac as an option in the list of 2fa options during account creation. --- ..._authentication_test_subject_controller.rb | 3 +- .../otp_verification_controller.rb | 2 +- ...piv_cac_authentication_setup_controller.rb | 7 +- .../two_factor_authentication_controller.rb | 4 +- ..._factor_authentication_setup_controller.rb | 8 +++ app/forms/two_factor_options_form.rb | 4 ++ app/models/service_provider.rb | 4 ++ app/models/user.rb | 13 ++-- .../two_factor_options_presenter.rb | 49 ++++++++++++++ .../index.html.slim | 43 ++++-------- config/application.yml.example | 2 + ...o_factor_authentication_controller_spec.rb | 10 +++ ...or_authentication_setup_controller_spec.rb | 14 ++++ spec/features/users/sign_up_spec.rb | 36 ++++++++++ spec/models/service_provider_spec.rb | 65 +++++++++++-------- 15 files changed, 199 insertions(+), 65 deletions(-) create mode 100644 app/presenters/two_factor_options_presenter.rb diff --git a/app/controllers/test/piv_cac_authentication_test_subject_controller.rb b/app/controllers/test/piv_cac_authentication_test_subject_controller.rb index eeea57c35a0..60fa7523534 100644 --- a/app/controllers/test/piv_cac_authentication_test_subject_controller.rb +++ b/app/controllers/test/piv_cac_authentication_test_subject_controller.rb @@ -36,7 +36,8 @@ def must_be_in_development end def token_from_params - error, subject = params.slice(:error, :subject) + error = params[:error] + subject = params[:subject] if error.present? with_nonce(error: error).to_json diff --git a/app/controllers/two_factor_authentication/otp_verification_controller.rb b/app/controllers/two_factor_authentication/otp_verification_controller.rb index 71ae6bbf3fe..b0d0f07070b 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 confirmation_context? || current_user.two_factor_enabled? + return if confirmation_context? || current_user.phone_enabled? redirect_to phone_setup_url end diff --git a/app/controllers/users/piv_cac_authentication_setup_controller.rb b/app/controllers/users/piv_cac_authentication_setup_controller.rb index 43c85bcd6ab..f145f8e9621 100644 --- a/app/controllers/users/piv_cac_authentication_setup_controller.rb +++ b/app/controllers/users/piv_cac_authentication_setup_controller.rb @@ -3,7 +3,8 @@ class PivCacAuthenticationSetupController < ApplicationController include UserAuthenticator include PivCacConcern - before_action :confirm_two_factor_authenticated + before_action :authenticate_user! + before_action :confirm_two_factor_authenticated, if: :two_factor_enabled? before_action :authorize_piv_cac_setup, only: :new before_action :authorize_piv_cac_disable, only: :delete @@ -30,6 +31,10 @@ def delete private + def two_factor_enabled? + current_user.two_factor_enabled? + end + def process_piv_cac_setup result = user_piv_cac_form.submit analytics.track_event(Analytics::USER_REGISTRATION_PIV_CAC_ENABLED, result.to_h) diff --git a/app/controllers/users/two_factor_authentication_controller.rb b/app/controllers/users/two_factor_authentication_controller.rb index 1947ab7941c..db1a17a40c7 100644 --- a/app/controllers/users/two_factor_authentication_controller.rb +++ b/app/controllers/users/two_factor_authentication_controller.rb @@ -7,8 +7,10 @@ class TwoFactorAuthenticationController < ApplicationController def show if current_user.totp_enabled? redirect_to login_two_factor_authenticator_url - elsif current_user.two_factor_enabled? + elsif current_user.phone_enabled? validate_otp_delivery_preference_and_send_code + elsif current_user.piv_cac_enabled? + redirect_to login_two_factor_piv_cac_url else redirect_to two_factor_options_url end diff --git a/app/controllers/users/two_factor_authentication_setup_controller.rb b/app/controllers/users/two_factor_authentication_setup_controller.rb index 9501da62289..07b65001cda 100644 --- a/app/controllers/users/two_factor_authentication_setup_controller.rb +++ b/app/controllers/users/two_factor_authentication_setup_controller.rb @@ -7,6 +7,7 @@ class TwoFactorAuthenticationSetupController < ApplicationController def index @two_factor_options_form = TwoFactorOptionsForm.new(current_user) + @presenter = two_factor_options_presenter analytics.track_event(Analytics::USER_REGISTRATION_2FA_SETUP_VISIT) end @@ -18,12 +19,17 @@ def create if result.success? process_valid_form else + @presenter = two_factor_options_presenter render :index end end private + def two_factor_options_presenter + TwoFactorOptionsPresenter.new(current_user, current_sp) + end + def authorize_2fa_setup if user_fully_authenticated? redirect_to account_url @@ -38,6 +44,8 @@ def process_valid_form redirect_to phone_setup_url when 'auth_app' redirect_to authenticator_setup_url + when 'piv_cac' + redirect_to setup_piv_cac_url end end diff --git a/app/forms/two_factor_options_form.rb b/app/forms/two_factor_options_form.rb index 5473549adb1..6ca9a0b8bec 100644 --- a/app/forms/two_factor_options_form.rb +++ b/app/forms/two_factor_options_form.rb @@ -19,6 +19,10 @@ def submit(params) FormResponse.new(success: success, errors: errors.messages, extra: extra_analytics_attributes) end + def selected?(type) + type == (selection || 'sms') + end + private attr_accessor :user diff --git a/app/models/service_provider.rb b/app/models/service_provider.rb index 2135fef4b11..2c5ead6cdb3 100644 --- a/app/models/service_provider.rb +++ b/app/models/service_provider.rb @@ -47,6 +47,10 @@ def live? active? && approved? end + def piv_cac_available? + PivCacService.piv_cac_available_for_agency?(agency) + end + private def redirect_uris_are_parsable diff --git a/app/models/user.rb b/app/models/user.rb index 467775908e8..5fec213a7a7 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -57,22 +57,23 @@ def confirm_piv_cac?(proposed_uuid) end def piv_cac_enabled? - x509_dn_uuid.present? + FeatureManagement.piv_cac_enabled? && x509_dn_uuid.present? end def piv_cac_available? - FeatureManagement.piv_cac_enabled? && ( - piv_cac_enabled? || - identities.any?(&:piv_cac_available?) - ) + piv_cac_enabled? || identities.any?(&:piv_cac_available?) end def need_two_factor_authentication?(_request) two_factor_enabled? end + def phone_enabled? + phone.present? + end + def two_factor_enabled? - phone.present? || totp_enabled? || piv_cac_enabled? + phone_enabled? || totp_enabled? || piv_cac_enabled? end def send_two_factor_authentication_code(_code) diff --git a/app/presenters/two_factor_options_presenter.rb b/app/presenters/two_factor_options_presenter.rb new file mode 100644 index 00000000000..5ef4a5e360a --- /dev/null +++ b/app/presenters/two_factor_options_presenter.rb @@ -0,0 +1,49 @@ +class TwoFactorOptionsPresenter + include ActionView::Helpers::TranslationHelper + + attr_reader :current_user, :service_provider + + def initialize(current_user, sp) + @current_user = current_user + @service_provider = sp + end + + def title + t('titles.two_factor_setup') + end + + def heading + t('devise.two_factor_authentication.two_factor_choice') + end + + def info + t('devise.two_factor_authentication.two_factor_choice_intro') + end + + def label + t('forms.two_factor_choice.legend') + ':' + end + + def options + available_2fa_types.map do |type| + OpenStruct.new( + type: type, + label: t("devise.two_factor_authentication.two_factor_choice_options.#{type}"), + info: t("devise.two_factor_authentication.two_factor_choice_options.#{type}_info"), + selected: type == :sms + ) + end + end + + private + + def available_2fa_types + %w[sms voice auth_app] + piv_cac_if_available + end + + def piv_cac_if_available + return [] if current_user.piv_cac_enabled? + return [] unless current_user.piv_cac_available? || service_provider&.piv_cac_available? + %w[piv_cac] + end +end diff --git a/app/views/users/two_factor_authentication_setup/index.html.slim b/app/views/users/two_factor_authentication_setup/index.html.slim index c490b3e1f8f..85d136b722f 100644 --- a/app/views/users/two_factor_authentication_setup/index.html.slim +++ b/app/views/users/two_factor_authentication_setup/index.html.slim @@ -1,8 +1,7 @@ -- title t('titles.two_factor_setup') +- title @presenter.title -h1.h3.my0 = t('devise.two_factor_authentication.two_factor_choice') -p.mt-tiny.mb3 - = t('devise.two_factor_authentication.two_factor_choice_intro') +h1.h3.my0 = @presenter.heading +p.mt-tiny.mb3 = @presenter.info = simple_form_for(@two_factor_options_form, html: { autocomplete: 'off', role: 'form' }, @@ -10,31 +9,17 @@ p.mt-tiny.mb3 url: two_factor_options_path) do |f| .mb3 fieldset.m0.p0.border-none. - legend.mb1.h4.serif.bold = t('forms.two_factor_choice.legend') + ':' - label.btn-border.col-12.mb1 for="two_factor_options_form_selection_sms" - .radio - = radio_button_tag 'two_factor_options_form[selection]', :sms, true - span.indicator.mt-tiny - span.blue.bold.fs-20p - = t('devise.two_factor_authentication.two_factor_choice_options.sms') - .regular.gray-dark.fs-10p.mb-tiny - = t('devise.two_factor_authentication.two_factor_choice_options.sms_info') - label.btn-border.col-12.mb1 for="two_factor_options_form_selection_voice" - .radio - = radio_button_tag 'two_factor_options_form[selection]', :voice, false - span.indicator.mt-tiny - span.blue.bold.fs-20p - = t('devise.two_factor_authentication.two_factor_choice_options.voice') - .regular.gray-dark.fs-10p.mb-tiny - = t('devise.two_factor_authentication.two_factor_choice_options.voice_info') - label.btn-border.col-12.mb1 for="two_factor_options_form_selection_auth_app" - .radio - = radio_button_tag 'two_factor_options_form[selection]', :auth_app, false - span.indicator.mt-tiny - span.blue.bold.fs-20p - = t('devise.two_factor_authentication.two_factor_choice_options.auth_app') - .regular.gray-dark.fs-10p.mb-tiny - = t('devise.two_factor_authentication.two_factor_choice_options.auth_app_info') + legend.mb1.h4.serif.bold = @presenter.label + - @presenter.options.each do |option| + label.btn-border.col-12.mb1 for="two_factor_options_form_selection_#{option.type}" + .radio + = radio_button_tag('two_factor_options_form[selection]', + option.type, + @two_factor_options_form.selected?(option.type)) + span.indicator.mt-tiny + span.blue.bold.fs-20p = option.label + .regular.gray-dark.fs-10p.mb-tiny = option.info + = f.button :submit, t('forms.buttons.continue') = render 'shared/cancel', link: destroy_user_session_path diff --git a/config/application.yml.example b/config/application.yml.example index f36a5947355..f58993e2ccb 100644 --- a/config/application.yml.example +++ b/config/application.yml.example @@ -144,6 +144,7 @@ development: otp_valid_for: '10' password_pepper: 'f22d4b2cafac9066fe2f4416f5b7a32c6942d82f7e00740c7594a095fa8de8db17c05314be7b18a5d6dd5683e73eadf6cc95aa633e5ad9a701edb95192a6a105' password_strength_enabled: 'true' + piv_cac_agencies: '["Test Government Agency"]' piv_cac_enabled: 'true' piv_cac_service_url: 'https://localhost:8443/' piv_cac_verify_token_url: 'https://localhost:8443/' @@ -366,6 +367,7 @@ test: otp_valid_for: '10' password_pepper: 'f22d4b2cafac9066fe2f4416f5b7a32c6942d82f7e00740c7594a095fa8de8db17c05314be7b18a5d6dd5683e73eadf6cc95aa633e5ad9a701edb95192a6a105' password_strength_enabled: 'false' + piv_cac_agencies: '["Test Government Agency"]' piv_cac_enabled: 'true' piv_cac_service_url: 'https://localhost:8443/' piv_cac_verify_token_url: 'https://localhost:8443/' diff --git a/spec/controllers/users/two_factor_authentication_controller_spec.rb b/spec/controllers/users/two_factor_authentication_controller_spec.rb index efb4febbae0..66f716b905c 100644 --- a/spec/controllers/users/two_factor_authentication_controller_spec.rb +++ b/spec/controllers/users/two_factor_authentication_controller_spec.rb @@ -69,6 +69,16 @@ def index end describe '#show' do + context 'when user is piv/cac enabled' do + it 'renders the piv/cac entry screen' do + stub_sign_in_before_2fa(build(:user)) + allow(subject.current_user).to receive(:piv_cac_enabled?).and_return(true) + get :show + + expect(response).to redirect_to login_two_factor_piv_cac_path + end + end + context 'when user is TOTP enabled' do it 'renders the :confirm_totp view' do stub_sign_in_before_2fa(build(:user)) diff --git a/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb b/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb index 86159235ad4..63fe066c199 100644 --- a/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb +++ b/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb @@ -127,6 +127,20 @@ end end + context 'when the selection is piv_cac' do + it 'redirects to piv/cac setup page' do + stub_sign_in_before_2fa + + patch :create, params: { + two_factor_options_form: { + selection: 'piv_cac', + }, + } + + expect(response).to redirect_to setup_piv_cac_url + end + end + context 'when the selection is not valid' do it 'renders index page' do stub_sign_in_before_2fa diff --git a/spec/features/users/sign_up_spec.rb b/spec/features/users/sign_up_spec.rb index 6da1222952d..8e72c903b47 100644 --- a/spec/features/users/sign_up_spec.rb +++ b/spec/features/users/sign_up_spec.rb @@ -163,6 +163,42 @@ expect(page).to have_current_path account_path end + it 'does not allow a user to choose piv/cac as 2FA method during sign up' do + allow(PivCacService).to receive(:piv_cac_available_for_agency?).and_return(false) + allow(FeatureManagement).to receive(:piv_cac_enabled?).and_return(true) + begin_sign_up_with_sp_and_loa(loa3: false) + + expect(page).to have_current_path two_factor_options_path + expect(page).not_to have_content( + t('devise.two_factor_authentication.two_factor_choice_options.piv_cac') + ) + end + + context 'when piv/cac is allowed' do + it 'allows a user to choose piv/cac as 2FA method during sign up' do + allow(PivCacService).to receive(:piv_cac_available_for_agency?).and_return(true) + allow(FeatureManagement).to receive(:piv_cac_enabled?).and_return(true) + + begin_sign_up_with_sp_and_loa(loa3: false) + + expect(page).to have_current_path two_factor_options_path + expect(page).to have_content( + t('devise.two_factor_authentication.two_factor_choice_options.piv_cac') + ) + end + + it 'directs to the piv/cac setup page' do + allow(PivCacService).to receive(:piv_cac_available_for_agency?).and_return(true) + allow(FeatureManagement).to receive(:piv_cac_enabled?).and_return(true) + + begin_sign_up_with_sp_and_loa(loa3: false) + + expect(page).to have_current_path two_factor_options_path + select_2fa_option('piv_cac') + expect(page).to have_current_path setup_piv_cac_path + end + end + it 'does not bypass 2FA when accessing authenticator_setup_path if the user is 2FA enabled' do user = create(:user, :signed_up) sign_in_user(user) diff --git a/spec/models/service_provider_spec.rb b/spec/models/service_provider_spec.rb index 0c3b01aa3ca..d7252f63743 100644 --- a/spec/models/service_provider_spec.rb +++ b/spec/models/service_provider_spec.rb @@ -1,6 +1,8 @@ require 'rails_helper' describe ServiceProvider do + let(:service_provider) { ServiceProvider.from_issuer('http://localhost:3000') } + describe 'validations' do it 'validates that all redirect_uris are absolute, parsable uris' do valid_sp = build(:service_provider, redirect_uris: ['http://foo.com']) @@ -28,26 +30,23 @@ describe '#issuer' do it 'returns the constructor value' do - sp = ServiceProvider.from_issuer('http://localhost:3000') - expect(sp.issuer).to eq 'http://localhost:3000' + expect(service_provider.issuer).to eq 'http://localhost:3000' end end describe '#from_issuer' do context 'the record exists' do it 'fetches the record' do - sp = ServiceProvider.from_issuer('http://localhost:3000') - - expect(sp).to be_a ServiceProvider - expect(sp.persisted?).to eq true + expect(service_provider).to be_a ServiceProvider + expect(service_provider.persisted?).to eq true end end context 'the record does not exist' do - it 'returns NullServiceProvider' do - sp = ServiceProvider.from_issuer('no-such-issuer') + let(:service_provider) { ServiceProvider.from_issuer('no-such-issuer') } - expect(sp).to be_a NullServiceProvider + it 'returns NullServiceProvider' do + expect(service_provider).to be_a NullServiceProvider end end end @@ -55,8 +54,6 @@ describe '#metadata' do context 'when the service provider is defined in the YAML' do it 'returns a hash with symbolized attributes from YAML plus fingerprint' do - service_provider = ServiceProvider.from_issuer('http://localhost:3000') - fingerprint = { fingerprint: '40808e52ef80f92e697149e058af95f898cefd9a54d0dc2416bd607c8f9891fa', } @@ -70,13 +67,35 @@ end end + describe 'piv_cac_available?' do + context 'when the service provider is with an enabled agency' do + it 'is truthy' do + allow(Figaro.env).to receive(:piv_cac_agencies).and_return( + [service_provider.agency].to_json + ) + PivCacService.send(:reset_piv_cac_avaialable_agencies) + + expect(service_provider.piv_cac_available?).to be_truthy + end + end + + context 'when the service provider agency is not enabled' do + it 'is falsey' do + allow(Figaro.env).to receive(:piv_cac_agencies).and_return( + [service_provider.agency + 'X'].to_json + ) + PivCacService.send(:reset_piv_cac_avaialable_agencies) + + expect(service_provider.piv_cac_available?).to be_falsey + end + end + end + describe '#encryption_opts' do context 'when responses are not encrypted' do it 'returns nil' do # block_encryption is set to 'none' for this SP - sp = ServiceProvider.from_issuer('http://localhost:3000') - - expect(sp.encryption_opts).to be_nil + expect(service_provider.encryption_opts).to be_nil end end @@ -113,9 +132,7 @@ context 'when the service provider is included in the list of authorized providers' do it 'returns true' do - sp = ServiceProvider.from_issuer('http://localhost:3000') - - expect(sp.approved?).to be true + expect(service_provider.approved?).to be true end end end @@ -131,27 +148,23 @@ context 'when the service provider is approved but not active' do it 'returns false' do - sp = ServiceProvider.from_issuer('http://localhost:3000') - sp.update(active: false) + service_provider.update(active: false) - expect(sp.live?).to be false + expect(service_provider.live?).to be false end end context 'when the service provider is active and approved' do it 'returns true' do - sp = ServiceProvider.from_issuer('http://localhost:3000') - - expect(sp.live?).to be true + expect(service_provider.live?).to be true end end context 'when the service provider is active but not approved' do it 'returns false' do - sp = ServiceProvider.from_issuer('http://localhost:3000') - sp.update(approved: false) + service_provider.update(approved: false) - expect(sp.live?).to be false + expect(service_provider.live?).to be false end end end From f4adcf34dd1fcc9164c54a5f4c6a6a6369bb4b4c Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Wed, 13 Jun 2018 20:54:00 -0500 Subject: [PATCH 19/40] LG-288 Add password verifier (#2226) **Why**: To start reading from the encrypted_password_digest column instead of the other password columns. --- .../concerns/user_access_key_overrides.rb | 70 ++++++------------- app/services/encryption/password_verifier.rb | 56 +++++++++++++++ spec/models/user_spec.rb | 6 +- .../encryption/password_verifier_spec.rb | 44 ++++++++++++ spec/services/pii/nist_encryption_spec.rb | 34 +++++---- 5 files changed, 144 insertions(+), 66 deletions(-) create mode 100644 app/services/encryption/password_verifier.rb create mode 100644 spec/services/encryption/password_verifier_spec.rb diff --git a/app/models/concerns/user_access_key_overrides.rb b/app/models/concerns/user_access_key_overrides.rb index af288ec784d..a014ca0d50b 100644 --- a/app/models/concerns/user_access_key_overrides.rb +++ b/app/models/concerns/user_access_key_overrides.rb @@ -5,69 +5,39 @@ module UserAccessKeyOverrides extend ActiveSupport::Concern - attr_accessor :user_access_key - def valid_password?(password) - return false if encrypted_password.blank? - begin - unlock_user_access_key(password) - rescue Pii::EncryptionError => err - log_error(err) - return false - end - Devise.secure_compare(encrypted_password, user_access_key.encrypted_password) - end - - def unlock_user_access_key(password) - self.user_access_key = build_user_access_key(password).unlock(encryption_key) + result = Encryption::PasswordVerifier.verify( + password: password, + digest: encrypted_password_digest + ) + log_password_verification_failure unless result + result end def password=(new_password) @password = new_password - encrypt_password(@password) if @password.present? - end - - def authenticatable_salt - password_salt + return if @password.blank? + digest = Encryption::PasswordVerifier.digest(@password) + self.encrypted_password_digest = digest.to_s + # Until we drop the old columns, still write to them so that we can rollback + write_legacy_password_attributes(digest) end private - def log_error(err) + def write_legacy_password_attributes(digest) + self.encrypted_password = digest.encrypted_password + self.encryption_key = digest.encryption_key + self.password_salt = digest.password_salt + self.password_cost = digest.password_cost + end + + def log_password_verification_failure metadata = { - event: 'Pii::EncryptionError when validating password', - error: err.to_s, + event: 'Failure to validate password', uuid: uuid, timestamp: Time.zone.now, } Rails.logger.info(metadata.to_json) end - - def encrypt_password(new_password) - self.password_salt = Devise.friendly_token[0, 20] - - user_access_key = build_user_access_key(new_password, cost: nil).build - - self.encryption_key = user_access_key.encryption_key - self.password_cost = user_access_key.cost - self.encrypted_password = user_access_key.encrypted_password - self.encrypted_password_digest = build_encrypted_password_digest - end - - def build_user_access_key(password, salt: authenticatable_salt, cost: password_cost) - Encryption::UserAccessKey.new( - password: password, - salt: salt, - cost: cost - ) - end - - def build_encrypted_password_digest - { - encryption_key: encryption_key, - encrypted_password: encrypted_password, - password_cost: password_cost, - password_salt: password_salt, - }.to_json - end end diff --git a/app/services/encryption/password_verifier.rb b/app/services/encryption/password_verifier.rb new file mode 100644 index 00000000000..9ddcf577d4c --- /dev/null +++ b/app/services/encryption/password_verifier.rb @@ -0,0 +1,56 @@ +module Encryption + class PasswordVerifier + PasswordDigest = Struct.new( + :encrypted_password, + :encryption_key, + :password_salt, + :password_cost + ) do + def self.parse_from_string(digest_string) + data = JSON.parse(digest_string, symbolize_names: true) + new( + data[:encrypted_password], + data[:encryption_key], + data[:password_salt], + data[:password_cost] + ) + rescue JSON::ParserError + raise Pii::EncryptionError, 'digest contains invalid json' + end + + def to_s + { + encrypted_password: encrypted_password, + encryption_key: encryption_key, + password_salt: password_salt, + password_cost: password_cost, + }.to_json + end + end + + def self.digest(password) + salt = Devise.friendly_token[0, 20] + uak = UserAccessKey.new(password: password, salt: salt) + uak.build + PasswordDigest.new( + uak.encrypted_password, + uak.encryption_key, + salt, + uak.cost + ) + end + + def self.verify(password:, digest:) + parsed_digest = PasswordDigest.parse_from_string(digest) + uak = UserAccessKey.new( + password: password, + salt: parsed_digest.password_salt, + cost: parsed_digest.password_cost + ) + uak.unlock(parsed_digest.encryption_key) + Devise.secure_compare(uak.encrypted_password, parsed_digest.encrypted_password) + rescue Pii::EncryptionError + false + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index aeb6c42389b..ff4926dd9e9 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -410,14 +410,14 @@ end context 'when a password is updated' do - it 'encrypted_password_digest is a json string of encryption parameters' do + it 'writes encrypted_password_digest and the legacy password attributes' do user = create(:user) expected = { - encryption_key: user.encryption_key, encrypted_password: user.encrypted_password, - password_cost: user.password_cost, + encryption_key: user.encryption_key, password_salt: user.password_salt, + password_cost: user.password_cost, }.to_json expect(user.encrypted_password_digest).to eq(expected) diff --git a/spec/services/encryption/password_verifier_spec.rb b/spec/services/encryption/password_verifier_spec.rb new file mode 100644 index 00000000000..6fd9d0e6212 --- /dev/null +++ b/spec/services/encryption/password_verifier_spec.rb @@ -0,0 +1,44 @@ +require 'rails_helper' + +describe Encryption::PasswordVerifier do + describe '.digest' do + it 'creates a digest from the password' do + salt = '1' * 20 + allow(Devise).to receive(:friendly_token).and_return(salt) + + digest = described_class.digest('saltypickles') + + uak = Encryption::UserAccessKey.new(password: 'saltypickles', salt: salt) + uak.unlock(digest.encryption_key) + + expect(digest.encrypted_password).to eq(uak.encrypted_password) + expect(digest.encryption_key).to eq(uak.encryption_key) + expect(digest.password_salt).to eq(salt) + expect(digest.password_cost).to eq(uak.cost) + end + end + + describe '.verify' do + it 'returns true if the password matches' do + password = 'saltypickles' + + digest = described_class.digest(password).to_s + result = described_class.verify(password: password, digest: digest) + + expect(result).to eq(true) + end + + it 'returns false if the password does not match' do + digest = described_class.digest('saltypickles').to_s + result = described_class.verify(password: 'pepperpickles', digest: digest) + + expect(result).to eq(false) + end + + it 'returns false for nonsese' do + result = described_class.verify(password: 'saltypickles', digest: 'this is fake') + + expect(result).to eq(false) + end + end +end diff --git a/spec/services/pii/nist_encryption_spec.rb b/spec/services/pii/nist_encryption_spec.rb index cddea5dc114..21c08e82790 100644 --- a/spec/services/pii/nist_encryption_spec.rb +++ b/spec/services/pii/nist_encryption_spec.rb @@ -74,24 +74,32 @@ user = create(:user, password: password) expect(user.valid_password?(password)).to eq true - expect(user.user_access_key).to be_a Encryption::UserAccessKey - expect(user.user_access_key.random_r).to eq random_R - expect(user.encryption_key).to_not be_nil - expect(user.password_salt).to_not be_nil - hash_E = OpenSSL::Digest::SHA256.hexdigest(user.user_access_key.z2 + random_R) - hash_F = OpenSSL::Digest::SHA256.hexdigest(user.user_access_key.cek) + digest = Encryption::PasswordVerifier::PasswordDigest.parse_from_string(user.encrypted_password_digest) + user_access_key = Encryption::UserAccessKey.new( + password: password, + salt: digest.password_salt, + cost: digest.password_cost + ) + user_access_key.unlock(digest.encryption_key) + + expect(user_access_key).to be_a Encryption::UserAccessKey + expect(user_access_key.random_r).to eq random_R + expect(digest.encryption_key).to_not be_nil + expect(digest.password_salt).to_not be_nil - expect(user.encrypted_password).to eq hash_F - expect(user.user_access_key.cek).to eq hash_E - expect(user.user_access_key.encrypted_password).to eq hash_F + hash_E = OpenSSL::Digest::SHA256.hexdigest(user_access_key.z2 + random_R) + hash_F = OpenSSL::Digest::SHA256.hexdigest(user_access_key.cek) + + expect(digest.encrypted_password).to eq hash_F + expect(user_access_key.cek).to eq hash_E + expect(user_access_key.encrypted_password).to eq hash_F - user.user_access_key.unlock(user.encryption_key) - expect(user.user_access_key.cek).to eq(hash_E) + expect(user_access_key.cek).to eq(hash_E) - encrypted_D = Base64.strict_decode64(user.encryption_key) + encrypted_D = Base64.strict_decode64(digest.encryption_key) - expect(kms_prefix + xor(user.user_access_key.z1, ciphered_R)).to eq(encrypted_D) + expect(kms_prefix + xor(user_access_key.z1, ciphered_R)).to eq(encrypted_D) end end From 37af366b0398955dd0638ae62e3eec4c9c061b9e Mon Sep 17 00:00:00 2001 From: James Smith Date: Thu, 14 Jun 2018 10:51:22 -0400 Subject: [PATCH 20/40] [LG-272] Support randomizable Piv/Cac service URLs **Why**: Browsers cache information about which certificate was used with which hostname. If a bad certificate is selected, the only recourse is to shut down the browser and restart it. This leads to a bad UX. **How**: We have wildcard hostnames for the piv/cac service, so we generate a random hostname each time we redirect the browser. We try to balance between randomness and DNS caching. --- app/services/piv_cac_service.rb | 9 ++++++++- spec/services/pii/nist_encryption_spec.rb | 4 +++- spec/services/piv_cac_service_spec.rb | 21 +++++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/app/services/piv_cac_service.rb b/app/services/piv_cac_service.rb index 1acf6661666..d81d54700a8 100644 --- a/app/services/piv_cac_service.rb +++ b/app/services/piv_cac_service.rb @@ -3,6 +3,8 @@ module PivCacService class << self + RANDOM_HOSTNAME_BYTES = 2 + include Rails.application.routes.url_helpers def decode_token(token) @@ -14,7 +16,7 @@ def piv_cac_service_link(nonce) if FeatureManagement.development_and_piv_cac_entry_enabled? test_piv_cac_entry_url else - uri = URI(Figaro.env.piv_cac_service_url) + uri = URI(randomize_uri(Figaro.env.piv_cac_service_url)) # add the nonce uri.query = "nonce=#{CGI.escape(nonce)}" uri.to_s @@ -38,6 +40,11 @@ def piv_cac_available_for_agency?(agency) private + def randomize_uri(uri) + # we only support {random}, so we're going for performance here + uri.gsub('{random}') { |_| SecureRandom.hex(RANDOM_HOSTNAME_BYTES) } + end + # Only used in tests def reset_piv_cac_avaialable_agencies @piv_cac_agencies = nil diff --git a/spec/services/pii/nist_encryption_spec.rb b/spec/services/pii/nist_encryption_spec.rb index 21c08e82790..0d34956d55b 100644 --- a/spec/services/pii/nist_encryption_spec.rb +++ b/spec/services/pii/nist_encryption_spec.rb @@ -75,7 +75,9 @@ expect(user.valid_password?(password)).to eq true - digest = Encryption::PasswordVerifier::PasswordDigest.parse_from_string(user.encrypted_password_digest) + digest = Encryption::PasswordVerifier::PasswordDigest.parse_from_string( + user.encrypted_password_digest + ) user_access_key = Encryption::UserAccessKey.new( password: password, salt: digest.password_salt, diff --git a/spec/services/piv_cac_service_spec.rb b/spec/services/piv_cac_service_spec.rb index be793758788..5bcfe56abc0 100644 --- a/spec/services/piv_cac_service_spec.rb +++ b/spec/services/piv_cac_service_spec.rb @@ -3,6 +3,27 @@ describe PivCacService do include Rails.application.routes.url_helpers + describe '#randomize_uri' do + let(:result) { PivCacService.send(:randomize_uri, uri) } + + context 'when a static URL is configured' do + let(:uri) { 'http://localhost:1234/' } + + it 'returns the URL unchanged' do + expect(result).to eq uri + end + end + + context 'when a random URL is configured' do + let(:uri) { 'http://{random}.example.com/' } + + it 'returns the URL with random bytes' do + expect(result).to_not eq uri + expect(result).to match(%r{http://[0-9a-f]+\.example\.com/$}) + end + end + end + describe '#decode_token' do context 'when configured for local development' do before(:each) do From a17ef767b1b0d886bca2e544d6b4ce345405d8f4 Mon Sep 17 00:00:00 2001 From: David Corwin Date: Thu, 14 Jun 2018 08:42:01 -0700 Subject: [PATCH 21/40] LG 360 LOA1 fail states (#2231) * Add full-width layout * Add generic fail state template with new layout, update max attempts error screens --- app/assets/images/alert/fail-x.svg | 1 + app/assets/images/alert/temp-lock.svg | 1 + app/controllers/application_controller.rb | 4 + .../concerns/two_factor_authenticatable.rb | 21 +++-- app/controllers/users/sessions_controller.rb | 10 +-- app/helpers/application_helper.rb | 4 + app/presenters/failure_presenter.rb | 52 +++++++++++++ .../max_attempts_reached_presenter.rb | 63 +++++++++++++++ app/views/layouts/application.html.slim | 77 +------------------ app/views/layouts/base.html.slim | 76 ++++++++++++++++++ app/views/layouts/card_wide.html.slim | 2 +- app/views/shared/failure.html.slim | 19 +++++ .../max_login_attempts_reached.html.erb | 19 ----- .../shared/max_otp_requests_reached.html.erb | 19 ----- config/locales/devise/en.yml | 22 ++++-- config/locales/devise/es.yml | 23 +++--- config/locales/devise/fr.yml | 25 +++--- config/locales/headings/en.yml | 1 + config/locales/headings/es.yml | 1 + config/locales/headings/fr.yml | 1 + config/locales/titles/en.yml | 2 +- config/locales/titles/es.yml | 2 +- config/locales/titles/fr.yml | 2 +- spec/presenters/failure_presenter_spec.rb | 45 +++++++++++ .../max_attempts_reached_presenter_spec.rb | 63 +++++++++++++++ ...ax_login_attempts_reached.html.erb_spec.rb | 21 ----- 26 files changed, 395 insertions(+), 181 deletions(-) create mode 100644 app/assets/images/alert/fail-x.svg create mode 100644 app/assets/images/alert/temp-lock.svg create mode 100644 app/presenters/failure_presenter.rb create mode 100644 app/presenters/two_factor_auth_code/max_attempts_reached_presenter.rb create mode 100644 app/views/layouts/base.html.slim create mode 100644 app/views/shared/failure.html.slim delete mode 100644 app/views/two_factor_authentication/shared/max_login_attempts_reached.html.erb delete mode 100644 app/views/two_factor_authentication/shared/max_otp_requests_reached.html.erb create mode 100644 spec/presenters/failure_presenter_spec.rb create mode 100644 spec/presenters/max_attempts_reached_presenter_spec.rb delete mode 100644 spec/views/two_factor_authentication/shared/max_login_attempts_reached.html.erb_spec.rb diff --git a/app/assets/images/alert/fail-x.svg b/app/assets/images/alert/fail-x.svg new file mode 100644 index 00000000000..c8028d21093 --- /dev/null +++ b/app/assets/images/alert/fail-x.svg @@ -0,0 +1 @@ +fail \ No newline at end of file diff --git a/app/assets/images/alert/temp-lock.svg b/app/assets/images/alert/temp-lock.svg new file mode 100644 index 00000000000..4a6f7ac72d7 --- /dev/null +++ b/app/assets/images/alert/temp-lock.svg @@ -0,0 +1 @@ +temp-lock \ No newline at end of file diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 9de798a682c..ab0d3cc5335 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -193,6 +193,10 @@ def render_timeout(exception) layout: false, status: :service_unavailable, formats: :html end + def render_full_width(template, **opts) + render template, **opts, layout: 'base' + end + def analytics_exception_info(exception) { backtrace: Rails.backtrace_cleaner.send(:filter, exception.backtrace), diff --git a/app/controllers/concerns/two_factor_authenticatable.rb b/app/controllers/concerns/two_factor_authenticatable.rb index 7d2fdc3b948..c25a03acf6d 100644 --- a/app/controllers/concerns/two_factor_authenticatable.rb +++ b/app/controllers/concerns/two_factor_authenticatable.rb @@ -26,22 +26,21 @@ def authenticate_user def handle_second_factor_locked_user(type) analytics.track_event(Analytics::MULTI_FACTOR_AUTH_MAX_ATTEMPTS) - decorator = current_user.decorate - sign_out - render( - 'two_factor_authentication/shared/max_login_attempts_reached', - locals: { type: type, decorator: decorator } - ) + handle_max_attempts(type + '_login_attempts') end def handle_too_many_otp_sends analytics.track_event(Analytics::MULTI_FACTOR_AUTH_MAX_SENDS) - decorator = current_user.decorate - sign_out - render( - 'two_factor_authentication/shared/max_otp_requests_reached', - locals: { decorator: decorator } + handle_max_attempts('otp_requests') + end + + def handle_max_attempts(type) + presenter = TwoFactorAuthCode::MaxAttemptsReachedPresenter.new( + type, + decorated_user ) + sign_out + render_full_width('shared/failure', locals: { presenter: presenter }) end def require_current_password diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index a3af30cf38f..05557de1914 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -69,12 +69,12 @@ def auth_params end def process_locked_out_user - decorator = current_user.decorate - sign_out - render( - 'two_factor_authentication/shared/max_login_attempts_reached', - locals: { type: 'generic', decorator: decorator } + presenter = TwoFactorAuthCode::MaxAttemptsReachedPresenter.new( + 'generic_login_attempts', + current_user.decorate ) + sign_out + render_full_width('shared/failure', locals: { presenter: presenter }) end def handle_valid_authentication diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index c931272ef9a..ec2df76c6fe 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -7,6 +7,10 @@ def card_cls(cls) content_for(:card_cls) { cls } end + def background_cls(cls) + content_for(:background_cls) { cls } + end + def step_class(step, active) if active > step 'complete' diff --git a/app/presenters/failure_presenter.rb b/app/presenters/failure_presenter.rb new file mode 100644 index 00000000000..f87b28b42d5 --- /dev/null +++ b/app/presenters/failure_presenter.rb @@ -0,0 +1,52 @@ + +class FailurePresenter + attr_reader :state + + STATE_CONFIG = { + failure: { + icon: 'alert/fail-x.svg', + alt_text: 'failure', + color: 'red', + }, + locked: { + icon: 'alert/temp-lock.svg', + alt_text: 'locked', + color: 'red', + }, + warning: { + icon: 'alert/warning-lg.svg', + alt_text: 'warning', + color: 'yellow', + }, + }.freeze + + def initialize(state) + @state = state + end + + def state_icon + STATE_CONFIG.dig(state, :icon) + end + + def state_alt_text + STATE_CONFIG.dig(state, :alt_text) + end + + def state_color + STATE_CONFIG.dig(state, :color) + end + + def message; end + + def title; end + + def header; end + + def description; end + + def next_steps + [] + end + + def js; end +end diff --git a/app/presenters/two_factor_auth_code/max_attempts_reached_presenter.rb b/app/presenters/two_factor_auth_code/max_attempts_reached_presenter.rb new file mode 100644 index 00000000000..707295ac8dd --- /dev/null +++ b/app/presenters/two_factor_auth_code/max_attempts_reached_presenter.rb @@ -0,0 +1,63 @@ +module TwoFactorAuthCode + class MaxAttemptsReachedPresenter < FailurePresenter + include ActionView::Helpers::TranslationHelper + include ActionView::Helpers::UrlHelper + + attr_reader :type, :decorated_user + + COUNTDOWN_ID = 'countdown'.freeze + + T_SCOPE = 'devise.two_factor_authentication'.freeze + + def initialize(type, decorated_user) + super(:locked) + @type = type + @decorated_user = decorated_user + end + + def title + t('titles.account_locked') + end + + def header + t('titles.account_locked') + end + + def description + t("max_#{type}_reached", scope: T_SCOPE) + end + + def message + t('headings.lock_failure') + end + + def next_steps + [please_try_again, read_about_two_factor_authentication] + end + + def js + <<~JS + var test = #{decorated_user.lockout_time_remaining} * 1000; + window.LoginGov.countdownTimer(document.getElementById('#{COUNTDOWN_ID}'), test); + JS + end + + private + + def please_try_again + t(:please_try_again_html, + scope: T_SCOPE, id: COUNTDOWN_ID, + time_remaining: decorated_user.lockout_time_remaining_in_words) + end + + def read_about_two_factor_authentication + link = link_to( + t('read_about_two_factor_authentication.link', scope: T_SCOPE), + MarketingSite.help_url + ) + + t('read_about_two_factor_authentication.text_html', + scope: T_SCOPE, link: link) + end + end +end diff --git a/app/views/layouts/application.html.slim b/app/views/layouts/application.html.slim index 9cd7ae6d5d9..17aaf2fe582 100644 --- a/app/views/layouts/application.html.slim +++ b/app/views/layouts/application.html.slim @@ -1,76 +1,3 @@ -doctype html -html lang="#{I18n.locale}" class='no-js' +- background_cls 'sm-bg-light-blue' - head - meta charset='utf-8' - meta name='description' content="#{content_for?(:description) ? yield(:description) : APP_NAME}" - meta http-equiv='X-UA-Compatible' content='IE=edge' - meta name='msapplication-config' content='none' - meta[name='viewport' content='width=device-width, initial-scale=1.0'] - meta name="format-detection" content="telephone=no" - - if content_for?(:meta_refresh) - meta http-equiv="refresh" content="#{yield(:meta_refresh)}" - - if session_with_trust? || FeatureManagement.disallow_all_web_crawlers? - meta name='robots' content='noindex,nofollow' - - title - = APP_NAME - - if content_for?(:title) - = ' - ' - = yield(:title) - - == stylesheet_link_tag 'application', media: 'all' - - == javascript_include_tag 'i18n-strings' - == javascript_pack_tag 'application' - == csrf_meta_tags - - link rel='apple-touch-icon' sizes='180x180' href='/apple-touch-icon.png' - link rel='icon' type='image/png' href='/favicon-32x32.png' sizes='32x32' - link rel='icon' type='image/png' href='/favicon-16x16.png' sizes='16x16' - link rel='manifest' href='/manifest.json' - link rel='mask-icon' href='/safari-pinned-tab.svg' color='#e21c3d' - meta name='theme-color' content='#ffffff' - - - - - - if Figaro.env.google_analytics_key.present? - = render 'shared/google_analytics/page_tracking' - - if Figaro.env.newrelic_browser_key.present? && Figaro.env.newrelic_browser_app_id.present? - = render 'shared/newrelic/browser_instrumentation' - - body class="#{Rails.env}-env site sm-bg-light-blue" - .site-wrap - = render 'shared/i18n_mode' if FeatureManagement.enable_i18n_mode? - = render 'shared/no_pii_banner' if FeatureManagement.no_pii_mode? - = render 'shared/usa_banner' - - if content_for?(:nav) - = yield(:nav) - - else - = render decorated_session.nav_partial - .container - div class="px2 py2 sm-py5 sm-px6 mx-auto sm-mb5 border-box card #{yield(:card_cls)}" - = render 'shared/flashes' - == yield - = render 'shared/footer_lite' - - #session-timeout-cntnr - - if current_user - = auto_session_timeout_js - - else - = auto_session_expired_js - - - if FeatureManagement.enable_i18n_mode? - == javascript_pack_tag 'i18n-mode' - - - if Figaro.env.participate_in_dap == 'true' && !session_with_trust? - = render 'shared/dap_analytics' += render template: 'layouts/base' diff --git a/app/views/layouts/base.html.slim b/app/views/layouts/base.html.slim new file mode 100644 index 00000000000..acfe5b7cca3 --- /dev/null +++ b/app/views/layouts/base.html.slim @@ -0,0 +1,76 @@ +doctype html +html lang="#{I18n.locale}" class='no-js' + + head + meta charset='utf-8' + meta name='description' content="#{content_for?(:description) ? yield(:description) : APP_NAME}" + meta http-equiv='X-UA-Compatible' content='IE=edge' + meta name='msapplication-config' content='none' + meta[name='viewport' content='width=device-width, initial-scale=1.0'] + meta name="format-detection" content="telephone=no" + - if content_for?(:meta_refresh) + meta http-equiv="refresh" content="#{yield(:meta_refresh)}" + - if session_with_trust? || FeatureManagement.disallow_all_web_crawlers? + meta name='robots' content='noindex,nofollow' + + title + = APP_NAME + - if content_for?(:title) + = ' - ' + = yield(:title) + + == stylesheet_link_tag 'application', media: 'all' + + == javascript_include_tag 'i18n-strings' + == javascript_pack_tag 'application' + == csrf_meta_tags + + link rel='apple-touch-icon' sizes='180x180' href='/apple-touch-icon.png' + link rel='icon' type='image/png' href='/favicon-32x32.png' sizes='32x32' + link rel='icon' type='image/png' href='/favicon-16x16.png' sizes='16x16' + link rel='manifest' href='/manifest.json' + link rel='mask-icon' href='/safari-pinned-tab.svg' color='#e21c3d' + meta name='theme-color' content='#ffffff' + + + + + - if Figaro.env.google_analytics_key.present? + = render 'shared/google_analytics/page_tracking' + - if Figaro.env.newrelic_browser_key.present? && Figaro.env.newrelic_browser_app_id.present? + = render 'shared/newrelic/browser_instrumentation' + + body class="#{Rails.env}-env site #{yield(:background_cls)}" + .site-wrap + = render 'shared/i18n_mode' if FeatureManagement.enable_i18n_mode? + = render 'shared/no_pii_banner' if FeatureManagement.no_pii_mode? + = render 'shared/usa_banner' + - if content_for?(:nav) + = yield(:nav) + - else + = render decorated_session.nav_partial + .container + div class="px2 py2 sm-py5 sm-px6 mx-auto sm-mb5 border-box card #{yield(:card_cls)}" + = render 'shared/flashes' + == yield + = render 'shared/footer_lite' + + #session-timeout-cntnr + - if current_user + = auto_session_timeout_js + - else + = auto_session_expired_js + + - if FeatureManagement.enable_i18n_mode? + == javascript_pack_tag 'i18n-mode' + + - if Figaro.env.participate_in_dap == 'true' && !session_with_trust? + = render 'shared/dap_analytics' diff --git a/app/views/layouts/card_wide.html.slim b/app/views/layouts/card_wide.html.slim index 000f2efd430..12d1ac90d93 100644 --- a/app/views/layouts/card_wide.html.slim +++ b/app/views/layouts/card_wide.html.slim @@ -1,3 +1,3 @@ - card_cls 'card-wide' -= render template: 'layouts/application' += render template: 'layouts/base' diff --git a/app/views/shared/failure.html.slim b/app/views/shared/failure.html.slim new file mode 100644 index 00000000000..5031207322d --- /dev/null +++ b/app/views/shared/failure.html.slim @@ -0,0 +1,19 @@ +- title presenter.title + += image_tag(asset_url(presenter.state_icon), + alt: presenter.state_alt_text, width: 54) + +h1.h3.mb1.mt3.my0 = presenter.header + +p = presenter.description + +.col-2 + hr class="mt3 mb2 bw4 rounded border-#{presenter.state_color}" + +h2.h4.mb2.mt3.my0 = presenter.message + +- presenter.next_steps.each do |step| + p == step + +- if presenter.js + = nonced_javascript_tag presenter.js diff --git a/app/views/two_factor_authentication/shared/max_login_attempts_reached.html.erb b/app/views/two_factor_authentication/shared/max_login_attempts_reached.html.erb deleted file mode 100644 index bc4a3651a51..00000000000 --- a/app/views/two_factor_authentication/shared/max_login_attempts_reached.html.erb +++ /dev/null @@ -1,19 +0,0 @@ -<% lockout_time_in_words = decorator.lockout_time_remaining_in_words %> -<% lockout_time_remaining = decorator.lockout_time_remaining %> -<% title t('titles.account_locked') %> - -

- <%= t('titles.account_locked') %> -

-

-<%= t("devise.two_factor_authentication.max_#{type}_login_attempts_reached") %> -

-

- <%= t('devise.two_factor_authentication.please_try_again_html', - time_remaining: content_tag(:span, lockout_time_in_words, id: 'countdown')) %> -

- -<%= nonced_javascript_tag do %> - var test = <%= lockout_time_remaining %> * 1000; - window.LoginGov.countdownTimer(document.getElementById('countdown'), test); -<% end %> diff --git a/app/views/two_factor_authentication/shared/max_otp_requests_reached.html.erb b/app/views/two_factor_authentication/shared/max_otp_requests_reached.html.erb deleted file mode 100644 index c5db7d9aabe..00000000000 --- a/app/views/two_factor_authentication/shared/max_otp_requests_reached.html.erb +++ /dev/null @@ -1,19 +0,0 @@ -<% lockout_time_in_words = decorator.lockout_time_remaining_in_words %> -<% lockout_time_remaining = decorator.lockout_time_remaining %> -<% title t('titles.account_locked') %> - -

- <%= t('titles.account_locked') %> -

-

-<%= t("devise.two_factor_authentication.max_otp_requests_reached") %> -

-

- <%= t('devise.two_factor_authentication.please_try_again_html', - time_remaining: content_tag(:span, lockout_time_in_words, id: 'countdown')) %> -

- -<%= nonced_javascript_tag do %> - var test = <%= lockout_time_remaining %> * 1000; - window.LoginGov.countdownTimer(document.getElementById('countdown'), test); -<% end %> diff --git a/config/locales/devise/en.yml b/config/locales/devise/en.yml index 4e0f88be735..85e3df9b9c8 100644 --- a/config/locales/devise/en.yml +++ b/config/locales/devise/en.yml @@ -103,13 +103,16 @@ en: request a new one-time security code. invalid_personal_key: That personal key is invalid. invalid_piv_cac: That PIV/CAC is incorrect. - max_generic_login_attempts_reached: Your account is temporarily locked. - max_otp_login_attempts_reached: Your account is temporarily locked because you - have entered the one-time security code incorrectly too many times. - max_otp_requests_reached: Your account is temporarily locked because you have - requested a security code too many times. - max_personal_key_login_attempts_reached: Your account is temporarily locked - because you have entered the personal key incorrectly too many times. + max_generic_login_attempts_reached: For your security, your account is temporarily + locked. + max_otp_login_attempts_reached: For your security, your account is temporarily + locked because you have entered the one-time security code incorrectly too + many times. + max_otp_requests_reached: For your security, your account is temporarily locked + because you have requested a security code too many times. + max_personal_key_login_attempts_reached: For your security, your account is + temporarily locked because you have entered the personal key incorrectly too + many times. otp_delivery_preference: instruction: You can change this selection the next time you log in. If you entered a landline, please select "Phone call" below. @@ -141,7 +144,10 @@ en: piv_cac_header_text: Present your PIV/CAC please_confirm: Your phone number has been set. Confirm it by entering the security code below. - please_try_again_html: Please try again in %{time_remaining}. + please_try_again_html: Please try again in %{time_remaining}. + read_about_two_factor_authentication: + link: read about two-factor authentication + text_html: You can %{link} and why we use it at our Help page. totp_fallback: sms_link_text: get a code via text message text_html: If you can’t use your authenticator app right now you can %{sms_link} diff --git a/config/locales/devise/es.yml b/config/locales/devise/es.yml index 98e82ea11d7..008ce5c4413 100644 --- a/config/locales/devise/es.yml +++ b/config/locales/devise/es.yml @@ -108,14 +108,16 @@ es: de nuevo o solicitar un nuevo código de seguridad de sólo un uso. invalid_personal_key: Esa clave personal no es válida. invalid_piv_cac: NOT TRANSLATED YET - max_generic_login_attempts_reached: Su cuenta está bloqueada temporalmente. - max_otp_login_attempts_reached: Su cuenta ha sido bloqueada temporalmente porque - ha ingresado incorrectamente el código de seguridad de sólo un uso demasiadas - veces. - max_otp_requests_reached: Su cuenta ha sido bloqueada temporalmente porque ha - solicitado un código de seguridad demasiadas veces más de lo permitido. - max_personal_key_login_attempts_reached: Su cuenta ha sido bloqueada temporalmente - porque ha ingresado incorrectamente la clave personal demasiadas veces. + max_generic_login_attempts_reached: Para su seguridad, su cuenta está bloqueada + temporalmente. + max_otp_login_attempts_reached: Para su seguridad, su cuenta ha sido bloqueada + temporalmente porque ha ingresado incorrectamente el código de seguridad de + sólo un uso demasiadas veces. + max_otp_requests_reached: Para su seguridad, su cuenta ha sido bloqueada temporalmente + porque ha solicitado un código de seguridad demasiadas veces más de lo permitido. + max_personal_key_login_attempts_reached: Para su seguridad, su cuenta ha sido + bloqueada temporalmente porque ha ingresado incorrectamente la clave personal + demasiadas veces. otp_delivery_preference: instruction: Puede cambiar esta selección la próxima vez que inicie sesión. phone_unsupported: NOT TRANSLATED YET @@ -148,7 +150,10 @@ es: piv_cac_header_text: NOT TRANSLATED YET please_confirm: Su número de teléfono ha sido establecido. Confírmelo ingresando el código de seguridad a continuación. - please_try_again_html: Inténtelo de nuevo en %{time_remaining}. + please_try_again_html: Inténtelo de nuevo en %{time_remaining}. + read_about_two_factor_authentication: + link: leer acerca de la autenticación de dos factores + text_html: Puede %{link} y por qué la utilizamos en nuestra página de Ayuda. totp_fallback: sms_link_text: Recibir un código por mensaje de texto text_html: Si no puede usar su app de autenticación ahora, puede %{sms_link} diff --git a/config/locales/devise/fr.yml b/config/locales/devise/fr.yml index 2fbb9fe4b2d..fefc7cd17b1 100644 --- a/config/locales/devise/fr.yml +++ b/config/locales/devise/fr.yml @@ -114,15 +114,16 @@ fr: de nouveau ou demander un nouveau code de sécurité à utilisation unique. invalid_personal_key: Cette clé personnelle est non valide. invalid_piv_cac: NOT TRANSLATED YET - max_generic_login_attempts_reached: Votre compte est temporairement verrouillé. - max_otp_login_attempts_reached: Votre compte est temporairement verrouillé, - car vous avez entré le code de sécurité à utilisation unique de façon erronée - à de trop nombreuses reprises. - max_otp_requests_reached: Votre compte est temporairement verrouillé car vous - avez demandé un code de sécurité à trop de reprises. - max_personal_key_login_attempts_reached: Votre compte est temporairement verrouillé, - car vous avez entré le code de sécurité à utilisation unique de façon erronée - à de trop nombreuses reprises. + max_generic_login_attempts_reached: Pour votre sécurité, votre compte est temporairement + verrouillé. + max_otp_login_attempts_reached: Pour votre sécurité, votre compte est temporairement + verrouillé, car vous avez entré le code de sécurité à utilisation unique de + façon erronée à de trop nombreuses reprises. + max_otp_requests_reached: Pour votre sécurité, votre compte est temporairement + verrouillé car vous avez demandé un code de sécurité à trop de reprises. + max_personal_key_login_attempts_reached: Pour votre sécurité, votre compte est + temporairement verrouillé, car vous avez entré le code de sécurité à utilisation + unique de façon erronée à de trop nombreuses reprises. otp_delivery_preference: instruction: Vous pouvez changer cette sélection la prochaine fois que vous vous connectez. @@ -156,7 +157,11 @@ fr: piv_cac_header_text: NOT TRANSLATED YET please_confirm: Votre numéro de téléphone a été entré. Confirmez-le en entrant le code de sécurité ci-dessous. - please_try_again_html: Veuillez essayer de nouveau dans %{time_remaining}. + please_try_again_html: Veuillez essayer de nouveau dans %{time_remaining}. + read_about_two_factor_authentication: + link: lire sur l'authentification à deux facteurs + text_html: Vous pouvez %{link} et pourquoi nous l'utilisons sur notre page + d'aide. totp_fallback: sms_link_text: Obtenir un code via message texte text_html: Si vous ne pouvez utiliser votre application d'authentification diff --git a/config/locales/headings/en.yml b/config/locales/headings/en.yml index 7ff6bfc5a76..d925758a965 100644 --- a/config/locales/headings/en.yml +++ b/config/locales/headings/en.yml @@ -19,6 +19,7 @@ en: email: Change your email password: Change your password phone: Enter your new phone number + lock_failure: Here's what you can do passwords: change: Change your password confirm: Confirm your current password to continue diff --git a/config/locales/headings/es.yml b/config/locales/headings/es.yml index ac932c544c0..52a5e2528bc 100644 --- a/config/locales/headings/es.yml +++ b/config/locales/headings/es.yml @@ -19,6 +19,7 @@ es: email: Cambie su email password: Cambie su contraseña phone: Ingrese su nuevo número de teléfono + lock_failure: Esto es lo que puedes hacer passwords: change: Cambie su contraseña confirm: Confirme la contraseña actual para continuar diff --git a/config/locales/headings/fr.yml b/config/locales/headings/fr.yml index 44e1a928dd7..f7d539628e6 100644 --- a/config/locales/headings/fr.yml +++ b/config/locales/headings/fr.yml @@ -19,6 +19,7 @@ fr: email: Changez votre courriel password: Changez votre mot de passe phone: Entrez votre nouveau numéro de téléphone + lock_failure: Voici ce que vous pouvez faire passwords: change: Changez votre mot de passe confirm: Confirmez votre mot de passe actuel pour continuer diff --git a/config/locales/titles/en.yml b/config/locales/titles/en.yml index 0170ca9833c..11459b4fd35 100644 --- a/config/locales/titles/en.yml +++ b/config/locales/titles/en.yml @@ -2,7 +2,7 @@ en: titles: account: Account - account_locked: Account locked + account_locked: Account temporarily locked confirmations: new: Resend confirmation instructions for your account show: Choose a password diff --git a/config/locales/titles/es.yml b/config/locales/titles/es.yml index 2bd2bedf56f..b6d6e03eff2 100644 --- a/config/locales/titles/es.yml +++ b/config/locales/titles/es.yml @@ -2,7 +2,7 @@ es: titles: account: Cuenta - account_locked: Cuenta bloqueada + account_locked: Cuenta bloqueada temporalmente confirmations: new: Reenviar instrucciones de confirmación de su cuenta show: Elija una contraseña diff --git a/config/locales/titles/fr.yml b/config/locales/titles/fr.yml index bec817df8ea..5059a46b48b 100644 --- a/config/locales/titles/fr.yml +++ b/config/locales/titles/fr.yml @@ -2,7 +2,7 @@ fr: titles: account: Compte - account_locked: Compte verrouillé + account_locked: Compte temporairement verrouillé confirmations: new: Envoyer les instructions de confirmation pour votre compte show: Choisissez un mot de passe diff --git a/spec/presenters/failure_presenter_spec.rb b/spec/presenters/failure_presenter_spec.rb new file mode 100644 index 00000000000..60e49832eba --- /dev/null +++ b/spec/presenters/failure_presenter_spec.rb @@ -0,0 +1,45 @@ +require 'rails_helper' + +describe FailurePresenter do + let(:state) { :warning } + let(:presenter) { described_class.new(state) } + + describe '#state' do + subject { presenter.state } + + it { is_expected.to eq(state) } + end + + context 'methods with default values of `nil`' do + %i[message title header description].each do |method| + describe "##{method}" do + subject { presenter.send(method) } + + it { is_expected.to be_nil } + end + end + end + + describe '#next_steps' do + subject { presenter.next_steps } + + it { is_expected.to be_empty } + end + + context 'methods configured by state' do + %i[icon alt_text color].each do |method| + %i[warning failure locked].each do |state| + describe "##{method} for #{state}" do + let(:state) { state } + subject { presenter.send('state_' + method.to_s) } + + it { is_expected.to eq(config(state, method)) } + end + end + end + end + + def config(state, key) + described_class::STATE_CONFIG.dig(state, key) + end +end diff --git a/spec/presenters/max_attempts_reached_presenter_spec.rb b/spec/presenters/max_attempts_reached_presenter_spec.rb new file mode 100644 index 00000000000..8ff66f42ede --- /dev/null +++ b/spec/presenters/max_attempts_reached_presenter_spec.rb @@ -0,0 +1,63 @@ +require 'rails_helper' + +describe TwoFactorAuthCode::MaxAttemptsReachedPresenter do + let(:type) { 'otp_requests' } + let(:decorated_user) { mock_decorated_user } + let(:presenter) { described_class.new(type, decorated_user) } + + describe 'it uses the :locked failure state' do + subject { presenter.state } + + it { is_expected.to eq(:locked) } + end + + describe '#type' do + subject { presenter.type } + + it { is_expected.to eq(type) } + end + + describe '#decorated_user' do + subject { presenter.decorated_user } + + it { is_expected.to eq(decorated_user) } + end + + context 'methods are overriden' do + %i[message title header description js].each do |method| + describe "##{method}" do + subject { presenter.send(method) } + + it { is_expected.to_not be_nil } + end + end + end + + describe '#next_steps' do + subject { presenter.next_steps } + + it 'includes `please_try_again` and `read_about_two_factor_authentication`' do + expect(subject).to eq( + [ + presenter.send(:please_try_again), + presenter.send(:read_about_two_factor_authentication), + ] + ) + end + end + + describe '#please_try_again' do + subject { presenter.send(:please_try_again) } + + it 'includes time remaining' do + expect(subject).to include('1000 years') + end + end + + def mock_decorated_user + decorated_user = instance_double(UserDecorator) + allow(decorated_user).to receive(:lockout_time_remaining_in_words).and_return('1000 years') + allow(decorated_user).to receive(:lockout_time_remaining).and_return(10_000) + decorated_user + end +end diff --git a/spec/views/two_factor_authentication/shared/max_login_attempts_reached.html.erb_spec.rb b/spec/views/two_factor_authentication/shared/max_login_attempts_reached.html.erb_spec.rb deleted file mode 100644 index 7b3af201719..00000000000 --- a/spec/views/two_factor_authentication/shared/max_login_attempts_reached.html.erb_spec.rb +++ /dev/null @@ -1,21 +0,0 @@ -require 'rails_helper' - -describe 'two_factor_authentication/shared/max_login_attempts_reached.html.erb' do - context 'locked out account' do - it 'includes localized error message with time remaining' do - user_decorator = instance_double(UserDecorator) - allow(view).to receive(:decorator).and_return(user_decorator) - allow(view).to receive(:type).and_return('otp') - allow(user_decorator).to receive(:lockout_time_remaining_in_words).and_return('1000 years') - allow(user_decorator).to receive(:lockout_time_remaining).and_return(10_000) - - render - - expect(rendered).to include(t('titles.account_locked')) - expect(rendered).to include( - t('devise.two_factor_authentication.max_otp_login_attempts_reached') - ) - expect(rendered).to include('1000 years') - end - end -end From 5e13d4b9ae50bf3b8abfdf2764d9b62b862f9425 Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Thu, 14 Jun 2018 12:17:53 -0400 Subject: [PATCH 22/40] LG-355 Add USAID logo **Why**: To facilitate integration testing and prepare for a production launch **How**: Add the logo to our assets --- app/assets/images/sp-logos/usaid.png | Bin 0 -> 8684 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 app/assets/images/sp-logos/usaid.png diff --git a/app/assets/images/sp-logos/usaid.png b/app/assets/images/sp-logos/usaid.png new file mode 100644 index 0000000000000000000000000000000000000000..be27e210b75c91c077726fcc2c3deffdbd527266 GIT binary patch literal 8684 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 literal 0 HcmV?d00001 From 5b31832cf16ca8e47af3e477f422f37706b53c31 Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Thu, 14 Jun 2018 13:34:40 -0500 Subject: [PATCH 23/40] Upgrade AAMVA to v3.0.1 (#2241) **Why**: To fix XML sanitization issue --- Gemfile | 2 +- Gemfile.lock | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile b/Gemfile index 8d05d9dedb1..2876cea811c 100644 --- a/Gemfile +++ b/Gemfile @@ -114,7 +114,7 @@ group :test do end group :production do - gem 'aamva', git: 'git@github.com:18F/identity-aamva-api-client-gem', tag: 'v3.0.0' + gem 'aamva', git: 'git@github.com:18F/identity-aamva-api-client-gem', tag: 'v3.0.1' gem 'equifax', git: 'git@github.com:18F/identity-equifax-api-client-gem.git', tag: 'v1.1.0' gem 'lexisnexis', git: 'git@github.com:18F/identity-lexisnexis-api-client-gem', tag: 'v1.0.0' end diff --git a/Gemfile.lock b/Gemfile.lock index 05391674335..489dfd4b7b9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,9 +1,9 @@ GIT remote: git@github.com:18F/identity-aamva-api-client-gem - revision: 41cf170a0161883f3a4a34f5a5edbb186a36bc06 - tag: v3.0.0 + revision: 015186dd86691294404229ee051cfcf9e87fb6c7 + tag: v3.0.1 specs: - aamva (3.0.0) + aamva (3.0.1) dotenv hashie httpi From bc01086083420c43bab31eef3d12096bfb8ba219 Mon Sep 17 00:00:00 2001 From: David Corwin Date: Thu, 14 Jun 2018 13:03:23 -0700 Subject: [PATCH 24/40] Revert background to blue for car wide layout (#2243) --- app/views/layouts/card_wide.html.slim | 1 + 1 file changed, 1 insertion(+) diff --git a/app/views/layouts/card_wide.html.slim b/app/views/layouts/card_wide.html.slim index 12d1ac90d93..f19d98cf6b1 100644 --- a/app/views/layouts/card_wide.html.slim +++ b/app/views/layouts/card_wide.html.slim @@ -1,3 +1,4 @@ - card_cls 'card-wide' +- background_cls 'sm-bg-light-blue' = render template: 'layouts/base' From d99cad582d60732ec9e09520cb2855ba41e012cc Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Mon, 11 Jun 2018 17:22:18 -0400 Subject: [PATCH 25/40] LG-356 Add help text to the account creation screen for SAM **Why**: To give SAM users clear instructions upon account creation **How**: Conditionally show the sp alert partial and change it's contents for SAM --- app/views/shared/_sp_alert.html.slim | 2 + app/views/sign_up/registrations/new.html.slim | 2 + config/locales/service_providers/en.yml | 7 +++ config/locales/service_providers/es.yml | 13 +++-- config/locales/service_providers/fr.yml | 13 +++-- .../registrations/new.html.slim_spec.rb | 51 +++++++++++++++++++ 6 files changed, 82 insertions(+), 6 deletions(-) diff --git a/app/views/shared/_sp_alert.html.slim b/app/views/shared/_sp_alert.html.slim index 6eb76769a30..5d4b716aabd 100644 --- a/app/views/shared/_sp_alert.html.slim +++ b/app/views/shared/_sp_alert.html.slim @@ -5,6 +5,8 @@ p.mb1 - if current_page?(sign_up_start_path) = t("service_providers.#{sp_alert_name}.account_page.body") + - elsif current_page?(sign_up_email_path) + = t("service_providers.#{sp_alert_name}.create_account_page.body") - else - account_link = link_to t("service_providers.#{sp_alert_name}.create_account_link"), sign_up_email_url(request_id: params[:request_id]) diff --git a/app/views/sign_up/registrations/new.html.slim b/app/views/sign_up/registrations/new.html.slim index 8aa0d261bad..f46be28dff1 100644 --- a/app/views/sign_up/registrations/new.html.slim +++ b/app/views/sign_up/registrations/new.html.slim @@ -1,5 +1,7 @@ - title t('titles.registrations.new') += render 'shared/sp_alert' + h1.h3.my0 = t('headings.registrations.enter_email') = simple_form_for(@register_user_email_form, diff --git a/config/locales/service_providers/en.yml b/config/locales/service_providers/en.yml index 9338df8ac1e..9b9b3258266 100644 --- a/config/locales/service_providers/en.yml +++ b/config/locales/service_providers/en.yml @@ -8,6 +8,8 @@ en: account below. body_html: Your old GOES userID and password won't work. Please %{link} create_account_link: create a login.gov account. + create_account_page: + body: '' usa_jobs: header: First time here from USAJOBS? account_page: @@ -16,6 +18,8 @@ en: body_html: Your old USAJOBS username and password won’t work. Please %{link} using the same email address you use for USAJOBS. create_account_link: create a login.gov account + create_account_page: + body: '' learn_more: Learn more. sam: header: First time here from SAM? @@ -25,3 +29,6 @@ en: body_html: Your old SAM username and password won’t work. Please %{link} using the same email address you use for SAM. create_account_link: create a login.gov account + create_account_page: + body: Please create a login.gov account using the same email address you use + for SAM. diff --git a/config/locales/service_providers/es.yml b/config/locales/service_providers/es.yml index 19075d381e6..0aef3ba185a 100644 --- a/config/locales/service_providers/es.yml +++ b/config/locales/service_providers/es.yml @@ -9,6 +9,8 @@ es: body_html: Su antiguo ID de usuario y contraseña de GOES no funcionarán. Favor de %{link} create_account_link: crear un nueva cuenta de login.gov. + create_account_page: + body: '' usa_jobs: header: "¿Ha venido de USAJOBS?" account_page: @@ -18,13 +20,18 @@ es: body_html: Si tiene un perfil de USAJOBS existente, favor de usar la dirección de correo electrónico primaria o secundaria que usó para USAJOBS para %{link}. create_account_link: crear su nueva cuenta de login.gov + create_account_page: + body: '' learn_more: Obtenga más información. sam: header: "¿Ha venido de SAM?" account_page: - body: Si tiene un perfil de SAM existente, favor de usar la dirección de correo - electrónico primaria o secundaria que usó para SAM para crear su nueva cuenta - de login.gov. + body: Su antiguo nombre de usuario y contraseña SAM no funcionará. Por favor + crea un login.gov cuenta usando la misma dirección de correo electrónico + que utiliza para SAM. body_html: Si tiene un perfil de SAM existente, favor de usar la dirección de correo electrónico primaria o secundaria que usó para SAM para %{link}. create_account_link: crear su nueva cuenta de login.gov + create_account_page: + body: Por favor crea un login.gov cuenta usando la misma dirección de correo + electrónico que utiliza para SAM. diff --git a/config/locales/service_providers/fr.yml b/config/locales/service_providers/fr.yml index 43377646637..b17e132600c 100644 --- a/config/locales/service_providers/fr.yml +++ b/config/locales/service_providers/fr.yml @@ -9,6 +9,8 @@ fr: body_html: Votre ancien nom d'utilisateur et mot de passe GOES ne marchera pas. Veuillez %{link} create_account_link: créer un nouveau compte login.gov. + create_account_page: + body: '' usa_jobs: header: Êtes-vous venu(e) de USAJOBS? account_page: @@ -18,13 +20,18 @@ fr: body_html: Si vous avez déjà un profil USAJOBS, veuillez utiliser l'adresse e-mail principale ou secondaire que vous avez utilisée pour USAJOBS pour %{link}. create_account_link: créer votre nouveau compte login.gov + create_account_page: + body: '' learn_more: En savoir plus. sam: header: Êtes-vous venu(e) de SAM? account_page: - body: Si vous avez déjà un profil SAM, veuillez utiliser l'adresse e-mail - principale ou secondaire que vous avez utilisée pour SAM pour créer votre - nouveau compte login.gov. + body: Votre ancien nom d'utilisateur et mot de passe SAM ne marchera pas. + Veuillez créer un nouveau compte login.gov avec la même adresse e-mail que + vous avez utilisée pour SAM. body_html: Si vous avez déjà un profil SAM, veuillez utiliser l'adresse e-mail principale ou secondaire que vous avez utilisée pour SAM pour %{link}. create_account_link: créer votre nouveau compte login.gov + create_account_page: + body: Veuillez créer un compte login.gov avec la même adresse e-mail que vous + avez utilisée pour SAM. diff --git a/spec/views/sign_up/registrations/new.html.slim_spec.rb b/spec/views/sign_up/registrations/new.html.slim_spec.rb index 54ed039d27a..1f5dac50952 100644 --- a/spec/views/sign_up/registrations/new.html.slim_spec.rb +++ b/spec/views/sign_up/registrations/new.html.slim_spec.rb @@ -55,4 +55,55 @@ expect(rendered).to have_selector('#recaptcha') end + + context 'when SAM is present' do + before do + @sp = build_stubbed( + :service_provider, + friendly_name: 'SAM', + return_to_sp_url: 'www.awesomeness.com' + ) + view_context = ActionController::Base.new.view_context + allow(view_context).to receive(:sign_up_start_url). + and_return('https://www.example.com/sign_up/start') + @decorated_session = DecoratedSession.new( + sp: @sp, + view_context: view_context, + sp_session: {}, + service_provider_request: ServiceProviderRequest.new + ).call + allow(view).to receive(:decorated_session).and_return(@decorated_session) + end + + it 'displays a custom alert message for SAM' do + render + + expect(rendered).to \ + have_content(t('service_providers.sam.create_account_page.body')) + end + + it 'has sp alert for the SAM service provider' do + @sp.friendly_name = 'SAM' + + render + + expect(rendered).to have_selector('.alert') + end + + it 'does not have an sp alert for the other service providers' do + @sp.friendly_name = 'other' + render + + expect(rendered).to_not have_selector('.alert') + end + end + + context 'when SP is not present' do + it 'does not display the branded content' do + render + + expect(rendered).not_to \ + have_content(t('service_providers.sam.create_account_page.body')) + end + end end From 1de4023946e405c9a424ef5240edad3e8a88f2ad Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Thu, 14 Jun 2018 16:05:52 -0400 Subject: [PATCH 26/40] LG-353 Add sufficient request tracing to be able to better diagnose timeouts **Why**: To be able to diagnose timeouts in production **How**: Monkeypatch the newrelic gem and override the code to truncate backtraces. The length of backtraces is not currently configurable but may be in the future. --- config/initializers/new_relic.rb | 13 +++++++++++++ config/initializers/new_relic_tracers.rb | 12 ++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 config/initializers/new_relic.rb diff --git a/config/initializers/new_relic.rb b/config/initializers/new_relic.rb new file mode 100644 index 00000000000..28c15b48cce --- /dev/null +++ b/config/initializers/new_relic.rb @@ -0,0 +1,13 @@ +# monkeypatch to prevent new relic from truncating backtraces. length is not configurable +module NewRelic + module Agent + class ErrorCollector + # Maximum number of frames in backtraces. May be made configurable + # in the future. + MAX_BACKTRACE_FRAMES = 50 + def truncate_trace(trace, _keep_frames = MAX_BACKTRACE_FRAMES) + trace + end + end + end +end diff --git a/config/initializers/new_relic_tracers.rb b/config/initializers/new_relic_tracers.rb index 2306435810f..7be3e33c66c 100644 --- a/config/initializers/new_relic_tracers.rb +++ b/config/initializers/new_relic_tracers.rb @@ -59,3 +59,15 @@ add_method_tracer :rs256_algorithm, "Custom/#{name}/rs256_algorithm" add_method_tracer :sign, "Custom/#{name}/sign" end + +Encryption::KmsClient.class_eval do + include ::NewRelic::Agent::MethodTracer + add_method_tracer :decrypt, "Custom/#{name}/decrypt" + add_method_tracer :encrypt, "Custom/#{name}/encrypt" +end + +TwilioService.class_eval do + include ::NewRelic::Agent::MethodTracer + add_method_tracer :place_call, "Custom/#{name}/place_call" + add_method_tracer :send_sms, "Custom/#{name}/send_sms" +end From a88dbb9835c65d33523fb7b50dd48dec1ea119f1 Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Fri, 2 Feb 2018 16:01:50 -0500 Subject: [PATCH 27/40] Update Ruby from 2.3.5 to 2.5.0 **Why**: To use the latest and greatest. Note that you will need to delete and regenerate your login.gov GPG key for the tests to pass on your local machine: ``` gpg --list-secret-keys ``` Find the key with uid `[ultimate] login dot gov (development only)` and copy and paste its 40-character key at the end of this command: ``` gpg --delete-secret-keys ``` Then run `make setup` --- .circleci/config.yml | 2 +- .rubocop.yml | 2 +- .ruby-version | 2 +- Dockerfile | 11 +++++++---- Gemfile | 2 +- Gemfile.lock | 4 ++-- app/services/file_encryptor.rb | 2 +- app/services/pii/cipher.rb | 8 ++++++-- bin/generate-example-keys | 2 +- bin/setup | 2 +- config/application.yml.example | 4 ++-- spec/services/pii/cipher_spec.rb | 2 +- spec/services/pii/encryptor_spec.rb | 4 ++-- 13 files changed, 27 insertions(+), 20 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 7cfe8417626..05fadbdae3d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -8,7 +8,7 @@ jobs: parallelism: 4 docker: # Specify the Ruby version you desire here - - image: circleci/ruby:2.3-node-browsers + - image: circleci/ruby:2.5.1-node-browsers environment: RAILS_ENV: test CC_TEST_REPORTER_ID: faecd27e9aed532634b3f4d3e251542d7de9457cfca96a94208a63270ef9b42e diff --git a/.rubocop.yml b/.rubocop.yml index 0ce87a28ea3..90dc0aa5147 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -18,7 +18,7 @@ AllCops: - 'lib/user_flow_exporter.rb' - 'node_modules/**/*' - 'tmp/**/*' - TargetRubyVersion: 2.3 + TargetRubyVersion: 2.5 TargetRailsVersion: 5.1 UseCache: true diff --git a/.ruby-version b/.ruby-version index bb576dbde10..95e3ba81920 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.3 +2.5 diff --git a/Dockerfile b/Dockerfile index dfae4e98b1a..59a60b732b2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Use the official Ruby image because the Rails images have been deprecated -FROM ruby:2.3 +FROM ruby:2.5 # Install packages of https RUN apt-get update && apt-get install apt-transport-https @@ -15,13 +15,16 @@ RUN apt-get update \ RUN ln -s ../node/bin/node /usr/local/bin/ RUN ln -s ../node/bin/npm /usr/local/bin/ -RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \ - && echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list \ - && apt-get update && apt-get install yarn + +ADD https://dl.yarnpkg.com/debian/pubkey.gpg /tmp/yarn-pubkey.gpg +RUN apt-key add /tmp/yarn-pubkey.gpg && rm /tmp/yarn-pubkey.gpg +RUN echo 'deb http://dl.yarnpkg.com/debian/ stable main' > /etc/apt/sources.list.d/yarn.list +RUN apt-get update && apt-get install -y --no-install-recommends yarn WORKDIR /upaya COPY package.json /upaya +COPY yarn.lock /upaya COPY Gemfile /upaya COPY Gemfile.lock /upaya diff --git a/Gemfile b/Gemfile index 2876cea811c..ba40671bc09 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,7 @@ source 'https://rubygems.org' git_source(:github) { |repo_name| "https://github.com/#{repo_name}.git" } -ruby '~> 2.3.7' +ruby '~> 2.5.1' gem 'rails', '~> 5.1.3' diff --git a/Gemfile.lock b/Gemfile.lock index 489dfd4b7b9..7bf3ea4dc70 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -272,7 +272,7 @@ GEM fasterer (0.4.1) colorize (~> 0.7) ruby_parser (~> 3.11.0) - ffi (1.9.23) + ffi (1.9.25) ffi-compiler (1.0.1) ffi (>= 1.0.0) rake @@ -777,7 +777,7 @@ DEPENDENCIES zxcvbn-js RUBY VERSION - ruby 2.3.7p456 + ruby 2.5.1p57 BUNDLED WITH 1.16.1 diff --git a/app/services/file_encryptor.rb b/app/services/file_encryptor.rb index 37eae7f632a..8544e982287 100644 --- a/app/services/file_encryptor.rb +++ b/app/services/file_encryptor.rb @@ -47,7 +47,7 @@ def gpg_encrypt_command(outfile) --pinentry-mode loopback \ --status-fd \ --with-colons \ - --no-tty \ + --no-tty \ -e \ -r #{Shellwords.shellescape(recipient_email)} \ --output #{Shellwords.shellescape(outfile)} diff --git a/app/services/pii/cipher.rb b/app/services/pii/cipher.rb index 0e54b93e1b8..64fb17458d3 100644 --- a/app/services/pii/cipher.rb +++ b/app/services/pii/cipher.rb @@ -5,14 +5,18 @@ class Cipher def encrypt(plaintext, cek) self.cipher = OpenSSL::Cipher.new 'aes-256-gcm' cipher.encrypt - cipher.key = cek + # The key length for the AES-256-GCM cipher is fixed at 128 bits, or 32 + # characters. Starting with Ruby 2.4, an expection is thrown if you try to + # set a key longer than 32 characters, which is what we have been doing + # all along. In prior versions of Ruby, the key was silently truncated. + cipher.key = cek[0..31] encipher(plaintext) end def decrypt(payload, cek) self.cipher = OpenSSL::Cipher.new 'aes-256-gcm' cipher.decrypt - cipher.key = cek + cipher.key = cek[0..31] decipher(payload) end diff --git a/bin/generate-example-keys b/bin/generate-example-keys index e908b472483..12d9cf7ab95 100755 --- a/bin/generate-example-keys +++ b/bin/generate-example-keys @@ -26,7 +26,7 @@ def generate_equifax_gpg_private_key %commit %echo done ' - run "echo '#{parameters}' | gpg --batch --gen-key" + run "echo '#{parameters}' | gpg --batch --pinentry-mode loopback --gen-key" run 'gpg --export --output keys/equifax_gpg.pub.bin logs@login.gov' end diff --git a/bin/setup b/bin/setup index 56a94bb5f10..c659ff8f96d 100755 --- a/bin/setup +++ b/bin/setup @@ -71,7 +71,7 @@ Dir.chdir APP_ROOT do run "rm -rf tmp/cache" puts "\n== Adding git hooks via Overcommit ==" - run 'overcommit --install' + run 'bundle exec overcommit --install' puts "\n== Restarting application server ==" run "mkdir -p tmp" diff --git a/config/application.yml.example b/config/application.yml.example index f58993e2ccb..3757df19203 100644 --- a/config/application.yml.example +++ b/config/application.yml.example @@ -142,7 +142,7 @@ development: otp_delivery_blocklist_findtime: '5' otp_delivery_blocklist_maxretry: '10' otp_valid_for: '10' - password_pepper: 'f22d4b2cafac9066fe2f4416f5b7a32c6942d82f7e00740c7594a095fa8de8db17c05314be7b18a5d6dd5683e73eadf6cc95aa633e5ad9a701edb95192a6a105' + password_pepper: 'f22d4b2cafac9066fe2f4416f5b7a32c' password_strength_enabled: 'true' piv_cac_agencies: '["Test Government Agency"]' piv_cac_enabled: 'true' @@ -365,7 +365,7 @@ test: otp_delivery_blocklist_findtime: '1' otp_delivery_blocklist_maxretry: '2' otp_valid_for: '10' - password_pepper: 'f22d4b2cafac9066fe2f4416f5b7a32c6942d82f7e00740c7594a095fa8de8db17c05314be7b18a5d6dd5683e73eadf6cc95aa633e5ad9a701edb95192a6a105' + password_pepper: 'f22d4b2cafac9066fe2f4416f5b7a32c' password_strength_enabled: 'false' piv_cac_agencies: '["Test Government Agency"]' piv_cac_enabled: 'true' diff --git a/spec/services/pii/cipher_spec.rb b/spec/services/pii/cipher_spec.rb index c0012f07051..e522bc2ca97 100644 --- a/spec/services/pii/cipher_spec.rb +++ b/spec/services/pii/cipher_spec.rb @@ -2,7 +2,7 @@ describe Pii::Cipher do let(:plaintext) { 'some long secret' } - let(:cek) { SecureRandom.uuid } + let(:cek) { SecureRandom.random_bytes(32) } describe '#encrypt' do it 'returns JSON string containing AES-encrypted ciphertext' do diff --git a/spec/services/pii/encryptor_spec.rb b/spec/services/pii/encryptor_spec.rb index 554d64a3bed..ee62f977600 100644 --- a/spec/services/pii/encryptor_spec.rb +++ b/spec/services/pii/encryptor_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' describe Pii::Encryptor do - let(:aes_cek) { SecureRandom.uuid } + let(:aes_cek) { SecureRandom.random_bytes(32) } let(:plaintext) { 'four score and seven years ago' } describe '#encrypt' do @@ -21,7 +21,7 @@ it 'requires same password used for encrypt' do encrypted = subject.encrypt(plaintext, aes_cek) - diff_cek = aes_cek.tr('-', 'z') + diff_cek = SecureRandom.random_bytes(32) expect { subject.decrypt(encrypted, diff_cek) }.to raise_error Pii::EncryptionError end From f962ffe5d1869bdb0443ff2e5e29e3a9a3335f7d Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Fri, 16 Feb 2018 20:31:00 -0500 Subject: [PATCH 28/40] Fix Rubocop offenses specific to Ruby 2.4+ In Ruby 2.4, `String#match?`, `Regexp#match?` and `Symbol#match?` have been added. The methods are faster than `match`. Because the methods avoid creating a `MatchData` object or saving backref. So, when `MatchData` is not used, use `match?` instead of `match` or `=~`. Reference: http://www.rubydoc.info/gems/rubocop/RuboCop/Cop/Performance/RegexpMatch --- app/forms/otp_verification_form.rb | 2 +- app/forms/totp_verification_form.rb | 2 +- app/services/openid_connect_attribute_scoper.rb | 2 +- app/services/saml_cert_rotation_manager.rb | 2 +- lib/i18n_override.rb | 4 ++-- lib/proofer_mocks/resolution_mock.rb | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/forms/otp_verification_form.rb b/app/forms/otp_verification_form.rb index 0c6e8ee98eb..51369840049 100644 --- a/app/forms/otp_verification_form.rb +++ b/app/forms/otp_verification_form.rb @@ -13,7 +13,7 @@ def submit attr_reader :code, :user def valid_direct_otp_code? - return false unless code =~ pattern_matching_otp_code_format + return false unless code.match? pattern_matching_otp_code_format user.authenticate_direct_otp(code) end diff --git a/app/forms/totp_verification_form.rb b/app/forms/totp_verification_form.rb index 7f4bf8053ea..faae780778f 100644 --- a/app/forms/totp_verification_form.rb +++ b/app/forms/totp_verification_form.rb @@ -13,7 +13,7 @@ def submit attr_reader :user, :code def valid_totp_code? - return false unless code =~ pattern_matching_totp_code_format + return false unless code.match? pattern_matching_totp_code_format user.authenticate_totp(code) end diff --git a/app/services/openid_connect_attribute_scoper.rb b/app/services/openid_connect_attribute_scoper.rb index 97fe4dcc3aa..3bd63c68485 100644 --- a/app/services/openid_connect_attribute_scoper.rb +++ b/app/services/openid_connect_attribute_scoper.rb @@ -29,7 +29,7 @@ class OpenidConnectAttributeScoper SCOPE_ATTRIBUTE_MAP = {}.tap do |scope_attribute_map| ATTRIBUTE_SCOPES_MAP.each do |attribute, scopes| - next [] if attribute =~ /_verified$/ + next [] if attribute.match?(/_verified$/) scopes.each do |scope| scope_attribute_map[scope] ||= [] scope_attribute_map[scope] << attribute diff --git a/app/services/saml_cert_rotation_manager.rb b/app/services/saml_cert_rotation_manager.rb index 41b2cc29980..a2fc116564f 100644 --- a/app/services/saml_cert_rotation_manager.rb +++ b/app/services/saml_cert_rotation_manager.rb @@ -23,7 +23,7 @@ def self.rotation_path_suffix def self.use_new_secrets_for_request?(request) return false unless FeatureManagement.enable_saml_cert_rotation? - return false unless request.path =~ /#{rotation_path_suffix}$/ + return false unless request.path.match?(/#{rotation_path_suffix}$/) true end diff --git a/lib/i18n_override.rb b/lib/i18n_override.rb index a702f0c54e3..365508683a8 100644 --- a/lib/i18n_override.rb +++ b/lib/i18n_override.rb @@ -5,7 +5,7 @@ class << self def translate_with_markup(*args) i18n_text = normal_translate(*args) return i18n_text unless FeatureManagement.enable_i18n_mode? && i18n_text.is_a?(String) - return i18n_text if caller(2..2).first =~ /flows_spec.rb|session_helper.rb/ + return i18n_text if caller(2..2).first.match?(/flows_spec.rb|session_helper.rb/) key = args.first.to_s rtn = i18n_text + i18n_mode_additional_markup(key) @@ -65,7 +65,7 @@ def find_line_number def match_line_in_file(file, match_arr) File.foreach(file).with_index do |line, index| - break index + 1 if line =~ /#{match_arr.join('|')}/ + break index + 1 if line.match?(/#{match_arr.join('|')}/) end end diff --git a/lib/proofer_mocks/resolution_mock.rb b/lib/proofer_mocks/resolution_mock.rb index ac907d9ee42..5e256e149b1 100644 --- a/lib/proofer_mocks/resolution_mock.rb +++ b/lib/proofer_mocks/resolution_mock.rb @@ -8,10 +8,10 @@ class ResolutionMock < Proofer::Base raise 'Failed to contact proofing vendor' if first_name =~ /Fail/i - if first_name =~ /Bad/i + if first_name.match?(/Bad/i) result.add_error(:first_name, 'Unverified first name.') - elsif applicant[:ssn] =~ /6666/ + elsif applicant[:ssn].match?(/6666/) result.add_error(:ssn, 'Unverified SSN.') elsif applicant[:zipcode] == '00000' From 34a938e3757abc5c8f9061d65ae436ad0a43620c Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Tue, 12 Jun 2018 08:36:12 -0400 Subject: [PATCH 29/40] LG-322 PIV/CAC users must set up a phone number **Why**: PIV/CAC users are required to configure a backup phone number so that they can sign in/recover their account if they no longer have access to their PIV/CAC card. --- .reek | 1 + .../account_recovery_setup_controller.rb | 18 ++++++++++ .../concerns/account_recoverable.rb | 5 +++ app/controllers/concerns/authorizable.rb | 11 ++++++ .../authorization_controller.rb | 8 ++++- app/controllers/saml_idp_controller.rb | 2 ++ .../piv_cac_verification_controller.rb | 8 ++++- .../users/phone_setup_controller.rb | 11 ++---- ...piv_cac_authentication_setup_controller.rb | 7 +++- ..._factor_authentication_setup_controller.rb | 11 ++---- .../account_recovery_options_presenter.rb | 32 +++++++++++++++++ .../account_recovery_setup/index.html.slim | 23 ++++++++++++ config/locales/forms/en.yml | 2 ++ config/locales/forms/es.yml | 2 ++ config/locales/forms/fr.yml | 2 ++ config/locales/headings/en.yml | 2 ++ config/locales/headings/es.yml | 2 ++ config/locales/headings/fr.yml | 2 ++ config/locales/instructions/en.yml | 2 ++ config/locales/instructions/es.yml | 2 ++ config/locales/instructions/fr.yml | 2 ++ config/locales/titles/en.yml | 1 + config/locales/titles/es.yml | 1 + config/locales/titles/fr.yml | 1 + config/routes.rb | 1 + .../account_recovery_setup_controller_spec.rb | 35 +++++++++++++++++++ spec/controllers/saml_idp_controller_spec.rb | 1 + .../users/phone_setup_controller_spec.rb | 9 ++--- ...or_authentication_setup_controller_spec.rb | 15 ++++++-- .../features/users/piv_cac_management_spec.rb | 30 ++++++++++++++-- spec/features/users/sign_in_spec.rb | 2 ++ spec/features/users/sign_up_spec.rb | 28 ++------------- spec/support/features/session_helper.rb | 33 +++++++++++++++++ .../shared_examples/account_creation.rb | 27 ++++++++++++++ spec/support/shared_examples/sign_in.rb | 22 ++++++++++++ 35 files changed, 307 insertions(+), 54 deletions(-) create mode 100644 app/controllers/account_recovery_setup_controller.rb create mode 100644 app/controllers/concerns/account_recoverable.rb create mode 100644 app/controllers/concerns/authorizable.rb create mode 100644 app/presenters/account_recovery_options_presenter.rb create mode 100644 app/views/account_recovery_setup/index.html.slim create mode 100644 spec/controllers/account_recovery_setup_controller_spec.rb diff --git a/.reek b/.reek index cd2da766a4a..f13c3d4d284 100644 --- a/.reek +++ b/.reek @@ -99,6 +99,7 @@ TooManyStatements: - Idv::Agent#proof - Idv::Proofer#configure_vendors - Idv::VendorResult#initialize + - SamlIdpController#auth - Upaya::QueueConfig#self.choose_queue_adapter - Upaya::RandomTools#self.random_weighted_sample - UserFlowFormatter#stop diff --git a/app/controllers/account_recovery_setup_controller.rb b/app/controllers/account_recovery_setup_controller.rb new file mode 100644 index 00000000000..eb22586fee4 --- /dev/null +++ b/app/controllers/account_recovery_setup_controller.rb @@ -0,0 +1,18 @@ +class AccountRecoverySetupController < ApplicationController + include AccountRecoverable + include UserAuthenticator + + before_action :confirm_two_factor_authenticated + + def index + return redirect_to account_url unless piv_cac_enabled_but_not_phone_enabled? + @two_factor_options_form = TwoFactorOptionsForm.new(current_user) + @presenter = account_recovery_options_presenter + end + + private + + def account_recovery_options_presenter + AccountRecoveryOptionsPresenter.new + end +end diff --git a/app/controllers/concerns/account_recoverable.rb b/app/controllers/concerns/account_recoverable.rb new file mode 100644 index 00000000000..5d80977bfff --- /dev/null +++ b/app/controllers/concerns/account_recoverable.rb @@ -0,0 +1,5 @@ +module AccountRecoverable + def piv_cac_enabled_but_not_phone_enabled? + current_user.piv_cac_enabled? && !current_user.phone_enabled? + end +end diff --git a/app/controllers/concerns/authorizable.rb b/app/controllers/concerns/authorizable.rb new file mode 100644 index 00000000000..524f3bff347 --- /dev/null +++ b/app/controllers/concerns/authorizable.rb @@ -0,0 +1,11 @@ +module Authorizable + def authorize_user + return unless current_user.phone_enabled? + + if user_fully_authenticated? + redirect_to account_url + elsif current_user.two_factor_enabled? + redirect_to user_two_factor_authentication_url + end + end +end diff --git a/app/controllers/openid_connect/authorization_controller.rb b/app/controllers/openid_connect/authorization_controller.rb index fa63a51f879..3d499416659 100644 --- a/app/controllers/openid_connect/authorization_controller.rb +++ b/app/controllers/openid_connect/authorization_controller.rb @@ -1,5 +1,6 @@ module OpenidConnect class AuthorizationController < ApplicationController + include AccountRecoverable include FullyAuthenticatable include VerifyProfileConcern include VerifySPAttributesConcern @@ -13,7 +14,8 @@ class AuthorizationController < ApplicationController def index return confirm_two_factor_authenticated(request_id) unless user_fully_authenticated? - @authorize_form.link_identity_to_service_provider(current_user, session.id) + link_identity_to_service_provider + return redirect_to account_recovery_setup_url if piv_cac_enabled_but_not_phone_enabled? return redirect_to_account_or_verify_profile_url if profile_or_identity_needs_verification? return redirect_to(sign_up_completed_url) if needs_sp_attribute_verification? handle_successful_handoff @@ -21,6 +23,10 @@ def index private + def link_identity_to_service_provider + @authorize_form.link_identity_to_service_provider(current_user, session.id) + end + def handle_successful_handoff redirect_to @authorize_form.success_redirect_uri delete_branded_experience diff --git a/app/controllers/saml_idp_controller.rb b/app/controllers/saml_idp_controller.rb index 5e4d7ce475d..c6bc5ab98ab 100644 --- a/app/controllers/saml_idp_controller.rb +++ b/app/controllers/saml_idp_controller.rb @@ -6,6 +6,7 @@ class SamlIdpController < ApplicationController include SamlIdp::Controller include SamlIdpAuthConcern include SamlIdpLogoutConcern + include AccountRecoverable include FullyAuthenticatable include VerifyProfileConcern include VerifySPAttributesConcern @@ -17,6 +18,7 @@ def auth return confirm_two_factor_authenticated(request_id) unless user_fully_authenticated? link_identity_from_session_data capture_analytics + return redirect_to account_recovery_setup_url if piv_cac_enabled_but_not_phone_enabled? return redirect_to_account_or_verify_profile_url if profile_or_identity_needs_verification? return redirect_to(sign_up_completed_url) if needs_sp_attribute_verification? handle_successful_handoff diff --git a/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb b/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb index bf836a596f6..2673991d530 100644 --- a/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb +++ b/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb @@ -35,10 +35,16 @@ def handle_valid_piv_cac ) handle_valid_otp_for_authentication_context - redirect_to after_otp_verification_confirmation_url + redirect_to next_step reset_otp_session_data end + def next_step + return account_recovery_setup_url unless current_user.phone_enabled? + + after_otp_verification_confirmation_url + end + def handle_invalid_piv_cac clear_piv_cac_information # create new nonce for retry diff --git a/app/controllers/users/phone_setup_controller.rb b/app/controllers/users/phone_setup_controller.rb index 4fd8c903220..08f47a27176 100644 --- a/app/controllers/users/phone_setup_controller.rb +++ b/app/controllers/users/phone_setup_controller.rb @@ -2,9 +2,10 @@ module Users class PhoneSetupController < ApplicationController include UserAuthenticator include PhoneConfirmation + include Authorizable before_action :authenticate_user - before_action :authorize_phone_setup + before_action :authorize_user def index @user_phone_form = UserPhoneForm.new(current_user) @@ -27,14 +28,6 @@ def create private - def authorize_phone_setup - if user_fully_authenticated? - redirect_to account_url - elsif current_user.two_factor_enabled? - redirect_to user_two_factor_authentication_url - end - end - def user_phone_form_params params.require(:user_phone_form).permit( :international_code, diff --git a/app/controllers/users/piv_cac_authentication_setup_controller.rb b/app/controllers/users/piv_cac_authentication_setup_controller.rb index f145f8e9621..e4982c9f1cc 100644 --- a/app/controllers/users/piv_cac_authentication_setup_controller.rb +++ b/app/controllers/users/piv_cac_authentication_setup_controller.rb @@ -59,7 +59,12 @@ def process_valid_submission subject: user_piv_cac_form.x509_dn, presented: true ) - redirect_to account_url + redirect_to next_step + end + + def next_step + return account_url if current_user.phone_enabled? + account_recovery_setup_url end def process_invalid_submission diff --git a/app/controllers/users/two_factor_authentication_setup_controller.rb b/app/controllers/users/two_factor_authentication_setup_controller.rb index 07b65001cda..1ffec9ad70a 100644 --- a/app/controllers/users/two_factor_authentication_setup_controller.rb +++ b/app/controllers/users/two_factor_authentication_setup_controller.rb @@ -1,9 +1,10 @@ module Users class TwoFactorAuthenticationSetupController < ApplicationController include UserAuthenticator + include Authorizable before_action :authenticate_user - before_action :authorize_2fa_setup + before_action :authorize_user def index @two_factor_options_form = TwoFactorOptionsForm.new(current_user) @@ -30,14 +31,6 @@ def two_factor_options_presenter TwoFactorOptionsPresenter.new(current_user, current_sp) end - def authorize_2fa_setup - if user_fully_authenticated? - redirect_to account_url - elsif current_user.two_factor_enabled? - redirect_to user_two_factor_authentication_url - end - end - def process_valid_form case @two_factor_options_form.selection when 'sms', 'voice' diff --git a/app/presenters/account_recovery_options_presenter.rb b/app/presenters/account_recovery_options_presenter.rb new file mode 100644 index 00000000000..7c0f450f2aa --- /dev/null +++ b/app/presenters/account_recovery_options_presenter.rb @@ -0,0 +1,32 @@ +class AccountRecoveryOptionsPresenter + include ActionView::Helpers::TranslationHelper + + AVAILABLE_2FA_TYPES = %w[sms voice].freeze + + def title + t('titles.account_recovery_setup') + end + + def heading + t('headings.account_recovery_setup.piv_cac_linked') + end + + def info + t('instructions.account_recovery_setup.piv_cac_next_step') + end + + def label + t('forms.account_recovery_setup.legend') + ':' + end + + def options + AVAILABLE_2FA_TYPES.map do |type| + OpenStruct.new( + type: type, + label: t("devise.two_factor_authentication.two_factor_choice_options.#{type}"), + info: t("devise.two_factor_authentication.two_factor_choice_options.#{type}_info"), + selected: type == :sms + ) + end + end +end diff --git a/app/views/account_recovery_setup/index.html.slim b/app/views/account_recovery_setup/index.html.slim new file mode 100644 index 00000000000..d0dc36506b2 --- /dev/null +++ b/app/views/account_recovery_setup/index.html.slim @@ -0,0 +1,23 @@ +- title @presenter.title + +h1.h3.my0 = @presenter.heading +p.mt-tiny.mb3 = @presenter.info + += simple_form_for(@two_factor_options_form, + html: { autocomplete: 'off', role: 'form' }, + method: :patch, + url: two_factor_options_path) do |f| + .mb3 + fieldset.m0.p0.border-none. + legend.mb1.h4.serif.bold = @presenter.label + - @presenter.options.each do |option| + label.btn-border.col-12.mb1 for="two_factor_options_form_selection_#{option.type}" + .radio + = radio_button_tag('two_factor_options_form[selection]', + option.type, + @two_factor_options_form.selected?(option.type)) + span.indicator.mt-tiny + span.blue.bold.fs-20p = option.label + .regular.gray-dark.fs-10p.mb-tiny = option.info + + = f.button :submit, t('forms.buttons.continue') diff --git a/config/locales/forms/en.yml b/config/locales/forms/en.yml index 7b0286d8c45..b6b79abf966 100644 --- a/config/locales/forms/en.yml +++ b/config/locales/forms/en.yml @@ -1,6 +1,8 @@ --- en: forms: + account_recovery_setup: + legend: Select a secondary authentication option buttons: back: Back continue: Continue diff --git a/config/locales/forms/es.yml b/config/locales/forms/es.yml index 5f45b02309d..2c4e0c85f20 100644 --- a/config/locales/forms/es.yml +++ b/config/locales/forms/es.yml @@ -1,6 +1,8 @@ --- es: forms: + account_recovery_setup: + legend: NOT TRANSLATED YET buttons: back: Atrás continue: Continuar diff --git a/config/locales/forms/fr.yml b/config/locales/forms/fr.yml index 463332376ff..597437d0a31 100644 --- a/config/locales/forms/fr.yml +++ b/config/locales/forms/fr.yml @@ -1,6 +1,8 @@ --- fr: forms: + account_recovery_setup: + legend: NOT TRANSLATED YET buttons: back: Retour continue: Continuer diff --git a/config/locales/headings/en.yml b/config/locales/headings/en.yml index d925758a965..12bde6b447a 100644 --- a/config/locales/headings/en.yml +++ b/config/locales/headings/en.yml @@ -9,6 +9,8 @@ en: reactivate: Reactivate your account two_factor: Two-factor authentication verified_account: Verified Account + account_recovery_setup: + piv_cac_linked: Your PIV/CAC card is linked to your account confirmations: new: Send another confirmation email create_account_with_sp: diff --git a/config/locales/headings/es.yml b/config/locales/headings/es.yml index 52a5e2528bc..b64ac5c5cb6 100644 --- a/config/locales/headings/es.yml +++ b/config/locales/headings/es.yml @@ -9,6 +9,8 @@ es: reactivate: Reactive su cuenta two_factor: Autenticación de dos factores verified_account: Cuenta verificada + account_recovery_setup: + piv_cac_linked: NOT TRANSLATED YET confirmations: new: Enviar otro email de confirmación create_account_with_sp: diff --git a/config/locales/headings/fr.yml b/config/locales/headings/fr.yml index f7d539628e6..fea1dcc54ae 100644 --- a/config/locales/headings/fr.yml +++ b/config/locales/headings/fr.yml @@ -9,6 +9,8 @@ fr: reactivate: Réactivez votre compte two_factor: Authentification à deux facteurs verified_account: Compte vérifié + account_recovery_setup: + piv_cac_linked: NOT TRANSLATED YET confirmations: new: Envoyer un autre courriel de confirmation create_account_with_sp: diff --git a/config/locales/instructions/en.yml b/config/locales/instructions/en.yml index 6619b10f1ce..3b99851d550 100644 --- a/config/locales/instructions/en.yml +++ b/config/locales/instructions/en.yml @@ -13,6 +13,8 @@ en: identity again. heading: Don't have your personal key? with_key: Do you have your personal key? + account_recovery_setup: + piv_cac_next_step: Next we need to give you a way to recover your account. forgot_password: close_window: You can close this browser window once you have reset your password. go_back_to_mobile_app: To continue, please go back to the %{friendly_name} app diff --git a/config/locales/instructions/es.yml b/config/locales/instructions/es.yml index 2dd82925b89..5d6010562f2 100644 --- a/config/locales/instructions/es.yml +++ b/config/locales/instructions/es.yml @@ -13,6 +13,8 @@ es: copy: Si no tiene su clave personal, verifique su identidad nuevamente. heading: NOT TRANSLATED YET with_key: "¿Tiene su clave personal?" + account_recovery_setup: + piv_cac_next_step: NOT TRANSLATED YET forgot_password: close_window: Puede cerrar esta ventana del navegador después que haya restablecido su contraseña. diff --git a/config/locales/instructions/fr.yml b/config/locales/instructions/fr.yml index d7e6ee49cc0..96c2c7ad5eb 100644 --- a/config/locales/instructions/fr.yml +++ b/config/locales/instructions/fr.yml @@ -15,6 +15,8 @@ fr: identité de nouveau. heading: Vous n'avez pas votre clé personnelle? with_key: Vous n'avez pas votre clé personnelle? + account_recovery_setup: + piv_cac_next_step: NOT TRANSLATED YET forgot_password: close_window: Vous pourrez fermer cette fenêtre de navigateur lorsque vous aurez réinitialisé votre mot de passe. diff --git a/config/locales/titles/en.yml b/config/locales/titles/en.yml index 11459b4fd35..4e4d8ac16ab 100644 --- a/config/locales/titles/en.yml +++ b/config/locales/titles/en.yml @@ -3,6 +3,7 @@ en: titles: account: Account account_locked: Account temporarily locked + account_recovery_setup: Account Recovery Setup confirmations: new: Resend confirmation instructions for your account show: Choose a password diff --git a/config/locales/titles/es.yml b/config/locales/titles/es.yml index b6d6e03eff2..2c4bc569bd4 100644 --- a/config/locales/titles/es.yml +++ b/config/locales/titles/es.yml @@ -3,6 +3,7 @@ es: titles: account: Cuenta account_locked: Cuenta bloqueada temporalmente + account_recovery_setup: NOT TRANSLATED YET confirmations: new: Reenviar instrucciones de confirmación de su cuenta show: Elija una contraseña diff --git a/config/locales/titles/fr.yml b/config/locales/titles/fr.yml index 5059a46b48b..cc6b8a10858 100644 --- a/config/locales/titles/fr.yml +++ b/config/locales/titles/fr.yml @@ -3,6 +3,7 @@ fr: titles: account: Compte account_locked: Compte temporairement verrouillé + account_recovery_setup: NOT TRANSLATED YET confirmations: new: Envoyer les instructions de confirmation pour votre compte show: Choisissez un mot de passe diff --git a/config/routes.rb b/config/routes.rb index cea7e3dee93..c29d2a27cd4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -99,6 +99,7 @@ as: :create_verify_personal_key get '/account/verify_phone' => 'users/verify_profile_phone#index', as: :verify_profile_phone post '/account/verify_phone' => 'users/verify_profile_phone#create' + get '/account_recovery_setup' => 'account_recovery_setup#index' if FeatureManagement.piv_cac_enabled? get '/piv_cac' => 'users/piv_cac_authentication_setup#new', as: :setup_piv_cac diff --git a/spec/controllers/account_recovery_setup_controller_spec.rb b/spec/controllers/account_recovery_setup_controller_spec.rb new file mode 100644 index 00000000000..4c77947abf2 --- /dev/null +++ b/spec/controllers/account_recovery_setup_controller_spec.rb @@ -0,0 +1,35 @@ +require 'rails_helper' + +describe AccountRecoverySetupController do + context 'user is not piv_cac enabled' do + it 'redirects to account_url' do + stub_sign_in + + get :index + + expect(response).to redirect_to account_url + end + end + + context 'user is piv_cac enabled and phone enabled' do + it 'redirects to account_url' do + user = build(:user, :signed_up, :with_piv_or_cac) + stub_sign_in(user) + + get :index + + expect(response).to redirect_to account_url + end + end + + context 'user is piv_cac enabled but not phone enabled' do + it 'redirects to account_url' do + user = build(:user, :signed_up, :with_piv_or_cac, phone: nil) + stub_sign_in(user) + + get :index + + expect(response).to render_template(:index) + end + end +end diff --git a/spec/controllers/saml_idp_controller_spec.rb b/spec/controllers/saml_idp_controller_spec.rb index f202715267e..e2ffb79274a 100644 --- a/spec/controllers/saml_idp_controller_spec.rb +++ b/spec/controllers/saml_idp_controller_spec.rb @@ -894,6 +894,7 @@ def stub_auth allow(controller).to receive(:validate_saml_request_and_authn_context).and_return(true) allow(controller).to receive(:user_fully_authenticated?).and_return(true) allow(controller).to receive(:link_identity_from_session_data).and_return(true) + allow(controller).to receive(:current_user).and_return(build(:user)) end context 'user requires ID verification' do diff --git a/spec/controllers/users/phone_setup_controller_spec.rb b/spec/controllers/users/phone_setup_controller_spec.rb index f8797d1b3e6..d96c2364cd6 100644 --- a/spec/controllers/users/phone_setup_controller_spec.rb +++ b/spec/controllers/users/phone_setup_controller_spec.rb @@ -161,15 +161,16 @@ expect(subject).to have_actions( :before, :authenticate_user, - :authorize_phone_setup + :authorize_user ) end end - describe '#authorize_otp_setup' do - context 'when the user is fully authenticated' do + describe '#authorize_user' do + context 'when the user is fully authenticated and phone enabled' do it 'redirects to account url' do - stub_sign_in + user = build_stubbed(:user, :with_phone) + stub_sign_in(user) get :index diff --git a/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb b/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb index 63fe066c199..e5af8d4b2e5 100644 --- a/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb +++ b/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb @@ -20,9 +20,10 @@ end end - context 'when fully authenticated' do + context 'when fully authenticated and phone enabled' do it 'redirects to account page' do - stub_sign_in + user = build(:user, :signed_up) + stub_sign_in(user) get :index @@ -30,6 +31,16 @@ end end + context 'when fully authenticated but not phone enabled' do + it 'allows access' do + stub_sign_in + + get :index + + expect(response).to render_template(:index) + end + end + context 'already two factor enabled but not fully authenticated' do it 'prompts for 2FA' do user = build(:user, :signed_up) diff --git a/spec/features/users/piv_cac_management_spec.rb b/spec/features/users/piv_cac_management_spec.rb index 7e8c77b3d42..9a033a90408 100644 --- a/spec/features/users/piv_cac_management_spec.rb +++ b/spec/features/users/piv_cac_management_spec.rb @@ -37,10 +37,10 @@ def find_form(page, attributes) sign_in_and_2fa_user(user) visit account_path - expect(page).to have_link(t('forms.buttons.enable'), href: setup_piv_cac_url) + click_link t('forms.buttons.enable'), href: setup_piv_cac_url - visit setup_piv_cac_url expect(page).to have_link(t('forms.piv_cac_setup.submit')) + nonce = get_piv_cac_nonce_from_link(find_link(t('forms.piv_cac_setup.submit'))) visit_piv_cac_service(setup_piv_cac_url, @@ -66,6 +66,32 @@ def find_form(page, attributes) form = find_form(page, action: disable_piv_cac_url) expect(form).to be_nil end + + context 'when the user does not have a phone number yet' do + it 'prompts to set one up after configuring PIV/CAC' do + allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) + stub_piv_cac_service + + user.update(phone: nil, otp_secret_key: 'secret') + sign_in_and_2fa_user(user) + visit account_path + click_link t('forms.buttons.enable'), href: setup_piv_cac_url + + expect(page).to have_current_path(setup_piv_cac_path) + + nonce = get_piv_cac_nonce_from_link(find_link(t('forms.piv_cac_setup.submit'))) + visit_piv_cac_service(setup_piv_cac_url, + nonce: nonce, + uuid: SecureRandom.uuid, + subject: 'SomeIgnoredSubject') + + expect(page).to have_current_path(account_recovery_setup_path) + + configure_backup_phone + + expect(page).to have_current_path account_path + end + end end context 'with a service provider not allowed to use piv/cac' do diff --git a/spec/features/users/sign_in_spec.rb b/spec/features/users/sign_in_spec.rb index 860fb3210e0..21441cfb3ce 100644 --- a/spec/features/users/sign_in_spec.rb +++ b/spec/features/users/sign_in_spec.rb @@ -403,6 +403,8 @@ it_behaves_like 'signing in as LOA3 with personal key', :oidc it_behaves_like 'signing in with wrong credentials', :saml it_behaves_like 'signing in with wrong credentials', :oidc + it_behaves_like 'signing with while PIV/CAC enabled but not phone enabled', :saml + it_behaves_like 'signing with while PIV/CAC enabled but not phone enabled', :oidc context 'user signs in with personal key, visits account page before viewing new key' do # this can happen if you submit the personal key form multiple times quickly diff --git a/spec/features/users/sign_up_spec.rb b/spec/features/users/sign_up_spec.rb index 8e72c903b47..adbe4f299b2 100644 --- a/spec/features/users/sign_up_spec.rb +++ b/spec/features/users/sign_up_spec.rb @@ -155,6 +155,9 @@ it_behaves_like 'creating an account using authenticator app for 2FA', :saml it_behaves_like 'creating an account using authenticator app for 2FA', :oidc + it_behaves_like 'creating an account using PIV/CAC for 2FA', :saml + it_behaves_like 'creating an account using PIV/CAC for 2FA', :oidc + it 'allows a user to choose TOTP as 2FA method during sign up' do sign_in_user set_up_2fa_with_authenticator_app @@ -174,31 +177,6 @@ ) end - context 'when piv/cac is allowed' do - it 'allows a user to choose piv/cac as 2FA method during sign up' do - allow(PivCacService).to receive(:piv_cac_available_for_agency?).and_return(true) - allow(FeatureManagement).to receive(:piv_cac_enabled?).and_return(true) - - begin_sign_up_with_sp_and_loa(loa3: false) - - expect(page).to have_current_path two_factor_options_path - expect(page).to have_content( - t('devise.two_factor_authentication.two_factor_choice_options.piv_cac') - ) - end - - it 'directs to the piv/cac setup page' do - allow(PivCacService).to receive(:piv_cac_available_for_agency?).and_return(true) - allow(FeatureManagement).to receive(:piv_cac_enabled?).and_return(true) - - begin_sign_up_with_sp_and_loa(loa3: false) - - expect(page).to have_current_path two_factor_options_path - select_2fa_option('piv_cac') - expect(page).to have_current_path setup_piv_cac_path - end - end - it 'does not bypass 2FA when accessing authenticator_setup_path if the user is 2FA enabled' do user = create(:user, :signed_up) sign_in_user(user) diff --git a/spec/support/features/session_helper.rb b/spec/support/features/session_helper.rb index d3beb46f646..bdfb8b76be6 100644 --- a/spec/support/features/session_helper.rb +++ b/spec/support/features/session_helper.rb @@ -407,6 +407,32 @@ def set_up_2fa_with_authenticator_app click_button 'Submit' end + def register_user_with_piv_cac(email = 'test@test.com') + allow(PivCacService).to receive(:piv_cac_available_for_agency?).and_return(true) + allow(FeatureManagement).to receive(:piv_cac_enabled?).and_return(true) + confirm_email_and_password(email) + + expect(page).to have_current_path two_factor_options_path + expect(page).to have_content( + t('devise.two_factor_authentication.two_factor_choice_options.piv_cac') + ) + + set_up_2fa_with_piv_cac + end + + def set_up_2fa_with_piv_cac + stub_piv_cac_service + select_2fa_option('piv_cac') + + expect(page).to have_current_path setup_piv_cac_path + + nonce = get_piv_cac_nonce_from_link(find_link(t('forms.piv_cac_setup.submit'))) + visit_piv_cac_service(setup_piv_cac_url, + nonce: nonce, + uuid: SecureRandom.uuid, + subject: 'SomeIgnoredSubject') + end + def sign_in_via_branded_page(user) allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) click_link t('links.sign_in') @@ -456,5 +482,12 @@ def link_identity(user, client_id, ial = nil) ial: ial ) end + + def configure_backup_phone + select_2fa_option('sms') + fill_in 'user_phone_form_phone', with: '202-555-1212' + click_send_security_code + click_submit_default + end end end diff --git a/spec/support/shared_examples/account_creation.rb b/spec/support/shared_examples/account_creation.rb index 8c19d125035..b5f0381ea93 100644 --- a/spec/support/shared_examples/account_creation.rb +++ b/spec/support/shared_examples/account_creation.rb @@ -68,3 +68,30 @@ end end end + +shared_examples 'creating an account using PIV/CAC for 2FA' do |sp| + it 'redirects to the SP', email: true do + allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) + visit_idp_from_sp_with_loa1(sp) + register_user_with_piv_cac + + expect(page).to have_current_path(account_recovery_setup_path) + expect(page).to have_content t('instructions.account_recovery_setup.piv_cac_next_step') + configure_backup_phone + click_acknowledge_personal_key + + if sp == :oidc + expect(page.response_headers['Content-Security-Policy']). + to(include('form-action \'self\' http://localhost:7654')) + end + + click_on t('forms.buttons.continue') + expect(current_url).to eq @saml_authn_request if sp == :saml + + if sp == :oidc + redirect_uri = URI(current_url) + + expect(redirect_uri.to_s).to start_with('http://localhost:7654/auth/result') + end + end +end diff --git a/spec/support/shared_examples/sign_in.rb b/spec/support/shared_examples/sign_in.rb index 40631bdf3fe..6a89df88c95 100644 --- a/spec/support/shared_examples/sign_in.rb +++ b/spec/support/shared_examples/sign_in.rb @@ -177,6 +177,28 @@ end end +shared_examples 'signing with while PIV/CAC enabled but not phone enabled' do |sp| + it 'does not allow bypassing setting up backup phone' do + stub_piv_cac_service + + user = create(:user, :signed_up, :with_piv_or_cac, phone: nil) + visit_idp_from_sp_with_loa1(sp) + click_link t('links.sign_in') + fill_in_credentials_and_submit(user.email, user.password) + nonce = visit_login_two_factor_piv_cac_and_get_nonce + visit_piv_cac_service(login_two_factor_piv_cac_path, + uuid: user.x509_dn_uuid, + dn: 'C=US, O=U.S. Government, OU=DoD, OU=PKI, CN=DOE.JOHN.1234', + nonce: nonce) + + expect(current_path).to eq account_recovery_setup_path + + visit_idp_from_sp_with_loa1(sp) + + expect(current_path).to eq account_recovery_setup_path + end +end + def personal_key_for_loa3_user(user, pii) pii_attrs = Pii::Attributes.new_from_hash(pii) profile = user.profiles.last From 08689c76773bcf2eea4087583671aa479011c091 Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Fri, 15 Jun 2018 08:22:56 -0500 Subject: [PATCH 30/40] Don't check attempt count on success step (#2246) **Why**: So that if a user successfully verifies on their last attempt, we don't redirect to the failure screen from the success screen. --- app/controllers/idv/sessions_controller.rb | 2 +- spec/support/idv_examples/max_attempts.rb | 121 +++++++++++++-------- 2 files changed, 75 insertions(+), 48 deletions(-) diff --git a/app/controllers/idv/sessions_controller.rb b/app/controllers/idv/sessions_controller.rb index b56cee9df6b..d434f610094 100644 --- a/app/controllers/idv/sessions_controller.rb +++ b/app/controllers/idv/sessions_controller.rb @@ -5,7 +5,7 @@ class SessionsController < ApplicationController include PersonalKeyConcern before_action :confirm_two_factor_authenticated, except: [:destroy] - before_action :confirm_idv_attempts_allowed + before_action :confirm_idv_attempts_allowed, except: %i[destroy success] before_action :confirm_idv_needed before_action :confirm_step_needed, except: %i[destroy success] before_action :initialize_idv_session, only: [:create] diff --git a/spec/support/idv_examples/max_attempts.rb b/spec/support/idv_examples/max_attempts.rb index 22bd2b408ba..ffda4afb25f 100644 --- a/spec/support/idv_examples/max_attempts.rb +++ b/spec/support/idv_examples/max_attempts.rb @@ -8,70 +8,97 @@ before do start_idv_from_sp(sp) complete_idv_steps_before_step(step, user) - if step == :profile - perfom_maximum_allowed_idv_step_attempts { fill_out_idv_form_fail } - elsif step == :phone - perfom_maximum_allowed_idv_step_attempts { fill_out_phone_form_fail } - end end - scenario 'more than 3 attempts in 24 hours prevents further attempts' do - # Blocked if visiting verify directly - visit idv_url - advance_to_phone_step if step == :phone - expect_user_to_be_unable_to_perform_idv(sp) + context 'after completing the max number of attempts' do + before do + if step == :profile + perfom_maximum_allowed_idv_step_attempts { fill_out_idv_form_fail } + elsif step == :phone + perfom_maximum_allowed_idv_step_attempts { fill_out_phone_form_fail } + end + end - # Blocked if visiting from an SP - visit_idp_from_sp_with_loa3(:oidc) - advance_to_phone_step if step == :phone - expect_user_to_be_unable_to_perform_idv(sp) + scenario 'more than 3 attempts in 24 hours prevents further attempts' do + # Blocked if visiting verify directly + visit idv_url + advance_to_phone_step if step == :phone + expect_user_to_be_unable_to_perform_idv(sp) - if step == :sessions - user.reload + # Blocked if visiting from an SP + visit_idp_from_sp_with_loa3(:oidc) + advance_to_phone_step if step == :phone + expect_user_to_be_unable_to_perform_idv(sp) - expect(user.idv_attempted_at).to_not be_nil + if step == :sessions + user.reload + + expect(user.idv_attempted_at).to_not be_nil + end end - end - scenario 'after 24 hours the user can retry and complete idv' do - visit account_path - first(:link, t('links.sign_out')).click - reattempt_interval = (Figaro.env.idv_attempt_window_in_hours.to_i + 1).hours + scenario 'after 24 hours the user can retry and complete idv' do + visit account_path + first(:link, t('links.sign_out')).click + reattempt_interval = (Figaro.env.idv_attempt_window_in_hours.to_i + 1).hours - Timecop.travel reattempt_interval do - visit_idp_from_sp_with_loa3(:oidc) - click_link t('links.sign_in') - sign_in_live_with_2fa(user) + Timecop.travel reattempt_interval do + visit_idp_from_sp_with_loa3(:oidc) + click_link t('links.sign_in') + sign_in_live_with_2fa(user) - expect(page).to_not have_content(t("idv.modal.#{step_locale_key}.heading")) - expect(current_url).to eq(idv_jurisdiction_url) + expect(page).to_not have_content(t("idv.modal.#{step_locale_key}.heading")) + expect(current_url).to eq(idv_jurisdiction_url) - fill_out_idv_jurisdiction_ok - click_idv_continue - complete_idv_profile_ok(user) - click_acknowledge_personal_key - click_idv_continue + fill_out_idv_jurisdiction_ok + click_idv_continue + complete_idv_profile_ok(user) + click_acknowledge_personal_key + click_idv_continue - expect(current_url).to start_with('http://localhost:7654/auth/result') + expect(current_url).to start_with('http://localhost:7654/auth/result') + end end - end - scenario 'user sees failure flash message' do - expect(page).to have_css('.alert-error', text: t("idv.modal.#{step_locale_key}.heading")) - expect(page).to have_css( - '.alert-error', - text: strip_tags(t("idv.modal.#{step_locale_key}.fail")) - ) - end - - context 'with js', :js do - scenario 'user sees the failure modal' do - expect(page).to have_css('.modal-fail', text: t("idv.modal.#{step_locale_key}.heading")) + scenario 'user sees failure flash message' do + expect(page).to have_css('.alert-error', text: t("idv.modal.#{step_locale_key}.heading")) expect(page).to have_css( - '.modal-fail', + '.alert-error', text: strip_tags(t("idv.modal.#{step_locale_key}.fail")) ) end + + context 'with js', :js do + scenario 'user sees the failure modal' do + expect(page).to have_css('.modal-fail', text: t("idv.modal.#{step_locale_key}.heading")) + expect(page).to have_css( + '.modal-fail', + text: strip_tags(t("idv.modal.#{step_locale_key}.fail")) + ) + end + end + end + + context 'after completing one less than the max attempts' do + it 'allows the user to continue if their last attempt is successful' do + max_attempts_less_one.times do + fill_out_idv_form_fail if step == :profile + fill_out_phone_form_fail if step == :phone + click_continue + end + + fill_out_idv_form_ok if step == :profile + fill_out_phone_form_ok if step == :phone + click_continue + + if step == :profile + expect(page).to have_content(t('idv.titles.session.success')) + expect(page).to have_current_path(idv_session_success_path) + elsif step == :phone + expect(page).to have_content(t('idv.titles.otp_delivery_method')) + expect(page).to have_current_path(idv_otp_delivery_method_path) + end + end end def perfom_maximum_allowed_idv_step_attempts From 9e783d7af98c2b46660d6b4ffb6a6c8a81c19d0c Mon Sep 17 00:00:00 2001 From: James Smith Date: Thu, 14 Jun 2018 09:36:17 -0400 Subject: [PATCH 31/40] [LG-257] Use redirection to hide nonce from HTML **Why**: We don't want the nonce discoverable by JavaScript. **How**: We use an endpoint to create a redirect to the piv/cac service. We create a new nonce when we send the redirect. --- .../piv_cac_verification_controller.rb | 3 +-- .../users/piv_cac_authentication_setup_controller.rb | 12 ++++++++---- .../piv_cac_authentication_setup_base_presenter.rb | 6 +----- .../piv_cac_authentication_presenter.rb | 2 +- config/routes.rb | 1 + saml_20180607094309.txt | 0 spec/support/features/session_helper.rb | 10 +++++++++- 7 files changed, 21 insertions(+), 13 deletions(-) delete mode 100644 saml_20180607094309.txt 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 2673991d530..e60a21c435f 100644 --- a/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb +++ b/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb @@ -3,14 +3,13 @@ class PivCacVerificationController < ApplicationController include TwoFactorAuthenticatable include PivCacConcern - before_action :confirm_piv_cac_enabled + before_action :confirm_piv_cac_enabled, only: :show before_action :reset_attempt_count_if_user_no_longer_locked_out, only: :show def show if params[:token] process_token else - create_piv_cac_nonce @presenter = presenter_for_two_factor_authentication_method end end diff --git a/app/controllers/users/piv_cac_authentication_setup_controller.rb b/app/controllers/users/piv_cac_authentication_setup_controller.rb index e4982c9f1cc..1d016e3fcbd 100644 --- a/app/controllers/users/piv_cac_authentication_setup_controller.rb +++ b/app/controllers/users/piv_cac_authentication_setup_controller.rb @@ -4,7 +4,9 @@ class PivCacAuthenticationSetupController < ApplicationController include PivCacConcern before_action :authenticate_user! - before_action :confirm_two_factor_authenticated, if: :two_factor_enabled? + before_action :confirm_two_factor_authenticated, + if: :two_factor_enabled?, + except: :redirect_to_piv_cac_service before_action :authorize_piv_cac_setup, only: :new before_action :authorize_piv_cac_disable, only: :delete @@ -12,9 +14,7 @@ def new if params.key?(:token) process_piv_cac_setup else - # add a nonce that we track for the return analytics.track_event(Analytics::USER_REGISTRATION_PIV_CAC_SETUP_VISIT) - create_piv_cac_nonce @presenter = PivCacAuthenticationSetupPresenter.new(user_piv_cac_form) render :new end @@ -29,6 +29,11 @@ def delete redirect_to account_url end + def redirect_to_piv_cac_service + create_piv_cac_nonce + redirect_to PivCacService.piv_cac_service_link(piv_cac_nonce) + end + private def two_factor_enabled? @@ -68,7 +73,6 @@ def next_step end def process_invalid_submission - create_piv_cac_nonce @presenter = PivCacAuthenticationSetupErrorPresenter.new(user_piv_cac_form) clear_piv_cac_information render :error diff --git a/app/presenters/piv_cac_authentication_setup_base_presenter.rb b/app/presenters/piv_cac_authentication_setup_base_presenter.rb index e51ec8a7387..56dc061ab57 100644 --- a/app/presenters/piv_cac_authentication_setup_base_presenter.rb +++ b/app/presenters/piv_cac_authentication_setup_base_presenter.rb @@ -8,15 +8,11 @@ def initialize(form) @form = form end - def piv_cac_nonce - @form.nonce - end - def piv_cac_capture_text t('forms.piv_cac_setup.submit') end def piv_cac_service_link - PivCacService.piv_cac_service_link(piv_cac_nonce) + redirect_to_piv_cac_service_url end end diff --git a/app/presenters/two_factor_auth_code/piv_cac_authentication_presenter.rb b/app/presenters/two_factor_auth_code/piv_cac_authentication_presenter.rb index 6a1d7017347..ef09966c1ce 100644 --- a/app/presenters/two_factor_auth_code/piv_cac_authentication_presenter.rb +++ b/app/presenters/two_factor_auth_code/piv_cac_authentication_presenter.rb @@ -35,7 +35,7 @@ def cancel_link end def piv_cac_service_link - PivCacService.piv_cac_service_link(piv_cac_nonce) + redirect_to_piv_cac_service_url end private diff --git a/config/routes.rb b/config/routes.rb index c29d2a27cd4..5e2c3f26cdc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -104,6 +104,7 @@ if FeatureManagement.piv_cac_enabled? get '/piv_cac' => 'users/piv_cac_authentication_setup#new', as: :setup_piv_cac delete '/piv_cac' => 'users/piv_cac_authentication_setup#delete', as: :disable_piv_cac + get '/present_piv_cac' => 'users/piv_cac_authentication_setup#redirect_to_piv_cac_service', as: :redirect_to_piv_cac_service end delete '/authenticator_setup' => 'users/totp_setup#disable', as: :disable_totp diff --git a/saml_20180607094309.txt b/saml_20180607094309.txt deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/spec/support/features/session_helper.rb b/spec/support/features/session_helper.rb index bdfb8b76be6..96672d210e2 100644 --- a/spec/support/features/session_helper.rb +++ b/spec/support/features/session_helper.rb @@ -470,8 +470,16 @@ def visit_login_two_factor_piv_cac_and_get_nonce get_piv_cac_nonce_from_link(find_link(t('forms.piv_cac_mfa.submit'))) end + # This is a bit convoluted because we generate a nonce when we visit the + # link. The link provides a redirect to the piv/cac service with the nonce. + # This way, even if JavaScript fetches the link to grab the nonce, a new nonce + # is generated when the user clicks on the link. def get_piv_cac_nonce_from_link(link) - CGI.unescape(URI(link['href']).query.sub(/^nonce=/, '')) + go_back = current_path + visit link['href'] + nonce = CGI.unescape(URI(current_url).query.sub(/^nonce=/, '')) + visit go_back + nonce end def link_identity(user, client_id, ial = nil) From fcdd895d0102d0b8c7a74624e8e0408ff0ab21df Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Fri, 15 Jun 2018 10:38:23 -0400 Subject: [PATCH 32/40] LG-372 Update SAM SP in production **Why**: SAM wants to add a redirect uri and a new SP that they can use to test prod from UAT. **How**: Update service_providers.yml --- config/service_providers.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/config/service_providers.yml b/config/service_providers.yml index 2c48c8350fe..463400bc902 100644 --- a/config/service_providers.yml +++ b/config/service_providers.yml @@ -671,4 +671,17 @@ production: return_to_sp_url: 'https://sam.gov/portal/SAM' redirect_uris: - 'https://sam.gov/portal/SAM' + - 'https://www.sam.gov/portal/SAM' + restrict_to_deploy_env: 'prod' + + # SAM – System for Award Management / testing prod from UAT + 'urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:sam_uat': + agency_id: 9 + friendly_name: 'SAM - UAT' + agency: 'GSA' + logo: 'sam.png' + cert: 'sam_prod' + return_to_sp_url: 'https://uat.sam.gov/portal/SAM' + redirect_uris: + - 'https://uat.sam.gov/portal/SAM' restrict_to_deploy_env: 'prod' From 085e1ce7d6787f32e17d8f14b08120a998d9e39b Mon Sep 17 00:00:00 2001 From: James G Smith Date: Fri, 15 Jun 2018 12:30:07 -0400 Subject: [PATCH 33/40] [LG-267] Auth requests to decode pivcac tokens (#2242) **Why**: We don't want everyone being able to take a token and inspect it. We only want the IdP able to do this. **How**: We have several options, including client/server TLS certificates. But adding an HMAC with a shared secret is the lightest weight way to lock down the decoding endpoint. --- app/services/piv_cac_service.rb | 26 +++++++++++++++++++++++--- config/application.yml.example | 2 ++ spec/services/piv_cac_service_spec.rb | 5 ++++- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/app/services/piv_cac_service.rb b/app/services/piv_cac_service.rb index d81d54700a8..facd772de2d 100644 --- a/app/services/piv_cac_service.rb +++ b/app/services/piv_cac_service.rb @@ -1,3 +1,4 @@ +require 'base64' require 'cgi' require 'net/https' @@ -61,14 +62,33 @@ def token_decoded(token) return { 'error' => 'service.disabled' } if FeatureManagement.identity_pki_disabled? uri = URI(piv_cac_verify_token_link) - res = Net::HTTP.post_form(uri, token: token) + res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http| + http.request(decode_request(uri, token)) + end decode_token_response(res) end + def decode_request(uri, token) + req = Net::HTTP::Post.new(uri, 'Authentication' => authenticate(token)) + req.form_data = { token: token } + req + end + + def authenticate(token) + # TODO: make this secret required once we have everything deployed and configured + # The piv/cac service side is pending, so this is not critical yet. + secret = Figaro.env.piv_cac_verify_token_secret + return '' if secret.blank? + nonce = SecureRandom.hex(10) + hmac = Base64.urlsafe_encode64( + OpenSSL::HMAC.digest('SHA256', secret, [token, nonce].join('+')) + ) + "hmac :#{nonce}:#{hmac}" + end + def decode_token_response(res) return { 'error' => 'token.bad' } unless res.code.to_i == 200 - result = res.body - JSON.parse(result) + JSON.parse(res.body) rescue JSON::JSONError { 'error' => 'token.bad' } end diff --git a/config/application.yml.example b/config/application.yml.example index 3757df19203..654764aa85b 100644 --- a/config/application.yml.example +++ b/config/application.yml.example @@ -146,6 +146,7 @@ development: password_strength_enabled: 'true' piv_cac_agencies: '["Test Government Agency"]' piv_cac_enabled: 'true' + piv_cac_verify_token_secret: 'ee7f20f44cdc2ba0c6830f70470d1d1d059e1279cdb58134db92b35947b1528ef5525ece5910cf4f2321ab989a618feea12ef95711dbc62b9601e8520a34ee12' piv_cac_service_url: 'https://localhost:8443/' piv_cac_verify_token_url: 'https://localhost:8443/' pkcs11_lib: '/opt/cloudhsm/lib/libcloudhsm_pkcs11.so' @@ -370,6 +371,7 @@ test: piv_cac_agencies: '["Test Government Agency"]' piv_cac_enabled: 'true' piv_cac_service_url: 'https://localhost:8443/' + piv_cac_verify_token_secret: '3ac13bfa23e22adae321194c083e783faf89469f6f85dcc0802b27475c94b5c3891b5657bd87d0c1ad65de459166440512f2311018db90d57b15d8ab6660748f' piv_cac_verify_token_url: 'https://localhost:8443/' pkcs11_lib: '/opt/cloudhsm/lib/libcloudhsm_pkcs11.so' proofer_mock_fallback: 'true' diff --git a/spec/services/piv_cac_service_spec.rb b/spec/services/piv_cac_service_spec.rb index 5bcfe56abc0..78446c738f5 100644 --- a/spec/services/piv_cac_service_spec.rb +++ b/spec/services/piv_cac_service_spec.rb @@ -104,7 +104,10 @@ let!(:request) do stub_request(:post, 'localhost:8443'). - with(body: 'token=foo'). + with( + body: 'token=foo', + headers: {'Authentication' => %r<^hmac\s+:.+:.+$>} + ). to_return( status: [200, 'Ok'], body: '{"dn":"dn","uuid":"uuid"}' From a676258941718ce2764822459459f436e853b942 Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Fri, 15 Jun 2018 11:38:30 -0500 Subject: [PATCH 34/40] Move Encryptor into the encryption namespace (#2249) **Why**: We added the `Encryption` namespace to encapsulate the code related to encryption. Previously this code primarily lived in the `Pii` namespace. This commit moves the underlying encryptor and related classes / modules into the encryption module. --- app/controllers/users/sessions_controller.rb | 2 +- app/forms/verify_personal_key_form.rb | 2 +- .../cipher.rb => encryption/aes_cipher.rb} | 6 +- app/services/{pii => encryption}/encodable.rb | 2 +- .../{pii => encryption}/encryption_error.rb | 2 +- .../encryption/encryptors/aes_encryptor.rb | 61 +++++++++++++++++++ .../encryptors/attribute_encryptor.rb | 4 +- .../encryption/encryptors/pii_encryptor.rb | 10 +-- .../encryptors/user_access_key_encryptor.rb | 8 +-- app/services/encryption/kms_client.rb | 6 +- app/services/encryption/password_verifier.rb | 4 +- app/services/personal_key_generator.rb | 8 ++- app/services/pii/encryptor.rb | 59 ------------------ .../users/sessions_controller_spec.rb | 6 +- spec/features/session/decryption_spec.rb | 4 +- spec/features/users/sign_in_spec.rb | 2 +- spec/services/encrypted_attribute_spec.rb | 2 +- .../aes_cipher_spec.rb} | 4 +- .../encryptors/aes_encryptor_spec.rb} | 4 +- .../encryptors/attribute_encryptor_spec.rb | 2 +- .../encryptors/pii_encryptor_spec.rb | 10 +-- .../user_access_key_encryptor_spec.rb | 4 +- spec/services/encryption/kms_client_spec.rb | 4 +- spec/services/pii/nist_encryption_spec.rb | 6 +- 24 files changed, 114 insertions(+), 108 deletions(-) rename app/services/{pii/cipher.rb => encryption/aes_cipher.rb} (94%) rename app/services/{pii => encryption}/encodable.rb (94%) rename app/services/{pii => encryption}/encryption_error.rb (73%) create mode 100644 app/services/encryption/encryptors/aes_encryptor.rb delete mode 100644 app/services/pii/encryptor.rb rename spec/services/{pii/cipher_spec.rb => encryption/aes_cipher_spec.rb} (92%) rename spec/services/{pii/encryptor_spec.rb => encryption/encryptors/aes_encryptor_spec.rb} (89%) diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index 05557de1914..e6e940a43a5 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -120,7 +120,7 @@ def cache_active_profile profile = current_user.decorate.active_or_pending_profile begin cacher.save(auth_params[:password], profile) - rescue Pii::EncryptionError => err + rescue Encryption::EncryptionError => err profile.deactivate(:encryption_error) analytics.track_event(Analytics::PROFILE_ENCRYPTION_INVALID, error: err.message) end diff --git a/app/forms/verify_personal_key_form.rb b/app/forms/verify_personal_key_form.rb index c718ec1a732..5a96dce3009 100644 --- a/app/forms/verify_personal_key_form.rb +++ b/app/forms/verify_personal_key_form.rb @@ -51,7 +51,7 @@ def reset_sensitive_fields def personal_key_decrypts? decrypted_pii.present? - rescue Pii::EncryptionError => _err + rescue Encryption::EncryptionError => _err false end end diff --git a/app/services/pii/cipher.rb b/app/services/encryption/aes_cipher.rb similarity index 94% rename from app/services/pii/cipher.rb rename to app/services/encryption/aes_cipher.rb index 64fb17458d3..9022acdfcfa 100644 --- a/app/services/pii/cipher.rb +++ b/app/services/encryption/aes_cipher.rb @@ -1,5 +1,5 @@ -module Pii - class Cipher +module Encryption + class AesCipher include Encodable def encrypt(plaintext, cek) @@ -49,7 +49,7 @@ def try_decipher(unpacked_payload) def unpack_payload(payload) JSON.parse(payload, symbolize_names: true) rescue StandardError - raise Pii::EncryptionError, 'Unable to parse encrypted payload' + raise EncryptionError, 'Unable to parse encrypted payload' end def iv(unpacked_payload) diff --git a/app/services/pii/encodable.rb b/app/services/encryption/encodable.rb similarity index 94% rename from app/services/pii/encodable.rb rename to app/services/encryption/encodable.rb index 9fc1c4a5feb..1c726e60603 100644 --- a/app/services/pii/encodable.rb +++ b/app/services/encryption/encodable.rb @@ -1,4 +1,4 @@ -module Pii +module Encryption module Encodable extend ActiveSupport::Concern diff --git a/app/services/pii/encryption_error.rb b/app/services/encryption/encryption_error.rb similarity index 73% rename from app/services/pii/encryption_error.rb rename to app/services/encryption/encryption_error.rb index d117adf952b..95696c009e3 100644 --- a/app/services/pii/encryption_error.rb +++ b/app/services/encryption/encryption_error.rb @@ -1,4 +1,4 @@ -module Pii +module Encryption class EncryptionError < StandardError end end diff --git a/app/services/encryption/encryptors/aes_encryptor.rb b/app/services/encryption/encryptors/aes_encryptor.rb new file mode 100644 index 00000000000..62ebe2a125a --- /dev/null +++ b/app/services/encryption/encryptors/aes_encryptor.rb @@ -0,0 +1,61 @@ +module Encryption + module Encryptors + class AesEncryptor + include Encodable + + DELIMITER = '.'.freeze + + # "It is a riddle, wrapped in a mystery, inside an enigma; but perhaps there is a key." + # - Winston Churchill, https://en.wiktionary.org/wiki/a_riddle_wrapped_up_in_an_enigma + # + + def initialize + self.cipher = AesCipher.new + end + + def encrypt(plaintext, cek) + payload = fingerprint_and_concat(plaintext) + encode(cipher.encrypt(payload, cek)) + end + + def decrypt(ciphertext, cek) + raise EncryptionError, 'ciphertext is invalid' unless sane_payload?(ciphertext) + decrypt_and_test_payload(decode(ciphertext), cek) + end + + private + + attr_accessor :cipher + + def fingerprint_and_concat(plaintext) + fingerprint = Pii::Fingerprinter.fingerprint(plaintext) + join_segments(plaintext, fingerprint) + end + + def decrypt_and_test_payload(payload, cek) + begin + payload = cipher.decrypt(payload, cek) + rescue OpenSSL::Cipher::CipherError => err + raise EncryptionError, err.inspect + end + raise EncryptionError, 'payload is invalid' unless sane_payload?(payload) + plaintext, fingerprint = split_into_segments(payload) + return plaintext if Pii::Fingerprinter.verify(plaintext, fingerprint) + end + + def sane_payload?(payload) + payload.split(DELIMITER).each do |segment| + return false unless valid_base64_encoding?(segment) + end + end + + def join_segments(*segments) + segments.map { |segment| encode(segment) }.join(DELIMITER) + end + + def split_into_segments(string) + string.split(DELIMITER).map { |segment| decode(segment) } + end + end + end +end diff --git a/app/services/encryption/encryptors/attribute_encryptor.rb b/app/services/encryption/encryptors/attribute_encryptor.rb index 43e4d69810e..dd32f618288 100644 --- a/app/services/encryption/encryptors/attribute_encryptor.rb +++ b/app/services/encryption/encryptors/attribute_encryptor.rb @@ -15,7 +15,7 @@ def decrypt(ciphertext) result = try_decrypt(ciphertext, key: key, cost: cost) return result unless result.nil? end - raise Pii::EncryptionError, 'unable to decrypt attribute with any key' + raise EncryptionError, 'unable to decrypt attribute with any key' end def stale? @@ -41,7 +41,7 @@ def try_decrypt(ciphertext, key:, cost:) result = UserAccessKeyEncryptor.new(user_access_key).decrypt(ciphertext) self.stale = key != current_key result - rescue Pii::EncryptionError + rescue EncryptionError nil end end diff --git a/app/services/encryption/encryptors/pii_encryptor.rb b/app/services/encryption/encryptors/pii_encryptor.rb index 2dcb2ab529a..2cc61810111 100644 --- a/app/services/encryption/encryptors/pii_encryptor.rb +++ b/app/services/encryption/encryptors/pii_encryptor.rb @@ -2,16 +2,16 @@ module Encryption module Encryptors class PiiEncryptor Ciphertext = Struct.new(:encrypted_data, :salt, :cost) do - include Pii::Encodable + include Encodable class << self - include Pii::Encodable + include Encodable end def self.parse_from_string(ciphertext_string) parsed_json = JSON.parse(ciphertext_string) new(extract_encrypted_data(parsed_json), parsed_json['salt'], parsed_json['cost']) rescue JSON::ParserError - raise Pii::EncryptionError, 'ciphertext is not valid JSON' + raise EncryptionError, 'ciphertext is not valid JSON' end def to_s @@ -24,7 +24,7 @@ def to_s def self.extract_encrypted_data(parsed_json) encoded_encrypted_data = parsed_json['encrypted_data'] - raise Pii::EncryptionError, 'ciphertext invalid' unless valid_base64_encoding?( + raise EncryptionError, 'ciphertext invalid' unless valid_base64_encoding?( encoded_encrypted_data ) decode(encoded_encrypted_data) @@ -33,7 +33,7 @@ def self.extract_encrypted_data(parsed_json) def initialize(password) @password = password - @aes_cipher = Pii::Cipher.new + @aes_cipher = AesCipher.new @kms_client = KmsClient.new end diff --git a/app/services/encryption/encryptors/user_access_key_encryptor.rb b/app/services/encryption/encryptors/user_access_key_encryptor.rb index 0975639ba74..0f5e505d7fc 100644 --- a/app/services/encryption/encryptors/user_access_key_encryptor.rb +++ b/app/services/encryption/encryptors/user_access_key_encryptor.rb @@ -1,13 +1,13 @@ module Encryption module Encryptors class UserAccessKeyEncryptor - include Pii::Encodable + include Encodable DELIMITER = '.'.freeze def initialize(user_access_key) @user_access_key = user_access_key - @encryptor = Pii::Encryptor.new + @encryptor = AesEncryptor.new end def encrypt(plaintext) @@ -36,7 +36,7 @@ def build_ciphertext(encryption_key, encrypted_contents) def encryption_key_from_ciphertext(ciphertext) encoded_encryption_key = ciphertext.split(DELIMITER).first - raise Pii::EncryptionError, 'ciphertext is invalid' unless valid_base64_encoding?( + raise EncryptionError, 'ciphertext is invalid' unless valid_base64_encoding?( encoded_encryption_key ) decode(encoded_encryption_key) @@ -44,7 +44,7 @@ def encryption_key_from_ciphertext(ciphertext) def encrypted_contents_from_ciphertext(ciphertext) contents = ciphertext.split(DELIMITER).second - raise Pii::EncryptionError, 'ciphertext is missing encrypted contents' if contents.nil? + raise EncryptionError, 'ciphertext is missing encrypted contents' if contents.nil? contents end diff --git a/app/services/encryption/kms_client.rb b/app/services/encryption/kms_client.rb index 2ee8808657c..56c0c77f8d2 100644 --- a/app/services/encryption/kms_client.rb +++ b/app/services/encryption/kms_client.rb @@ -1,6 +1,6 @@ module Encryption class KmsClient - include Pii::Encodable + include Encodable KEY_TYPE = { KMS: 'KMSx', @@ -30,7 +30,7 @@ def decrypt_kms(ciphertext) kms_input = ciphertext.sub(KEY_TYPE[:KMS], '') aws_client.decrypt(ciphertext_blob: kms_input).plaintext rescue Aws::KMS::Errors::InvalidCiphertextException - raise Pii::EncryptionError, 'Aws::KMS::Errors::InvalidCiphertextException' + raise EncryptionError, 'Aws::KMS::Errors::InvalidCiphertextException' end def encrypt_local(plaintext) @@ -53,7 +53,7 @@ def aws_client end def encryptor - @encryptor ||= Pii::Encryptor.new + @encryptor ||= Encryptors::AesEncryptor.new end end end diff --git a/app/services/encryption/password_verifier.rb b/app/services/encryption/password_verifier.rb index 9ddcf577d4c..6e5196bf638 100644 --- a/app/services/encryption/password_verifier.rb +++ b/app/services/encryption/password_verifier.rb @@ -15,7 +15,7 @@ def self.parse_from_string(digest_string) data[:password_cost] ) rescue JSON::ParserError - raise Pii::EncryptionError, 'digest contains invalid json' + raise EncryptionError, 'digest contains invalid json' end def to_s @@ -49,7 +49,7 @@ def self.verify(password:, digest:) ) uak.unlock(parsed_digest.encryption_key) Devise.secure_compare(uak.encrypted_password, parsed_digest.encrypted_password) - rescue Pii::EncryptionError + rescue EncryptionError false end end diff --git a/app/services/personal_key_generator.rb b/app/services/personal_key_generator.rb index 8c9d7b188bd..646a79f2433 100644 --- a/app/services/personal_key_generator.rb +++ b/app/services/personal_key_generator.rb @@ -17,10 +17,12 @@ def create def verify(plaintext_code) @user_access_key = make_user_access_key(normalize(plaintext_code)) - encryption_key, encrypted_code = user.personal_key.split(Pii::Encryptor::DELIMITER) + encryption_key, encrypted_code = user.personal_key.split( + Encryption::Encryptors::AesEncryptor::DELIMITER + ) begin user_access_key.unlock(encryption_key) - rescue Pii::EncryptionError => _err + rescue Encryption::EncryptionError => _err return false end Devise.secure_compare(encrypted_code, user_access_key.encrypted_password) @@ -74,7 +76,7 @@ def hashed_code [ user_access_key.encryption_key, user_access_key.encrypted_password, - ].join(Pii::Encryptor::DELIMITER) + ].join(Encryption::Encryptors::AesEncryptor::DELIMITER) end def raw_personal_key diff --git a/app/services/pii/encryptor.rb b/app/services/pii/encryptor.rb deleted file mode 100644 index c95806e012b..00000000000 --- a/app/services/pii/encryptor.rb +++ /dev/null @@ -1,59 +0,0 @@ -module Pii - class Encryptor - include Encodable - - DELIMITER = '.'.freeze - - # "It is a riddle, wrapped in a mystery, inside an enigma; but perhaps there is a key." - # - Winston Churchill, https://en.wiktionary.org/wiki/a_riddle_wrapped_up_in_an_enigma - # - - def initialize - self.cipher = Pii::Cipher.new - end - - def encrypt(plaintext, cek) - payload = fingerprint_and_concat(plaintext) - encode(cipher.encrypt(payload, cek)) - end - - def decrypt(ciphertext, cek) - raise EncryptionError, 'ciphertext is invalid' unless sane_payload?(ciphertext) - decrypt_and_test_payload(decode(ciphertext), cek) - end - - private - - attr_accessor :cipher - - def fingerprint_and_concat(plaintext) - fingerprint = Pii::Fingerprinter.fingerprint(plaintext) - join_segments(plaintext, fingerprint) - end - - def decrypt_and_test_payload(payload, cek) - begin - payload = cipher.decrypt(payload, cek) - rescue OpenSSL::Cipher::CipherError => err - raise EncryptionError, err.inspect - end - raise EncryptionError, 'payload is invalid' unless sane_payload?(payload) - plaintext, fingerprint = split_into_segments(payload) - return plaintext if Pii::Fingerprinter.verify(plaintext, fingerprint) - end - - def sane_payload?(payload) - payload.split(DELIMITER).each do |segment| - return false unless valid_base64_encoding?(segment) - end - end - - def join_segments(*segments) - segments.map { |segment| encode(segment) }.join(DELIMITER) - end - - def split_into_segments(string) - string.split(DELIMITER).map { |segment| decode(segment) } - end - end -end diff --git a/spec/controllers/users/sessions_controller_spec.rb b/spec/controllers/users/sessions_controller_spec.rb index 5414e70873b..e7c3161b716 100644 --- a/spec/controllers/users/sessions_controller_spec.rb +++ b/spec/controllers/users/sessions_controller_spec.rb @@ -320,13 +320,13 @@ expect(response).to render_template(:new) end - it 'logs Pii::EncryptionError' do + it 'logs Encryption::EncryptionError' do user = create(:user, :signed_up) - allow(user).to receive(:unlock_user_access_key).and_raise Pii::EncryptionError, 'foo' + allow(user).to receive(:unlock_user_access_key).and_raise Encryption::EncryptionError, 'foo' expect(Rails.logger).to receive(:info) do |attributes| attributes = JSON.parse(attributes) - expect(attributes['event']).to eq 'Pii::EncryptionError when validating password' + expect(attributes['event']).to eq 'Encryption::EncryptionError when validating password' expect(attributes['error']).to eq 'foo' expect(attributes['uuid']).to eq user.uuid expect(attributes).to have_key('timestamp') diff --git a/spec/features/session/decryption_spec.rb b/spec/features/session/decryption_spec.rb index 872add2dd25..13db76bc3a4 100644 --- a/spec/features/session/decryption_spec.rb +++ b/spec/features/session/decryption_spec.rb @@ -6,9 +6,9 @@ sign_in_and_2fa_user session_encryptor = Rails.application.config.session_options[:serializer] - allow(session_encryptor).to receive(:load).and_raise(Pii::EncryptionError) + allow(session_encryptor).to receive(:load).and_raise(Encryption::EncryptionError) - expect { visit account_path }.to raise_error(Pii::EncryptionError) + expect { visit account_path }.to raise_error(Encryption::EncryptionError) allow(session_encryptor).to receive(:load).and_call_original visit account_path diff --git a/spec/features/users/sign_in_spec.rb b/spec/features/users/sign_in_spec.rb index 21441cfb3ce..07bfc725e28 100644 --- a/spec/features/users/sign_in_spec.rb +++ b/spec/features/users/sign_in_spec.rb @@ -251,7 +251,7 @@ rotate_attribute_encryption_key_with_invalid_queue expect { signin(email, password) }. - to raise_error Pii::EncryptionError, 'unable to decrypt attribute with any key' + to raise_error Encryption::EncryptionError, 'unable to decrypt attribute with any key' user = User.find_with_email(email) expect(user.encrypted_email).to eq encrypted_email diff --git a/spec/services/encrypted_attribute_spec.rb b/spec/services/encrypted_attribute_spec.rb index 8b56ae499a9..bb627374916 100644 --- a/spec/services/encrypted_attribute_spec.rb +++ b/spec/services/encrypted_attribute_spec.rb @@ -29,7 +29,7 @@ rotate_attribute_encryption_key_with_invalid_queue expect { EncryptedAttribute.new(encrypted_with_old_key) }. - to raise_error Pii::EncryptionError, 'unable to decrypt attribute with any key' + to raise_error Encryption::EncryptionError, 'unable to decrypt attribute with any key' end end diff --git a/spec/services/pii/cipher_spec.rb b/spec/services/encryption/aes_cipher_spec.rb similarity index 92% rename from spec/services/pii/cipher_spec.rb rename to spec/services/encryption/aes_cipher_spec.rb index e522bc2ca97..6290996fcfd 100644 --- a/spec/services/pii/cipher_spec.rb +++ b/spec/services/encryption/aes_cipher_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -describe Pii::Cipher do +describe Encryption::AesCipher do let(:plaintext) { 'some long secret' } let(:cek) { SecureRandom.random_bytes(32) } @@ -25,7 +25,7 @@ ciphertext = subject.encrypt(plaintext, cek) ciphertext += 'foo' - expect { subject.decrypt(ciphertext, cek) }.to raise_error Pii::EncryptionError + expect { subject.decrypt(ciphertext, cek) }.to raise_error Encryption::EncryptionError end end end diff --git a/spec/services/pii/encryptor_spec.rb b/spec/services/encryption/encryptors/aes_encryptor_spec.rb similarity index 89% rename from spec/services/pii/encryptor_spec.rb rename to spec/services/encryption/encryptors/aes_encryptor_spec.rb index ee62f977600..b6142be0cfc 100644 --- a/spec/services/pii/encryptor_spec.rb +++ b/spec/services/encryption/encryptors/aes_encryptor_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -describe Pii::Encryptor do +describe Encryption::Encryptors::AesEncryptor do let(:aes_cek) { SecureRandom.random_bytes(32) } let(:plaintext) { 'four score and seven years ago' } @@ -23,7 +23,7 @@ encrypted = subject.encrypt(plaintext, aes_cek) diff_cek = SecureRandom.random_bytes(32) - expect { subject.decrypt(encrypted, diff_cek) }.to raise_error Pii::EncryptionError + expect { subject.decrypt(encrypted, diff_cek) }.to raise_error Encryption::EncryptionError end end end diff --git a/spec/services/encryption/encryptors/attribute_encryptor_spec.rb b/spec/services/encryption/encryptors/attribute_encryptor_spec.rb index 66610de3639..6906062a1bb 100644 --- a/spec/services/encryption/encryptors/attribute_encryptor_spec.rb +++ b/spec/services/encryption/encryptors/attribute_encryptor_spec.rb @@ -83,7 +83,7 @@ it 'raises and encryption error' do expect { subject.decrypt(ciphertext) }.to raise_error( - Pii::EncryptionError, 'unable to decrypt attribute with any key' + Encryption::EncryptionError, 'unable to decrypt attribute with any key' ) end end diff --git a/spec/services/encryption/encryptors/pii_encryptor_spec.rb b/spec/services/encryption/encryptors/pii_encryptor_spec.rb index 6caa35066f6..bc3d8440e2c 100644 --- a/spec/services/encryption/encryptors/pii_encryptor_spec.rb +++ b/spec/services/encryption/encryptors/pii_encryptor_spec.rb @@ -23,8 +23,8 @@ expect(scrypt_password).to receive(:digest).and_return(scrypt_digest) expect(SCrypt::Password).to receive(:new).and_return(scrypt_password) - cipher = instance_double(Pii::Cipher) - expect(Pii::Cipher).to receive(:new).and_return(cipher) + cipher = instance_double(Encryption::AesCipher) + expect(Encryption::AesCipher).to receive(:new).and_return(cipher) expect(cipher).to receive(:encrypt). with(plaintext, scrypt_digest[0...32]). and_return('aes_ciphertext') @@ -59,7 +59,7 @@ ciphertext = subject.encrypt(plaintext) new_encryptor = described_class.new('This is not the passowrd') - expect { new_encryptor.decrypt(ciphertext) }.to raise_error Pii::EncryptionError + expect { new_encryptor.decrypt(ciphertext) }.to raise_error Encryption::EncryptionError end it 'uses layered AES and KMS to decrypt the contents' do @@ -78,8 +78,8 @@ with('kms_ciphertext'). and_return('aes_ciphertext') - cipher = instance_double(Pii::Cipher) - expect(Pii::Cipher).to receive(:new).and_return(cipher) + cipher = instance_double(Encryption::AesCipher) + expect(Encryption::AesCipher).to receive(:new).and_return(cipher) expect(cipher).to receive(:decrypt). with('aes_ciphertext', scrypt_digest[0...32]). and_return(plaintext) diff --git a/spec/services/encryption/encryptors/user_access_key_encryptor_spec.rb b/spec/services/encryption/encryptors/user_access_key_encryptor_spec.rb index f5cef70078c..c78776164d0 100644 --- a/spec/services/encryption/encryptors/user_access_key_encryptor_spec.rb +++ b/spec/services/encryption/encryptors/user_access_key_encryptor_spec.rb @@ -37,11 +37,11 @@ wrong_key = Encryption::UserAccessKey.new(password: 'This is not the password', salt: salt) new_encryptor = described_class.new(wrong_key) - expect { new_encryptor.decrypt(ciphertext) }.to raise_error Pii::EncryptionError + expect { new_encryptor.decrypt(ciphertext) }.to raise_error Encryption::EncryptionError end it 'raises an error if the ciphertext is not base64 encoded' do - expect { subject.decrypt('@@@@@@@') }.to raise_error Pii::EncryptionError + expect { subject.decrypt('@@@@@@@') }.to raise_error Encryption::EncryptionError end it 'only unlocks the user access key once' do diff --git a/spec/services/encryption/kms_client_spec.rb b/spec/services/encryption/kms_client_spec.rb index 769deadcab6..29469b966de 100644 --- a/spec/services/encryption/kms_client_spec.rb +++ b/spec/services/encryption/kms_client_spec.rb @@ -11,14 +11,14 @@ before do allow(Figaro.env).to receive(:password_pepper).and_return(password_pepper) - encryptor = Pii::Encryptor.new + encryptor = Encryption::Encryptors::AesEncryptor.new allow(encryptor).to receive(:encrypt). with(local_plaintext, password_pepper). and_return(local_ciphertext) allow(encryptor).to receive(:decrypt). with(local_ciphertext, password_pepper). and_return(local_plaintext) - allow(Pii::Encryptor).to receive(:new).and_return(encryptor) + allow(Encryption::Encryptors::AesEncryptor).to receive(:new).and_return(encryptor) stub_aws_kms_client(kms_plaintext, kms_ciphertext) allow(FeatureManagement).to receive(:use_kms?).and_return(kms_enabled) diff --git a/spec/services/pii/nist_encryption_spec.rb b/spec/services/pii/nist_encryption_spec.rb index 0d34956d55b..2f50c5aaf1d 100644 --- a/spec/services/pii/nist_encryption_spec.rb +++ b/spec/services/pii/nist_encryption_spec.rb @@ -135,7 +135,7 @@ expect(Base64.strict_decode64(encrypted_key)).to eq(kms_prefix + encrypted_D) # unroll encrypted_C to verify it was encrypted with hash_E - cipher = Pii::Cipher.new + cipher = Encryption::AesCipher.new expect { cipher.decrypt(encrypted_C, hash_E) }.not_to raise_error @@ -150,7 +150,9 @@ end def open_envelope(envelope) - envelope.split(Pii::Encryptor::DELIMITER).map { |segment| Base64.strict_decode64(segment) } + envelope.split(Encryption::Encryptors::AesEncryptor::DELIMITER).map do |segment| + Base64.strict_decode64(segment) + end end def hex_to_bin(str) From d518db6b3847b2b7d001b62756286543fae18cea Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Fri, 15 Jun 2018 13:22:12 -0400 Subject: [PATCH 35/40] LG-376 Add DOE / Fossil Energy SP to production **Why**: Integration testing is complete and they are ready to go to production. **How**: Update service_providers.yml, add cert and logo, create a new agency (DOE) --- app/assets/images/sp-logos/doe.png | Bin 0 -> 22262 bytes certs/sp/doe_prod.crt | 30 +++++++++++++++++++++++++++++ config/agencies.yml | 6 ++++++ config/service_providers.yml | 15 +++++++++++++++ 4 files changed, 51 insertions(+) create mode 100644 app/assets/images/sp-logos/doe.png create mode 100644 certs/sp/doe_prod.crt diff --git a/app/assets/images/sp-logos/doe.png b/app/assets/images/sp-logos/doe.png new file mode 100644 index 0000000000000000000000000000000000000000..cf097696f310dd251eb6570b18e73b1bbba9cf54 GIT binary patch literal 22262 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 Date: Fri, 15 Jun 2018 11:01:07 -0700 Subject: [PATCH 36/40] LG 362 jurisdiction fail screens (#2247) * Make the generic failure template a partial * Update jurisdiction fail screens --- .../concerns/two_factor_authenticatable.rb | 2 +- .../idv/jurisdiction_controller.rb | 8 ++- app/controllers/users/sessions_controller.rb | 2 +- .../idv/jurisdiction_failure_presenter.rb | 62 +++++++++++++++++++ app/views/idv/jurisdiction/show.html.slim | 22 ------- .../{failure.html.slim => _failure.html.slim} | 0 config/i18n-tasks.yml | 1 + config/locales/idv/en.yml | 15 +++-- config/locales/idv/es.yml | 16 +++-- config/locales/idv/fr.yml | 16 +++-- .../idv/jurisdiction_controller_spec.rb | 16 +---- 11 files changed, 102 insertions(+), 58 deletions(-) create mode 100644 app/presenters/idv/jurisdiction_failure_presenter.rb delete mode 100644 app/views/idv/jurisdiction/show.html.slim rename app/views/shared/{failure.html.slim => _failure.html.slim} (100%) diff --git a/app/controllers/concerns/two_factor_authenticatable.rb b/app/controllers/concerns/two_factor_authenticatable.rb index c25a03acf6d..e57a3e28be9 100644 --- a/app/controllers/concerns/two_factor_authenticatable.rb +++ b/app/controllers/concerns/two_factor_authenticatable.rb @@ -40,7 +40,7 @@ def handle_max_attempts(type) decorated_user ) sign_out - render_full_width('shared/failure', locals: { presenter: presenter }) + render_full_width('shared/_failure', locals: { presenter: presenter }) end def require_current_password diff --git a/app/controllers/idv/jurisdiction_controller.rb b/app/controllers/idv/jurisdiction_controller.rb index a1b515f0128..5eb733b6fb9 100644 --- a/app/controllers/idv/jurisdiction_controller.rb +++ b/app/controllers/idv/jurisdiction_controller.rb @@ -27,8 +27,12 @@ def create end def show - @state = user_session[:idv_jurisdiction] - @reason = params[:reason] + presenter = JurisdictionFailurePresenter.new( + reason: params[:reason], + jurisdiction: user_session[:idv_jurisdiction], + view_context: view_context + ) + render_full_width('shared/_failure', locals: { presenter: presenter }) end def jurisdiction_params diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index e6e940a43a5..31a53946858 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -74,7 +74,7 @@ def process_locked_out_user current_user.decorate ) sign_out - render_full_width('shared/failure', locals: { presenter: presenter }) + render_full_width('shared/_failure', locals: { presenter: presenter }) end def handle_valid_authentication diff --git a/app/presenters/idv/jurisdiction_failure_presenter.rb b/app/presenters/idv/jurisdiction_failure_presenter.rb new file mode 100644 index 00000000000..db81973c6da --- /dev/null +++ b/app/presenters/idv/jurisdiction_failure_presenter.rb @@ -0,0 +1,62 @@ +module Idv + class JurisdictionFailurePresenter < FailurePresenter + attr_reader :jurisdiction, :reason, :view_context + + delegate :account_path, + :decorated_session, + :idv_jurisdiction_path, + :link_to, + :state_name_for_abbrev, + :t, + to: :view_context + + def initialize(jurisdiction:, reason:, view_context:) + super(:failure) + @jurisdiction = jurisdiction + @reason = reason + @view_context = view_context + end + + def title + t("idv.titles.#{reason}", **i18n_args) + end + + def header + t("idv.titles.#{reason}", **i18n_args) + end + + def description + t("idv.messages.jurisdiction.#{reason}_failure", **i18n_args) + end + + def message + t('headings.lock_failure') + end + + def next_steps + [try_again_step, sp_step, profile_step].compact + end + + private + + def i18n_args + jurisdiction ? { state: state_name_for_abbrev(jurisdiction) } : {} + end + + def try_again_step + try_again_link = link_to(t('idv.messages.jurisdiction.try_again_link'), idv_jurisdiction_path) + t('idv.messages.jurisdiction.try_again', link: try_again_link) + end + + def sp_step + return unless (sp_name = decorated_session.sp_name) + support_link = link_to(sp_name, decorated_session.sp_alert_learn_more) + t('idv.messages.jurisdiction.sp_support', link: support_link) + end + + def profile_step + profile_link = link_to(t('idv.messages.jurisdiction.profile_link'), account_path) + t('idv.messages.jurisdiction.profile', link: profile_link) + end + end +end diff --git a/app/views/idv/jurisdiction/show.html.slim b/app/views/idv/jurisdiction/show.html.slim deleted file mode 100644 index e884d0a42df..00000000000 --- a/app/views/idv/jurisdiction/show.html.slim +++ /dev/null @@ -1,22 +0,0 @@ -- i18n_args = @state ? { state: state_name_for_abbrev(@state) } : {} - -- title t("idv.titles.#{@reason}", **i18n_args) - -h1.h3.mb2.my0 = t("idv.titles.#{@reason}", **i18n_args) - -- if @state - p.mb1 = t("idv.messages.jurisdiction.#{@reason}", **i18n_args) - -.col-2 - hr.mt5.mb2.bw4.border-blue.rounded - -- if decorated_session.sp_name - - support_link = link_to(decorated_session.sp_name, decorated_session.sp_alert_learn_more) - p == t('idv.messages.jurisdiction.sp_support', link: support_link) - -- profile_link = link_to(t('idv.messages.jurisdiction.profile_link'), account_path) -p == t('idv.messages.jurisdiction.profile', link: profile_link) - -p.mt4 = link_to t('forms.buttons.back'), - decorated_session.cancel_link_url || account_path, - class: 'btn btn-primary btn-wide' diff --git a/app/views/shared/failure.html.slim b/app/views/shared/_failure.html.slim similarity index 100% rename from app/views/shared/failure.html.slim rename to app/views/shared/_failure.html.slim diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index 23fdba5e82b..6b4300eea71 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -116,6 +116,7 @@ ignore_unused: - 'headings.piv_cac_setup.*' - 'titles.sign_up.*' - 'users.delete.bullet_2_loa*' + - 'idv.messages.jurisdiction.{no_id,unsupported_jurisdiction}_failure' # - 'simple_form.{yes,no}' # - 'simple_form.{placeholders,hints,labels}.*' # - 'simple_form.{error_notification,required}.:' diff --git a/config/locales/idv/en.yml b/config/locales/idv/en.yml index 162fd34617c..95e23031361 100644 --- a/config/locales/idv/en.yml +++ b/config/locales/idv/en.yml @@ -81,15 +81,18 @@ en: help_center_html: Visit our Help Center to learn more about verifying your account. jurisdiction: - why: To verify your identity, you'll need information from your state-issued - ID. - where: Where was your driver's license, driver's permit, or state ID issued? no_id: I don't have a state-issued ID - unsupported_jurisdiction: We're working hard to add more states and hope to - support %{state} soon. - sp_support: Visit %{link} for more information. + no_id_failure: We're working hard to add more ways to verify your identity. profile: To access your account in the future, you can %{link}. profile_link: view your account here + sp_support: Visit %{link} for more information. + try_again: Make a mistake? You can %{link}. + try_again_link: try again + unsupported_jurisdiction_failure: We're working hard to add more states and + hope to support %{state} soon. + why: To verify your identity, you'll need information from your state-issued + ID. + where: Where was your driver's license, driver's permit, or state ID issued? loading: Verifying your identity mail_sent: Your letter is on its way otp_delivery_method: diff --git a/config/locales/idv/es.yml b/config/locales/idv/es.yml index d557def97bc..22aaa709e4a 100644 --- a/config/locales/idv/es.yml +++ b/config/locales/idv/es.yml @@ -79,16 +79,20 @@ es: help_center_html: Visite nuestro Centro de Ayuda para obtener más información sobre la verificación de su cuenta. jurisdiction: - why: Para verificar su identidad, necesitará información de su identificación - emitida por el estado. - where: "¿Dónde se emitió su licencia de conducir, permiso de conducir o identificación - del estado?" no_id: No tengo una identificación emitida por el estado + no_id_failure: Estamos trabajando arduamente para agregar más formas de verificar + su identidad. profile: Para acceder a su cuenta en el futuro, puede %{link}. profile_link: mira tu cuenta aquí sp_support: Visita %{link} para obtener más información. - unsupported_jurisdiction: Estamos trabajando duro para agregar más estados - y esperamos apoyar a %{state} pronto. + try_again: "¿Cometer un error? Puedes %{link}." + try_again_link: intentarlo de nuevo + unsupported_jurisdiction_failure: Estamos trabajando duro para agregar más + estados y esperamos apoyar a %{state} pronto. + why: Para verificar su identidad, necesitará información de su identificación + emitida por el estado. + where: "¿Dónde se emitió su licencia de conducir, permiso de conducir o identificación + del estado?" loading: NOT TRANSLATED YET mail_sent: Su carta está en camino otp_delivery_method: diff --git a/config/locales/idv/fr.yml b/config/locales/idv/fr.yml index 2a61050ccd0..7f31c4748cd 100644 --- a/config/locales/idv/fr.yml +++ b/config/locales/idv/fr.yml @@ -85,16 +85,20 @@ fr: help_center_html: Visitez notre Centre d'aide pour en apprendre davantage sur la façon dont nous vérifions votre compte. jurisdiction: - why: Pour vérifier votre identité, vous aurez besoin d'informations provenant - de votre carte d'identité officielle. - where: Où a été délivré votre permis de conduire, votre permis de conduire - ou votre carte d'identité? no_id: Je n'ai pas de carte d'identité officielle + no_id_failure: Nous travaillons dur pour ajouter plus de moyens de vérifier + votre identité. profile: Pour accéder à votre compte dans le futur, vous pouvez %{link}. profile_link: voir votre compte ici sp_support: Visitez %{link} pour plus d'informations. - unsupported_jurisdiction: Nous travaillons dur pour ajouter plus d'états et - espérons pouvoir bientôt prendre en charge %{state}. + try_again: Faire une erreur? Vous pouvez %{link}. + try_again_link: réessayer + unsupported_jurisdiction_failure: Nous travaillons dur pour ajouter plus d'états + et espérons pouvoir bientôt prendre en charge %{state}. + why: Pour vérifier votre identité, vous aurez besoin d'informations provenant + de votre carte d'identité officielle. + where: Où a été délivré votre permis de conduire, votre permis de conduire + ou votre carte d'identité? loading: NOT TRANSLATED YET mail_sent: Votre lettre est en route otp_delivery_method: diff --git a/spec/controllers/idv/jurisdiction_controller_spec.rb b/spec/controllers/idv/jurisdiction_controller_spec.rb index fcde8267922..84f1406eecd 100644 --- a/spec/controllers/idv/jurisdiction_controller_spec.rb +++ b/spec/controllers/idv/jurisdiction_controller_spec.rb @@ -76,22 +76,10 @@ controller.user_session[:idv_jurisdiction] = supported_jurisdiction end - it 'renders the `show` template' do + it 'renders the `_failure` template' do get :show, params: { reason: reason } - expect(response).to render_template(:show) - end - - it 'puts the jurisdiction from the user_session into @state' do - get :show, params: { reason: reason } - - expect(assigns(:state)).to eq(supported_jurisdiction) - end - - it 'puts the reason from the params in @reason' do - get :show, params: { reason: reason } - - expect(assigns(:reason)).to eq(reason) + expect(response).to render_template('shared/_failure') end end end From 3142ae640fdf7e17b537a92320b7f8c1e4298264 Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Fri, 15 Jun 2018 16:17:25 -0400 Subject: [PATCH 37/40] Remove warning constant initialized on MAX_BACKTRACE_FRAMES --- config/initializers/new_relic.rb | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/config/initializers/new_relic.rb b/config/initializers/new_relic.rb index 28c15b48cce..cdbf7fa15c5 100644 --- a/config/initializers/new_relic.rb +++ b/config/initializers/new_relic.rb @@ -1,11 +1,14 @@ -# monkeypatch to prevent new relic from truncating backtraces. length is not configurable +# monkeypatch to prevent new relic from truncating backtraces. +# stack length is not currently configurable in new relic. +# The MAX_BACKTRACE_FRAMES constant is commented out for reference + module NewRelic module Agent class ErrorCollector # Maximum number of frames in backtraces. May be made configurable # in the future. - MAX_BACKTRACE_FRAMES = 50 - def truncate_trace(trace, _keep_frames = MAX_BACKTRACE_FRAMES) + # MAX_BACKTRACE_FRAMES = 50 + def truncate_trace(trace, _keep_frames = nil) trace end end From 875c7f41aafbcf559f49fdb6297d0f31f3eca4b5 Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Fri, 15 Jun 2018 16:28:42 -0400 Subject: [PATCH 38/40] LG-373 Use correct 2FA options link for PIV/CAC **Why**: When implementing the feature that requires PIV/CAC users to set up a backup phone, I reused an existing view but didn't update the link to point back to the account recovery form that's specific to PIV/CAC users. This fixes the link. --- app/views/users/phone_setup/index.html.slim | 3 ++- spec/support/shared_examples/account_creation.rb | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/views/users/phone_setup/index.html.slim b/app/views/users/phone_setup/index.html.slim index f6ddb4f5c43..7728c1797f2 100644 --- a/app/views/users/phone_setup/index.html.slim +++ b/app/views/users/phone_setup/index.html.slim @@ -21,7 +21,8 @@ p.mt-tiny.mb0 = @presenter.info input_html: { class: 'phone col-8 mb4' } = f.button :submit, t('forms.buttons.send_security_code') .mt2.pt1.border-top - = link_to t('devise.two_factor_authentication.two_factor_choice_cancel'), two_factor_options_path + - path = current_user.piv_cac_enabled? ? account_recovery_setup_path : two_factor_options_path + = link_to t('devise.two_factor_authentication.two_factor_choice_cancel'), path = stylesheet_link_tag 'intl-tel-number/intlTelInput' = javascript_pack_tag 'intl-tel-input' diff --git a/spec/support/shared_examples/account_creation.rb b/spec/support/shared_examples/account_creation.rb index b5f0381ea93..afe342b0246 100644 --- a/spec/support/shared_examples/account_creation.rb +++ b/spec/support/shared_examples/account_creation.rb @@ -77,6 +77,12 @@ expect(page).to have_current_path(account_recovery_setup_path) expect(page).to have_content t('instructions.account_recovery_setup.piv_cac_next_step') + + select_2fa_option('sms') + click_link t('devise.two_factor_authentication.two_factor_choice_cancel') + + expect(page).to have_current_path account_recovery_setup_path + configure_backup_phone click_acknowledge_personal_key From 9d7f5f3dbad77dc3298601c17315eb0043f9dad0 Mon Sep 17 00:00:00 2001 From: James Smith Date: Fri, 15 Jun 2018 13:16:35 -0400 Subject: [PATCH 39/40] Don't set up 2fa without authenticating first **Why**: If someone creates an account and adds their piv/cac and then logs out, on subsequent login, they had the option of adding a phone number when prompted for their piv/cac. This allowed someone with the username and password to bypass the piv/cac second factor and set the phone, which would then be used as the second factor. **How**: Only allow phone setup during account creation or after authenticating with a second factor. --- .../concerns/two_factor_authenticatable.rb | 1 + .../otp_verification_controller.rb | 10 ++++++- .../piv_cac_verification_controller.rb | 1 + .../users/phone_setup_controller.rb | 5 ++++ .../authenticator_delivery_presenter.rb | 3 +- .../piv_cac_authentication_presenter.rb | 3 +- spec/factories/users.rb | 15 ++++++++-- .../two_factor_authentication/sign_in_spec.rb | 4 +-- spec/features/users/sign_in_spec.rb | 30 +++++++++++++++++++ spec/features/users/sign_up_spec.rb | 30 +++++++++++++++++++ .../piv_cac_authentication_presenter_spec.rb | 20 +++++++++++-- .../totp_verification/show.html.slim_spec.rb | 3 +- 12 files changed, 114 insertions(+), 11 deletions(-) diff --git a/app/controllers/concerns/two_factor_authenticatable.rb b/app/controllers/concerns/two_factor_authenticatable.rb index e57a3e28be9..a1b64bce45b 100644 --- a/app/controllers/concerns/two_factor_authenticatable.rb +++ b/app/controllers/concerns/two_factor_authenticatable.rb @@ -253,6 +253,7 @@ def authenticator_view_data two_factor_authentication_method: two_factor_authentication_method, user_email: current_user.email, remember_device_available: false, + phone_enabled: current_user.phone_enabled?, }.merge(generic_data) end diff --git a/app/controllers/two_factor_authentication/otp_verification_controller.rb b/app/controllers/two_factor_authentication/otp_verification_controller.rb index b0d0f07070b..de65c743a6e 100644 --- a/app/controllers/two_factor_authentication/otp_verification_controller.rb +++ b/app/controllers/two_factor_authentication/otp_verification_controller.rb @@ -26,11 +26,19 @@ def create private def confirm_two_factor_enabled - return if confirmation_context? || current_user.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 + end redirect_to phone_setup_url end + def phone_enabled? + current_user.phone_enabled? + end + def confirm_voice_capability return if two_factor_authentication_method == 'sms' diff --git a/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb b/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb index e60a21c435f..32ea41c4d2c 100644 --- a/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb +++ b/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb @@ -57,6 +57,7 @@ def piv_cac_view_data user_email: current_user.email, remember_device_available: false, totp_enabled: current_user.totp_enabled?, + phone_enabled: current_user.phone_enabled?, piv_cac_nonce: piv_cac_nonce, }.merge(generic_data) end diff --git a/app/controllers/users/phone_setup_controller.rb b/app/controllers/users/phone_setup_controller.rb index 08f47a27176..ead14b0dc7c 100644 --- a/app/controllers/users/phone_setup_controller.rb +++ b/app/controllers/users/phone_setup_controller.rb @@ -6,6 +6,7 @@ class PhoneSetupController < ApplicationController before_action :authenticate_user before_action :authorize_user + before_action :confirm_two_factor_authenticated, if: :two_factor_enabled? def index @user_phone_form = UserPhoneForm.new(current_user) @@ -28,6 +29,10 @@ def create private + def two_factor_enabled? + current_user.two_factor_enabled? + end + def user_phone_form_params params.require(:user_phone_form).permit( :international_code, diff --git a/app/presenters/two_factor_auth_code/authenticator_delivery_presenter.rb b/app/presenters/two_factor_auth_code/authenticator_delivery_presenter.rb index 6bf128a47eb..ee9e22e0f5c 100644 --- a/app/presenters/two_factor_auth_code/authenticator_delivery_presenter.rb +++ b/app/presenters/two_factor_auth_code/authenticator_delivery_presenter.rb @@ -30,9 +30,10 @@ def cancel_link private - attr_reader :user_email, :two_factor_authentication_method + attr_reader :user_email, :two_factor_authentication_method, :phone_enabled def otp_fallback_options + return unless phone_enabled t( 'devise.two_factor_authentication.totp_fallback.text_html', sms_link: sms_link, diff --git a/app/presenters/two_factor_auth_code/piv_cac_authentication_presenter.rb b/app/presenters/two_factor_auth_code/piv_cac_authentication_presenter.rb index ef09966c1ce..8efbed8b36f 100644 --- a/app/presenters/two_factor_auth_code/piv_cac_authentication_presenter.rb +++ b/app/presenters/two_factor_auth_code/piv_cac_authentication_presenter.rb @@ -40,9 +40,10 @@ def piv_cac_service_link private - attr_reader :user_email, :two_factor_authentication_method, :totp_enabled, :piv_cac_nonce + attr_reader :user_email, :two_factor_authentication_method, :totp_enabled, :phone_enabled def otp_fallback_options + return unless phone_enabled t( 'devise.two_factor_authentication.totp_fallback.text_html', sms_link: sms_link, diff --git a/spec/factories/users.rb b/spec/factories/users.rb index c69990d0a18..6410f18f62e 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -15,6 +15,17 @@ x509_dn_uuid { SecureRandom.uuid } end + trait :with_personal_key do + after :build do |user| + user.personal_key = PersonalKeyGenerator.new(user).create + end + end + + trait :with_authentication_app do + with_personal_key + otp_secret_key 'abc123' + end + trait :admin do role :admin end @@ -25,9 +36,7 @@ trait :signed_up do with_phone - after :build do |user| - user.personal_key = PersonalKeyGenerator.new(user).create - end + with_personal_key end trait :unconfirmed do diff --git a/spec/features/two_factor_authentication/sign_in_spec.rb b/spec/features/two_factor_authentication/sign_in_spec.rb index ab18e115208..b4b2edf38f0 100644 --- a/spec/features/two_factor_authentication/sign_in_spec.rb +++ b/spec/features/two_factor_authentication/sign_in_spec.rb @@ -526,9 +526,9 @@ def submit_prefilled_otp_code end end - describe 'when the user is TOTP enabled' do + describe 'when the user is TOTP enabled and phone enabled' do it 'allows SMS and Voice fallbacks' do - user = create(:user, :signed_up, otp_secret_key: 'foo') + user = create(:user, :with_authentication_app, :with_phone) sign_in_before_2fa(user) click_link t('devise.two_factor_authentication.totp_fallback.sms_link_text') diff --git a/spec/features/users/sign_in_spec.rb b/spec/features/users/sign_in_spec.rb index 07bfc725e28..f954c9ccfae 100644 --- a/spec/features/users/sign_in_spec.rb +++ b/spec/features/users/sign_in_spec.rb @@ -431,4 +431,34 @@ expect(page.response_headers['Content-Security-Policy']). to(include('style-src \'self\'')) end + + context 'user is totp_enabled but not phone_enabled' do + before do + user = create(:user, :with_authentication_app) + signin(user.email, user.password) + end + + it 'requires 2FA before allowing access to phone setup form' do + visit phone_setup_path + + expect(page).to have_current_path login_two_factor_authenticator_path + end + + it 'does not redirect to phone setup form when visiting /login/two_factor/sms' do + visit login_two_factor_path(otp_delivery_preference: 'sms') + + expect(page).to have_current_path login_two_factor_authenticator_path + end + + it 'does not redirect to phone setup form when visiting /login/two_factor/voice' do + visit login_two_factor_path(otp_delivery_preference: 'voice') + + expect(page).to have_current_path login_two_factor_authenticator_path + end + + it 'does not display OTP Fallback text and links' do + expect(page). + to_not have_content t('devise.two_factor_authentication.totp_fallback.sms_link_text') + end + end end diff --git a/spec/features/users/sign_up_spec.rb b/spec/features/users/sign_up_spec.rb index adbe4f299b2..4d2ae4ccb76 100644 --- a/spec/features/users/sign_up_spec.rb +++ b/spec/features/users/sign_up_spec.rb @@ -212,4 +212,34 @@ to(include('style-src \'self\' \'unsafe-inline\'')) end end + + describe 'user is partially authenticated and phone 2fa is not configured' do + context 'with piv/cac enabled' do + let(:user) do + create(:user, :with_piv_or_cac) + end + + before(:each) do + sign_in_user(user) + end + + scenario 'can not access phone_setup' do + expect(page).to have_current_path login_two_factor_piv_cac_path + visit phone_setup_path + expect(page).to have_current_path login_two_factor_piv_cac_path + end + + scenario 'can not access phone_setup via login/two_factor/sms' do + expect(page).to have_current_path login_two_factor_piv_cac_path + visit login_two_factor_path(otp_delivery_preference: :sms) + expect(page).to have_current_path login_two_factor_piv_cac_path + end + + scenario 'can not access phone_setup via login/two_factor/voice' do + expect(page).to have_current_path login_two_factor_piv_cac_path + visit login_two_factor_path(otp_delivery_preference: :voice) + expect(page).to have_current_path login_two_factor_piv_cac_path + end + end + end end diff --git a/spec/presenters/two_factor_auth_code/piv_cac_authentication_presenter_spec.rb b/spec/presenters/two_factor_auth_code/piv_cac_authentication_presenter_spec.rb index a3d8a1f2181..615ab590732 100644 --- a/spec/presenters/two_factor_auth_code/piv_cac_authentication_presenter_spec.rb +++ b/spec/presenters/two_factor_auth_code/piv_cac_authentication_presenter_spec.rb @@ -35,8 +35,24 @@ def presenter_with(arguments = {}, view = ActionController::Base.new.view_contex end describe '#fallback_links' do - it 'has two options' do - expect(presenter.fallback_links.count).to eq 2 + context 'with phone enabled' do + let(:presenter) do + presenter_with(reauthn: reauthn, user_email: user_email, phone_enabled: true) + end + + it 'has two options' do + expect(presenter.fallback_links.count).to eq 2 + end + end + + context 'with phone disabled' do + let(:presenter) do + presenter_with(reauthn: reauthn, user_email: user_email, phone_enabled: false) + end + + it 'has one option' do + expect(presenter.fallback_links.count).to eq 1 + end end end diff --git a/spec/views/two_factor_authentication/totp_verification/show.html.slim_spec.rb b/spec/views/two_factor_authentication/totp_verification/show.html.slim_spec.rb index 1b7e4724516..adece4b916a 100644 --- a/spec/views/two_factor_authentication/totp_verification/show.html.slim_spec.rb +++ b/spec/views/two_factor_authentication/totp_verification/show.html.slim_spec.rb @@ -5,7 +5,8 @@ let(:presenter_data) do attributes_for(:generic_otp_presenter).merge( two_factor_authentication_method: 'authenticator', - user_email: view.current_user.email + user_email: view.current_user.email, + phone_enabled: user.phone_enabled? ) end From 98bd3940c56d4354724fc8ba39be80e2c7e5d56f Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Tue, 19 Jun 2018 10:04:24 -0400 Subject: [PATCH 40/40] Fix presenter in Users::PhonesController#update **Why**: The presenter argument expects an otp_delivery_preference, not a User object. --- app/controllers/users/phones_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/users/phones_controller.rb b/app/controllers/users/phones_controller.rb index 5db18b67791..32045dd9a28 100644 --- a/app/controllers/users/phones_controller.rb +++ b/app/controllers/users/phones_controller.rb @@ -11,7 +11,7 @@ def edit def update @user_phone_form = UserPhoneForm.new(current_user) - @presenter = PhoneSetupPresenter.new(current_user) + @presenter = PhoneSetupPresenter.new(current_user.otp_delivery_preference) if @user_phone_form.submit(user_params).success? process_updates bypass_sign_in current_user