Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proof-of-concept: reCAPTCHA at sign-in #10587

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/components/captcha_submit_button_component.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,14 @@
</div>
<% 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) %>
Expand Down
5 changes: 3 additions & 2 deletions app/components/captcha_submit_button_component.rb
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the recaptcha_action not the older link-or-button action right? What if we renamed to recapcha_action tomatch the RECAPTCHA_ACTION constant it gets called with?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it's the reCAPTCHA concept of "action", also documented with a reference link a couple lines below.

# @param [String] action https://developers.google.com/recaptcha/docs/v3#actions

But sure, I think renaming recaptcha_action could be clearer / more consistent.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, that link should probably reference the Enterprise documentation, which is a little more complete and matches the expected production behavior:

https://cloud.google.com/recaptcha-enterprise/docs/actions-website


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

Expand Down
35 changes: 35 additions & 0 deletions app/controllers/users/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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]))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed a little bit offline, but this query should probably include a subquery on email address for the user_id since cookie_uuid is not unique.

return true if device.user.email_addresses.lazy.map(&:email).include?(auth_params[:email])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oooo smart use of .lazy! Another idea:

Suggested change
return true if device.user.email_addresses.lazy.map(&:email).include?(auth_params[:email])
return true if device.user.email_addresses.any? { |e| e.email == auth_params[:email] }

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I thought it was a nice way to avoid having to use a block, but funny that the block form ends up being shorter anyways 馃槄

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we default to RecaptchaForm for completeness?

Suggested change
args
args.merge(form_class: RecaptchaForm)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's defaulted in SignInRecaptchaForm, so not strictly necessary:

def initialize(form_class: RecaptchaForm, **form_args)

This follows from the implementation for phone setup:

def recaptcha_form_args
args = { analytics: }
if IdentityConfig.store.phone_recaptcha_mock_validator
args.merge(form_class: RecaptchaMockForm, score: recaptcha_mock_score)
elsif FeatureManagement.recaptcha_enterprise?
args.merge(form_class: RecaptchaEnterpriseForm)
else
args
end
end

But I could also change it so that form_class is a required keyword attribute and assign it here instead.

(Aside: When implementing this "proper", I'll probably plan to create a separate form class for handling the sign-in+reCAPTCHA validation, similar to what we have with NewPhoneForm)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah seeing the default argument there is what prompted me to make the comment here

end
end

def redirect_to_signin
controller_info = 'users/sessions#create'
analytics.invalid_authenticity_token(controller: controller_info)
Expand Down
24 changes: 24 additions & 0 deletions app/forms/sign_in_recaptcha_form.rb
Original file line number Diff line number Diff line change
@@ -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
7 changes: 6 additions & 1 deletion app/views/devise/sessions/new.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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? %>
<div class='margin-x-neg-1 margin-top-205'>
Expand Down
2 changes: 2 additions & 0 deletions config/application.yml.default
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions config/application.yml.default.docker
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/identity_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down