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