diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 6b109224397..5533e39c4f6 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -188,9 +188,35 @@ def service_provider_mfa_setup_url service_provider_mfa_policy.user_needs_sp_auth_method_setup? ? two_factor_options_url : nil end + def fix_broken_personal_key_url + return if !current_user.broken_personal_key? + + flash[:info] = t('account.personal_key.needs_new') + + pii_unlocked = user_session[:decrypted_pii].present? + + if pii_unlocked + cacher = Pii::Cacher.new(current_user, user_session) + profile = current_user.active_profile + user_session[:personal_key] = profile.encrypt_recovery_pii(cacher.fetch) + profile.save! + + analytics.track_event(Analytics::BROKEN_PERSONAL_KEY_REGENERATED) + + manage_personal_key_url + else + user_session[:needs_new_personal_key] = true + + capture_password_url + end + end + def after_sign_in_path_for(_user) - service_provider_mfa_setup_url || add_piv_cac_setup_url || - user_session.delete(:stored_location) || sp_session_request_url_with_updated_params || + service_provider_mfa_setup_url || + add_piv_cac_setup_url || + fix_broken_personal_key_url || + user_session.delete(:stored_location) || + sp_session_request_url_with_updated_params || signed_in_url end diff --git a/app/controllers/password_capture_controller.rb b/app/controllers/password_capture_controller.rb index 322ffa1dd85..94dd31ad2a8 100644 --- a/app/controllers/password_capture_controller.rb +++ b/app/controllers/password_capture_controller.rb @@ -6,12 +6,15 @@ class PasswordCaptureController < ApplicationController before_action :confirm_two_factor_authenticated before_action :apply_secure_headers_override + helper_method :password_header + def new session[:password_attempts] ||= 0 end def create if current_user.valid_password?(password) + user_session.delete(:needs_new_personal_key) handle_valid_password else handle_invalid_password @@ -20,6 +23,14 @@ def create private + def password_header + if user_session[:needs_new_personal_key] + t('headings.passwords.confirm_for_personal_key') + else + t('headings.passwords.confirm') + end + end + def password params.require(:user)[:password] end diff --git a/app/models/user.rb b/app/models/user.rb index 5260b7d00f4..1253749639c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -101,6 +101,16 @@ def default_phone_configuration phone_configurations.order('made_default_at DESC NULLS LAST, created_at').first end + def broken_personal_key? + window_start = IdentityConfig.store.broken_personal_key_window_start + window_finish = IdentityConfig.store.broken_personal_key_window_finish + last_personal_key_at = self.encrypted_recovery_code_digest_generated_at + + (!last_personal_key_at || last_personal_key_at < window_finish) && + active_profile.present? && + (window_start..window_finish).cover?(active_profile.verified_at) + end + # To send emails asynchronously via ActiveJob. def send_devise_notification(notification, *args) devise_mailer.send(notification, self, *args).deliver_now_or_later diff --git a/app/services/analytics.rb b/app/services/analytics.rb index de21ddd52d8..ee34273cd70 100644 --- a/app/services/analytics.rb +++ b/app/services/analytics.rb @@ -135,6 +135,7 @@ def browser_attributes AUTHENTICATION_CONFIRMATION_RESET = 'Authentication Confirmation: Reset selected' BANNED_USER_REDIRECT = 'Banned User redirected' BANNED_USER_VISITED = 'Banned User visited' + BROKEN_PERSONAL_KEY_REGENERATED = 'Broken Personal Key: Regenerated' DOC_AUTH = 'Doc Auth' # visited or submitted is appended DOC_AUTH_ASYNC = 'Doc Auth Async' DOC_AUTH_WARNING = 'Doc Auth Warning' diff --git a/app/views/password_capture/new.html.erb b/app/views/password_capture/new.html.erb index 605eb70f5d2..bb8a35d9819 100644 --- a/app/views/password_capture/new.html.erb +++ b/app/views/password_capture/new.html.erb @@ -1,6 +1,6 @@ <% title t('titles.visitors.index') %> -<%= render PageHeadingComponent.new.with_content(t('headings.passwords.confirm')) %> +<%= render PageHeadingComponent.new.with_content(password_header) %> <%= validated_form_for( current_user, diff --git a/config/application.yml.default b/config/application.yml.default index fdbd2ecbf59..27fc1c6d4cb 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -50,6 +50,8 @@ aws_logo_bucket: '' aws_region: 'us-west-2' aws_kms_multi_region_enabled: false backup_code_cost: '2000$8$1$' +broken_personal_key_window_start: '2021-07-29T00:00:00Z' +broken_personal_key_window_finish: '2021-09-22T00:00:00Z' country_phone_number_overrides: '{}' doc_auth_error_dpi_threshold: 290 doc_auth_error_sharpness_threshold: 40 diff --git a/config/locales/account/en.yml b/config/locales/account/en.yml index f02c5b60ae2..014266d1d3c 100644 --- a/config/locales/account/en.yml +++ b/config/locales/account/en.yml @@ -107,6 +107,8 @@ en: get_new_description: When you receive a new personal key, your old personal key will not work anymore. last_generated: 'Last generated on %{timestamp}' + needs_new: Your account needs a new personal key. Your old personal key will not + work if you forget your password. old_key_will_not_work: Please print, copy, or download the new personal key below. Your old personal key will not work if you forget your password. reset_instructions: Reset your personal key if you don’t have it. You’ll need diff --git a/config/locales/account/es.yml b/config/locales/account/es.yml index 023c8460d7c..274de96f553 100644 --- a/config/locales/account/es.yml +++ b/config/locales/account/es.yml @@ -107,6 +107,8 @@ es: get_new: Obtenga una nueva clave personal get_new_description: Su antigua clave personal dejará de funcionar en cuanto reciba una nueva. last_generated: 'Generada por última vez en %{timestamp}' + needs_new: Se requiere una nueva clave personal para su cuenta. Su antigua clave + personal no funcionará si se olvida de su contraseña. old_key_will_not_work: Por favor, imprima, copie o descargue la nueva clave personal que aparece a continuación. Su antigua clave personal no funcionará si olvida su contraseña. diff --git a/config/locales/account/fr.yml b/config/locales/account/fr.yml index fc65acfaa26..8af0daf971a 100644 --- a/config/locales/account/fr.yml +++ b/config/locales/account/fr.yml @@ -114,6 +114,8 @@ fr: get_new_description: Lorsque vous recevez une nouvelle clé personnelle, votre ancienne clé personnelle ne fonctionnera plus. last_generated: 'Dernière génération le %{timestamp}' + needs_new: Votre compte a besoin d’une nouvelle clé personnelle. Votre ancienne + clé personnelle ne fonctionnera pas si vous oubliez votre mot de passe. old_key_will_not_work: Veuillez imprimer, copier ou télécharger la nouvelle clé personnelle ci-dessous. Votre ancienne clé personnelle ne fonctionnera pas si vous oubliez votre mot de passe. diff --git a/config/locales/headings/en.yml b/config/locales/headings/en.yml index 29abfd443af..1746afd5dc8 100644 --- a/config/locales/headings/en.yml +++ b/config/locales/headings/en.yml @@ -32,6 +32,7 @@ en: passwords: change: Change your password confirm: Confirm your current password to continue + confirm_for_personal_key: Enter password and get a new personal key forgot: Forgot your password? personal_key: Save your personal key piv_cac: diff --git a/config/locales/headings/es.yml b/config/locales/headings/es.yml index 3f0d6e34932..6315c930147 100644 --- a/config/locales/headings/es.yml +++ b/config/locales/headings/es.yml @@ -32,6 +32,7 @@ es: passwords: change: Cambie su contraseña confirm: Confirme la contraseña actual para continuar + confirm_for_personal_key: Introduzca la contraseña y obtenga una nueva clave personal forgot: '¿Olvidó su contraseña?' personal_key: Guarda tu clave personal piv_cac: diff --git a/config/locales/headings/fr.yml b/config/locales/headings/fr.yml index 9416525f83e..c00f6e3cfb8 100644 --- a/config/locales/headings/fr.yml +++ b/config/locales/headings/fr.yml @@ -32,6 +32,7 @@ fr: passwords: change: Changez votre mot de passe confirm: Confirmez votre mot de passe actuel pour continuer + confirm_for_personal_key: Entrez le mot de passe et obtenez une nouvelle clé personnelle forgot: Vous avez oublié votre mot de passe? personal_key: Enregistrez votre clé personnelle piv_cac: diff --git a/lib/identity_config.rb b/lib/identity_config.rb index 7d3ccd10ab3..1a19f7e939f 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -107,6 +107,8 @@ def self.build_store(config_map) config.add(:aws_logo_bucket, type: :string) config.add(:aws_region, type: :string) config.add(:backup_code_cost, type: :string) + config.add(:broken_personal_key_window_start, type: :timestamp) + config.add(:broken_personal_key_window_finish, type: :timestamp) config.add(:country_phone_number_overrides, type: :json) config.add(:dashboard_api_token, type: :string) config.add(:dashboard_url, type: :string) diff --git a/spec/features/users/sign_in_spec.rb b/spec/features/users/sign_in_spec.rb index a913788f081..797c7b32f34 100644 --- a/spec/features/users/sign_in_spec.rb +++ b/spec/features/users/sign_in_spec.rb @@ -664,6 +664,11 @@ it_behaves_like 'signing in with wrong credentials', :saml it_behaves_like 'signing in with wrong credentials', :oidc + it_behaves_like 'signing in as proofed account with broken personal key', :saml, sp_ial: 1 + it_behaves_like 'signing in as proofed account with broken personal key', :oidc, sp_ial: 1 + it_behaves_like 'signing in as proofed account with broken personal key', :saml, sp_ial: 2 + it_behaves_like 'signing in as proofed account with broken personal key', :oidc, sp_ial: 2 + context 'user signs in and chooses another authentication method' do it 'signs out the user if they choose to cancel' do user = create(:user, :signed_up) diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index c5a993e987c..2a40a29100f 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -411,4 +411,86 @@ end end end + + describe '#broken_personal_key?' do + before do + allow(IdentityConfig.store).to receive(:broken_personal_key_window_start). + and_return(3.days.ago) + allow(IdentityConfig.store).to receive(:broken_personal_key_window_finish). + and_return(1.day.ago) + end + + let(:user) { build(:user) } + + context 'for a user with no profile' do + it { expect(user.broken_personal_key?).to eq(false) } + end + + context 'for a user with a profile that is not verified' do + before do + create(:profile, user: user, activated_at: nil, verified_at: nil) + end + + it { expect(user.broken_personal_key?).to eq(false) } + end + + context 'for a user with a profile verified before the broken key window' do + before do + create( + :profile, + user: user, + active: true, + activated_at: 5.days.ago, + verified_at: 5.days.ago, + ) + end + + it { expect(user.broken_personal_key?).to eq(false) } + end + + context 'for a user with a profile verified after the broken key window' do + before do + create(:profile, :active, :verified, user: user) + end + + it { expect(user.broken_personal_key?).to eq(false) } + end + + context 'for a user with a profile verified during the broken key window' do + let(:personal_key_generated_at) { nil } + let(:verified_at) { 2.days.ago } + + let(:user) do + build(:user, encrypted_recovery_code_digest_generated_at: personal_key_generated_at) + end + + before do + create( + :profile, + user: user, + active: true, + activated_at: verified_at, + verified_at: verified_at, + ) + end + + context 'for a user missing the personal key verified timestamp (legacy data)' do + let(:personal_key_generated_at) { nil } + + it { expect(user.broken_personal_key?).to eq(true) } + end + + context 'for a personal key generated before the window ends' do + let(:personal_key_generated_at) { 2.days.ago } + + it { expect(user.broken_personal_key?).to eq(true) } + end + + context 'for a personal key generated after the window (fixed)' do + let(:personal_key_generated_at) { Time.zone.now } + + it { expect(user.broken_personal_key?).to eq(false) } + end + end + end end diff --git a/spec/support/shared_examples/sign_in.rb b/spec/support/shared_examples/sign_in.rb index 8b7b449bf29..4ecbf52561b 100644 --- a/spec/support/shared_examples/sign_in.rb +++ b/spec/support/shared_examples/sign_in.rb @@ -185,6 +185,74 @@ end end +shared_examples 'signing in as proofed account with broken personal key' do |protocol, sp_ial:| + let(:window_start) { 3.days.ago } + let(:window_end) { 1.day.ago } + + before do + allow(IdentityConfig.store).to receive(:broken_personal_key_window_start). + and_return(window_start) + allow(IdentityConfig.store).to receive(:broken_personal_key_window_finish). + and_return(window_end) + end + + def user_with_broken_personal_key(protocol) + user = create_ial2_account_go_back_to_sp_and_sign_out(protocol) + + user.active_profile.update(verified_at: window_start + 1.hour) + user.update(encrypted_recovery_code_digest_generated_at: nil) + + user + end + + context "protocol: #{protocol}, ial: #{sp_ial}" do + it 'prompts the user to get a new personal key when signing in with email/password' do + user = user_with_broken_personal_key(protocol) + + case sp_ial + when 1 + visit_idp_from_sp_with_ial2(protocol) + when 2 + visit_idp_from_sp_with_ial1(protocol) + else + raise "unknown sp_ial=#{sp_ial}" + end + + fill_in_credentials_and_submit(user.email, user.password) + + expect(page).to have_content(t('account.personal_key.needs_new')) + code = page.all('[data-personal-key]').map(&:text).join(' ') + click_acknowledge_personal_key + + expect(user.reload.valid_personal_key?(code)).to eq(true) + expect(user.active_profile.reload.recover_pii(code)).to be_present + end + + it 'prompts for password when signing in via PIV/CAC' do + user = user_with_broken_personal_key(protocol) + + create(:piv_cac_configuration, user: user) + + visit_idp_from_sp_with_ial1(protocol) + click_on t('account.login.piv_cac') + fill_in_piv_cac_credentials_and_submit(user) + + expect(page).to have_content(t('account.personal_key.needs_new')) + expect(page).to have_content(t('headings.passwords.confirm_for_personal_key')) + + fill_in t('forms.password'), with: user.password + click_button t('forms.buttons.submit.default') + + expect(page).to have_content(t('account.personal_key.needs_new')) + code = page.all('[data-personal-key]').map(&:text).join(' ') + click_acknowledge_personal_key + + expect(user.reload.valid_personal_key?(code)).to eq(true) + expect(user.active_profile.reload.recover_pii(code)).to be_present + end + end +end + def personal_key_for_ial2_user(user, pii) pii_attrs = Pii::Attributes.new_from_hash(pii) profile = user.profiles.last