Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions app/controllers/password_capture_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/services/analytics.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion app/views/password_capture/new.html.erb
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
2 changes: 2 additions & 0 deletions config/application.yml.default
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions config/locales/account/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions config/locales/account/es.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions config/locales/account/fr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions config/locales/headings/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions config/locales/headings/es.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions config/locales/headings/fr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions lib/identity_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions spec/features/users/sign_in_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
82 changes: 82 additions & 0 deletions spec/models/user_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
68 changes: 68 additions & 0 deletions spec/support/shared_examples/sign_in.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down