diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index e16f565cf4c..041c3c76b6e 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -229,6 +229,7 @@ def fix_broken_personal_key_url
def after_sign_in_path_for(_user)
return rules_of_use_path if !current_user.accepted_rules_of_use_still_valid?
return user_please_call_url if current_user.suspended?
+ return user_password_compromised_url if session[:redirect_to_password_compromised].present?
return authentication_methods_setup_url if user_needs_sp_auth_method_setup?
return login_add_piv_cac_prompt_url if session[:needs_to_setup_piv_cac_after_sign_in].present?
return fix_broken_personal_key_url if current_user.broken_personal_key?
diff --git a/app/controllers/users/password_compromised_controller.rb b/app/controllers/users/password_compromised_controller.rb
new file mode 100644
index 00000000000..78b63f4764d
--- /dev/null
+++ b/app/controllers/users/password_compromised_controller.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Users
+ class PasswordCompromisedController < ApplicationController
+ before_action :confirm_two_factor_authenticated
+ before_action :verify_feature_toggle_on
+
+ def show
+ session.delete(:redirect_to_password_compromised)
+ @after_sign_in_path = after_sign_in_path_for(current_user)
+ analytics.user_password_compromised_visited
+ end
+
+ def verify_feature_toggle_on
+ redirect_to after_sign_in_path_for(current_user) unless
+ FeatureManagement.check_password_enabled?
+ end
+ end
+end
diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb
index b3359f8d75b..a9420871002 100644
--- a/app/controllers/users/sessions_controller.rb
+++ b/app/controllers/users/sessions_controller.rb
@@ -117,6 +117,7 @@ def handle_valid_authentication
user_id: current_user.id,
email: auth_params[:email],
)
+ check_password_compromised
user_session[:platform_authenticator_available] =
params[:platform_authenticator_available] == 'true'
redirect_to next_url_after_valid_authentication
@@ -204,6 +205,32 @@ def override_csp_for_google_analytics
def sign_in_params
params[resource_name]&.permit(:email) if request.post?
end
+
+ def check_password_compromised
+ return if current_user.password_compromised_checked_at.present? ||
+ !eligible_for_password_lookup?
+
+ session[:redirect_to_password_compromised] =
+ PwnedPasswords::LookupPassword.call(auth_params[:password])
+ update_user_password_compromised_checked_at
+ end
+
+ def eligible_for_password_lookup?
+ FeatureManagement.check_password_enabled? &&
+ randomize_check_password?
+ end
+
+ def update_user_password_compromised_checked_at
+ UpdateUser.new(
+ user: current_user,
+ attributes: { password_compromised_checked_at: Time.zone.now },
+ ).call
+ end
+
+ def randomize_check_password?
+ SecureRandom.random_number(IdentityConfig.store.compromised_password_randomizer_value) >=
+ IdentityConfig.store.compromised_password_randomizer_threshold
+ end
end
def unsafe_redirect_error(_exception)
diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb
index f7b56a7af0e..17ee133c0db 100644
--- a/app/services/analytics_events.rb
+++ b/app/services/analytics_events.rb
@@ -5134,6 +5134,14 @@ def user_marked_authed(authentication_type:, **extra)
)
end
+ # Tracks when the user is notified their password is compromised
+ def user_password_compromised_visited(**extra)
+ track_event(
+ :user_password_compromised_visited,
+ **extra,
+ )
+ end
+
# @param [Boolean] success
# @param [Hash] errors
# @param [Integer] enabled_mfa_methods_count
diff --git a/app/views/users/password_compromised/show.html.erb b/app/views/users/password_compromised/show.html.erb
new file mode 100644
index 00000000000..3ffd9914932
--- /dev/null
+++ b/app/views/users/password_compromised/show.html.erb
@@ -0,0 +1,15 @@
+<%= render(
+ 'idv/shared/error',
+ heading: 'Password Compromised',
+ ) do %>
+
+ PASSWORD COMPROMISED
+
+<% end %>
+
+
+ <%= link_to(
+ @after_sign_in_path,
+ class: 'usa-button usa-button--wide usa-button--big margin-bottom-3',
+ ) { 'SKIP' } %>
+
\ No newline at end of file
diff --git a/config/application.yml.default b/config/application.yml.default
index d289730f772..8c2cdf0de5f 100644
--- a/config/application.yml.default
+++ b/config/application.yml.default
@@ -60,6 +60,9 @@ broken_personal_key_window_start: '2021-07-29T00:00:00Z'
broken_personal_key_window_finish: '2021-09-22T00:00:00Z'
component_previews_enabled: false
component_previews_embed_frame_ancestors: '[]'
+compromised_password_randomizer_value: 1000
+compromised_password_randomizer_threshold: 900
+check_user_password_compromised_enabled: false
country_phone_number_overrides: '{}'
database_pool_idp: 5
database_socket: ''
@@ -378,8 +381,11 @@ development:
attribute_encryption_key: 2086dfbd15f5b0c584f3664422a1d3409a0d2aa6084f65b6ba57d64d4257431c124158670c7655e45cabe64194f7f7b6c7970153c285bdb8287ec0c4f7553e25
attribute_encryption_key_queue: '[{ "key": "11111111111111111111111111111111" }, { "key": "22222222222222222222222222222222" }]'
aws_logo_bucket: ''
+ check_user_password_compromised_enabled: true
component_previews_enabled: true
component_previews_embed_frame_ancestors: '["http://localhost:4000"]'
+ compromised_password_randomizer_value: 1
+ compromised_password_randomizer_threshold: 0
dashboard_api_token: test_token
dashboard_url: http://localhost:3001/api/service_providers
database_host: ''
diff --git a/config/routes.rb b/config/routes.rb
index f37a9283f3a..7eb75e98c60 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -294,6 +294,7 @@
get '/confirm_backup_codes' => 'users/backup_code_setup#confirm_backup_codes'
get '/user_please_call' => 'users/please_call#show'
+ get '/user_password_compromised' => 'users/password_compromised#show'
post '/sign_up/create_password' => 'sign_up/passwords#create', as: :sign_up_create_password
get '/sign_up/email/confirm' => 'sign_up/email_confirmations#create',
diff --git a/lib/feature_management.rb b/lib/feature_management.rb
index 7e628fea0ba..ff09c12af14 100644
--- a/lib/feature_management.rb
+++ b/lib/feature_management.rb
@@ -89,6 +89,10 @@ def self.identity_pki_local_dev?
Rails.env.development? && IdentityConfig.store.identity_pki_local_dev
end
+ def self.check_password_enabled?
+ IdentityConfig.store.check_user_password_compromised_enabled
+ end
+
def self.doc_capture_polling_enabled?
IdentityConfig.store.doc_capture_polling_enabled
end
diff --git a/lib/identity_config.rb b/lib/identity_config.rb
index f3b4ea3ffc9..9b4c2e42c00 100644
--- a/lib/identity_config.rb
+++ b/lib/identity_config.rb
@@ -149,8 +149,11 @@ def self.build_store(config_map)
config.add(:backup_code_cost, type: :string)
config.add(:broken_personal_key_window_finish, type: :timestamp)
config.add(:broken_personal_key_window_start, type: :timestamp)
+ config.add(:check_user_password_compromised_enabled, type: :boolean)
config.add(:component_previews_embed_frame_ancestors, type: :json)
config.add(:component_previews_enabled, type: :boolean)
+ config.add(:compromised_password_randomizer_value, type: :integer)
+ config.add(:compromised_password_randomizer_threshold, type: :integer)
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/controllers/users/sessions_controller_spec.rb b/spec/controllers/users/sessions_controller_spec.rb
index da59e990a77..26131776f4b 100644
--- a/spec/controllers/users/sessions_controller_spec.rb
+++ b/spec/controllers/users/sessions_controller_spec.rb
@@ -192,6 +192,102 @@
end
end
+ context 'Password Compromised toggle is set to true' do
+ before do
+ allow(FeatureManagement).to receive(:check_password_enabled?).and_return(true)
+ end
+
+ context 'User has a compromised password' do
+ let(:user) { create(:user, :fully_registered) }
+ before do
+ allow(PwnedPasswords::LookupPassword).to receive(:call).and_return true
+ end
+
+ context 'user randomly chosen to be tested' do
+ before do
+ allow(SecureRandom).to receive(:random_number).and_return(5)
+ allow(IdentityConfig.store).to receive(:compromised_password_randomizer_threshold).
+ and_return(2)
+ end
+
+ it 'updates user attribute password_compromised_checked_at' do
+ expect(user.password_compromised_checked_at).to be_falsey
+ post :create, params: { user: { email: user.email, password: user.password } }
+ user.reload
+ expect(user.password_compromised_checked_at).to be_truthy
+ end
+
+ it 'stores in session redirect to check compromise' do
+ post :create, params: { user: { email: user.email, password: user.password } }
+ expect(controller.session[:redirect_to_password_compromised]).to be_truthy
+ end
+ end
+
+ context 'user not chosen to be tested' do
+ before do
+ allow(SecureRandom).to receive(:random_number).and_return(1)
+ allow(IdentityConfig.store).to receive(:compromised_password_randomizer_threshold).
+ and_return(5)
+ end
+
+ it 'does not store anything in user_session' do
+ post :create, params: { user: { email: user.email, password: user.password } }
+ expect(user.password_compromised_checked_at).to be_falsey
+ end
+
+ it 'does not update the user ' do
+ post :create, params: { user: { email: user.email, password: user.password } }
+ expect(controller.session[:redirect_to_password_compromised]).to be_falsey
+ end
+ end
+ end
+
+ context 'user does not have a compromised password' do
+ let(:user) { create(:user, :fully_registered) }
+ before do
+ allow(PwnedPasswords::LookupPassword).to receive(:call).and_return false
+ end
+
+ context 'user randomly chosen to be tested' do
+ before do
+ allow(SecureRandom).to receive(:random_number).and_return(5)
+ allow(IdentityConfig.store).to receive(:compromised_password_randomizer_threshold).
+ and_return(2)
+ end
+
+ it 'updates user attribute password_compromised_checked_at' do
+ expect(user.password_compromised_checked_at).to be_falsey
+ post :create, params: { user: { email: user.email, password: user.password } }
+ user.reload
+ expect(user.password_compromised_checked_at).to be_truthy
+ end
+
+ it 'stores in session false to attempt to redirect password compromised' do
+ post :create, params: { user: { email: user.email, password: user.password } }
+ expect(controller.session[:redirect_to_password_compromised]).to be_falsey
+ end
+ end
+
+ context 'user not chosen to be tested' do
+ before do
+ allow(SecureRandom).to receive(:random_number).and_return(1)
+ allow(IdentityConfig.store).to receive(:compromised_password_randomizer_threshold).
+ and_return(5)
+ end
+
+ it 'does not store anything in user_session' do
+ post :create, params: { user: { email: user.email, password: user.password } }
+ expect(user.password_compromised_checked_at).to be_falsey
+ end
+
+ it 'does not update the user ' do
+ post :create, params: { user: { email: user.email, password: user.password } }
+ expect(controller.session[:redirect_to_password_compromised]).to be_falsey
+ end
+ end
+ end
+ end
+
context 'IAL2 user' do
before do
allow(FeatureManagement).to receive(:use_kms?).and_return(false)
diff --git a/spec/features/users/sign_in_spec.rb b/spec/features/users/sign_in_spec.rb
index f7ff848b63d..20fceb7bd6b 100644
--- a/spec/features/users/sign_in_spec.rb
+++ b/spec/features/users/sign_in_spec.rb
@@ -968,6 +968,86 @@
end
end
+ context 'check_password_compromised feature toggle is true' do
+ before do
+ allow(FeatureManagement).to receive(:check_password_enabled?).and_return(true)
+ end
+
+ context 'user has a compromised password' do
+ let(:user) { create(:user, :fully_registered, password: '3.141592653589793238') }
+ context 'user is chosen to check if password compromised' do
+ before do
+ allow(SecureRandom).to receive(:random_number).and_return(5)
+ allow(IdentityConfig.store).to receive(:compromised_password_randomizer_threshold).
+ and_return(2)
+ end
+ it 'should bring user to compromised password page' do
+ visit new_user_session_path
+ fill_in_credentials_and_submit(user.email, user.password)
+ fill_in_code_with_last_phone_otp
+ click_submit_default
+
+ expect(current_path).to eq user_password_compromised_path
+ end
+ end
+
+ context 'user is not chosen to check if password compromised' do
+ before do
+ allow(SecureRandom).to receive(:random_number).and_return(2)
+ allow(IdentityConfig.store).to receive(:compromised_password_randomizer_threshold).
+ and_return(5)
+ end
+ it 'should continue without issue' do
+ visit new_user_session_path
+ fill_in_credentials_and_submit(user.email, user.password)
+ fill_in_code_with_last_phone_otp
+ click_submit_default
+
+ expect(current_path).to eq account_path
+ end
+ end
+ end
+
+ context 'user does not have compromised password' do
+ let(:user) { create(:user, :fully_registered) }
+ context 'user is chosen to check if password compromised' do
+ before do
+ allow(SecureRandom).to receive(:random_number).and_return(5)
+ allow(IdentityConfig.store).to receive(:compromised_password_randomizer_threshold).
+ and_return(2)
+ end
+ it 'should bring user to account page and set password compromised attr' do
+ visit new_user_session_path
+ fill_in_credentials_and_submit(user.email, user.password)
+ fill_in_code_with_last_phone_otp
+ click_submit_default
+
+ expect(current_path).to eq account_path
+ user.reload
+ expect(user.password_compromised_checked_at).to be_truthy
+ end
+ end
+
+ context 'user is not chosen to check if password compromised' do
+ before do
+ allow(SecureRandom).to receive(:random_number).and_return(2)
+ allow(IdentityConfig.store).to receive(:compromised_password_randomizer_threshold).
+ and_return(5)
+ end
+ it 'should continue without issue and does not set password compromised attr' do
+ visit new_user_session_path
+ fill_in_credentials_and_submit(user.email, user.password)
+ fill_in_code_with_last_phone_otp
+ click_submit_default
+
+ expect(current_path).to eq account_path
+ user.reload
+ expect(user.password_compromised_checked_at).to be_falsey
+ end
+ end
+ end
+ end
+
context 'when piv/cac is required' do
before do
visit_idp_from_oidc_sp_with_hspd12_and_require_piv_cac