diff --git a/app/components/captcha_submit_button_component.html.erb b/app/components/captcha_submit_button_component.html.erb index 175257297aa..88e7033c4ac 100644 --- a/app/components/captcha_submit_button_component.html.erb +++ b/app/components/captcha_submit_button_component.html.erb @@ -25,13 +25,14 @@ <% end %> <% else %> - <%= f.input(:recaptcha_token, as: :hidden) %> + <%= f.input(:recaptcha_token, as: :hidden, input_html: { value: '' }) %> <% end %> <%= render SpinnerButtonComponent.new( action_message: t('components.captcha_submit_button.action_message'), type: :submit, big: true, wide: true, + **button_options, ).with_content(content) %> <% if recaptcha_script_src.present? %> <%= content_tag(:script, '', src: recaptcha_script_src, async: true) %> diff --git a/app/components/captcha_submit_button_component.rb b/app/components/captcha_submit_button_component.rb index 9c1a30c5cd6..981dd374224 100644 --- a/app/components/captcha_submit_button_component.rb +++ b/app/components/captcha_submit_button_component.rb @@ -1,14 +1,15 @@ # frozen_string_literal: true class CaptchaSubmitButtonComponent < BaseComponent - attr_reader :form, :action, :tag_options + attr_reader :form, :action, :button_options, :tag_options alias_method :f, :form # @param [String] action https://developers.google.com/recaptcha/docs/v3#actions - def initialize(form:, action:, **tag_options) + def initialize(form:, action:, button_options:, **tag_options) @form = form @action = action + @button_options = button_options @tag_options = tag_options end diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index 7b1555de346..9a5d79fe510 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -31,6 +31,7 @@ def create session[:sign_in_flow] = :sign_in return process_locked_out_session if session_bad_password_count_max_exceeded? return process_locked_out_user if current_user && user_locked_out?(current_user) + return process_failed_captcha if !valid_captcha_result? rate_limit_password_failure = true self.resource = warden.authenticate!(auth_options) @@ -79,6 +80,40 @@ def process_locked_out_session redirect_to root_url end + def valid_captcha_result? + if cookies[:device] && (device = Device.find_by(cookie_uuid: cookies[:device])) + return true if device.user.email_addresses.lazy.map(&:email).include?(auth_params[:email]) + end + + response, _assessment_id = recaptcha_form.submit(params.require(:user)[:recaptcha_token]) + flash[:error] = response.first_error_message if !response.success? + response.success? + end + + def process_failed_captcha + warden.logout(:user) + warden.lock! + redirect_to root_url + end + + def recaptcha_form + @recaptcha_form ||= SignInRecaptchaForm.new(**recaptcha_form_args) + end + + def recaptcha_form_args + args = { analytics: } + if IdentityConfig.store.phone_recaptcha_mock_validator + args.merge( + form_class: RecaptchaMockForm, + score: params.require(:user)[:recaptcha_mock_score].to_f, + ) + elsif FeatureManagement.recaptcha_enterprise? + args.merge(form_class: RecaptchaEnterpriseForm) + else + args + end + end + def redirect_to_signin controller_info = 'users/sessions#create' analytics.invalid_authenticity_token(controller: controller_info) diff --git a/app/forms/sign_in_recaptcha_form.rb b/app/forms/sign_in_recaptcha_form.rb new file mode 100644 index 00000000000..79accdfddcb --- /dev/null +++ b/app/forms/sign_in_recaptcha_form.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class SignInRecaptchaForm + RECAPTCHA_ACTION = 'sign_in' + + attr_reader :form_class, :form_args + + delegate :submit, :errors, to: :form + + def initialize(form_class: RecaptchaForm, **form_args) + @form_class = form_class + @form_args = form_args + end + + private + + def form + @form ||= form_class.new( + score_threshold: IdentityConfig.store.sign_in_recaptcha_score_threshold, + recaptcha_action: RECAPTCHA_ACTION, + **form_args, + ) + end +end diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb index 4740a47101c..2b41fad879c 100644 --- a/app/views/devise/sessions/new.html.erb +++ b/app/views/devise/sessions/new.html.erb @@ -48,7 +48,12 @@ }, ) %> <%= hidden_field_tag :platform_authenticator_available, id: 'platform_authenticator_available' %> - <%= f.submit t('links.sign_in'), full_width: true, wide: false %> + + <%= render CaptchaSubmitButtonComponent.new( + form: f, + action: SignInRecaptchaForm::RECAPTCHA_ACTION, + button_options: { full_width: true }, + ).with_content(t('links.sign_in')) %> <% end %> <% if desktop_device? %>
diff --git a/config/application.yml.default b/config/application.yml.default index 228d7775106..85367b52c0b 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -316,6 +316,7 @@ session_timeout_in_minutes: 15 session_timeout_warning_seconds: 150 session_total_duration_timeout_in_minutes: 720 ses_configuration_set_name: '' +sign_in_recaptcha_score_threshold: 0.0 sp_handoff_bounce_max_seconds: 2 show_unsupported_passkey_platform_authentication_setup: false show_user_attribute_deprecation_warnings: false @@ -431,6 +432,7 @@ development: secret_key_base: development_secret_key_base session_encryption_key: 27bad3c25711099429c1afdfd1890910f3b59f5a4faec1c85e945cb8b02b02f261ba501d99cfbb4fab394e0102de6fecf8ffe260f322f610db3e96b2a775c120 show_unsupported_passkey_platform_authentication_setup: true + sign_in_recaptcha_score_threshold: 0.3 skip_encryption_allowed_list: '["urn:gov:gsa:SAML:2.0.profiles:sp:sso:localhost"]' state_tracking_enabled: true telephony_adapter: test diff --git a/config/application.yml.default.docker b/config/application.yml.default.docker index 77df9cae3a6..d473fd69527 100644 --- a/config/application.yml.default.docker +++ b/config/application.yml.default.docker @@ -20,10 +20,12 @@ production: redis_url: ['env', 'REDIS_URL'] password_pepper: f22d4b2cafac9066fe2f4416f5b7a32c session_encryption_key: 27bad3c25711099429c1afdfd1890910f3b59f5a4faec1c85e945cb8b02b02f261ba501d99cfbb4fab394e0102de6fecf8ffe260f322f610db3e96b2a775c120 + phone_recaptcha_mock_validator: true piv_cac_service_url: ['env', 'PIV_CAC_SERVICE_URL'] piv_cac_verify_token_secret: "a6ed2fb16320ae85a7a8e48f4b0eeb6afca5f1ac64af2a05a0c486df1c20b693987832a11f0910729f199b3ce5c7609fe6d580bed428d035ea8460990e38a382" piv_cac_verify_token_url: ['env', 'PIV_CAC_VERIFY_TOKEN_URL'] secret_key_base: development_secret_key_base + sign_in_recaptcha_score_threshold: 0.3 domain_name: ['env', 'DOMAIN_NAME'] use_kms: false email_from: no-reply@identitysandbox.gov diff --git a/lib/identity_config.rb b/lib/identity_config.rb index de4ed292e4f..ae8012c5f22 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -385,6 +385,7 @@ def self.store config.add(:show_user_attribute_deprecation_warnings, type: :boolean) config.add(:short_term_phone_otp_max_attempts, type: :integer) config.add(:short_term_phone_otp_max_attempt_window_in_seconds, type: :integer) + config.add(:sign_in_recaptcha_score_threshold, type: :float) config.add(:skip_encryption_allowed_list, type: :json) config.add(:sp_handoff_bounce_max_seconds, type: :integer) config.add(:sp_issuer_user_counts_report_configs, type: :json)