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
6 changes: 3 additions & 3 deletions app/components/captcha_submit_button_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def call
:'lg-captcha-submit-button',
safe_join([input_errors_tag, input_tag, spinner_button_tag, recaptcha_script_tag]),
**tag_options,
'recaptcha-site-key': IdentityConfig.store.recaptcha_site_key,
'recaptcha-site-key': IdentityConfig.store.recaptcha_site_key_v3,
'recaptcha-action': action,
)
end
Expand All @@ -42,11 +42,11 @@ def input_tag
end

def recaptcha_script_tag
return if IdentityConfig.store.recaptcha_site_key.blank?
return if IdentityConfig.store.recaptcha_site_key_v3.blank?
content_tag(:script, '', src: recaptcha_script_src, async: true)
end

def recaptcha_script_src
UriService.add_params(RECAPTCHA_SCRIPT_SRC, render: IdentityConfig.store.recaptcha_site_key)
UriService.add_params(RECAPTCHA_SCRIPT_SRC, render: IdentityConfig.store.recaptcha_site_key_v3)
end
end
4 changes: 4 additions & 0 deletions app/controllers/concerns/recaptcha_concern.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ module RecaptchaConcern
'https://recaptcha.google.com/recaptcha/',
].freeze

def recoverable_recaptcha_error?(result)
result.errors.keys == [:recaptcha_token]
end

def allow_csp_recaptcha_src
policy = current_content_security_policy
policy.script_src(*policy.script_src, *RECAPTCHA_SCRIPT_SRC)
Expand Down
3 changes: 3 additions & 0 deletions app/controllers/users/phone_setup_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ def create

if result.success?
handle_create_success(@new_phone_form.phone)
elsif recoverable_recaptcha_error?(result)
render :spam_protection, locals: { two_factor_options_path: two_factor_options_path }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

just thinking out loud, if this new template is so different from existing stuff, should we add a new controller for it? so we redirect to users/phone_setup/spam_production#index or something?

counterpoint, would we be able to easily link back to the phone setup from that new controller if we did?

Copy link
Copy Markdown
Contributor Author

@aduth aduth Feb 14, 2023

Choose a reason for hiding this comment

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

Yeah, I noodled on a few different approaches here. One key thing that is hard to do with other approaches is maintaining form values between the initial submission and the subsequent checkbox submission. As implemented here without a redirect, those form values carry over automatically. With a redirect, we'd have to find some other way to save those values (e.g. storing them in session).

else
render :index
end
Expand Down Expand Up @@ -84,6 +86,7 @@ def new_phone_form_params
:otp_delivery_preference,
:otp_make_default_number,
:recaptcha_token,
:recaptcha_version,
)
end
end
Expand Down
6 changes: 5 additions & 1 deletion app/controllers/users/phones_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@ def add

def create
@new_phone_form = NewPhoneForm.new(user: current_user, analytics: analytics)
if @new_phone_form.submit(user_params).success?
result = @new_phone_form.submit(user_params)
if result.success?
confirm_phone
bypass_sign_in current_user
elsif recoverable_recaptcha_error?(result)
render 'users/phone_setup/spam_protection'
else
render :add
end
Expand All @@ -37,6 +40,7 @@ def user_params
:otp_delivery_preference,
:otp_make_default_number,
:recaptcha_token,
:recaptcha_version,
)
end

Expand Down
17 changes: 14 additions & 3 deletions app/forms/new_phone_form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ class NewPhoneForm
:otp_delivery_preference,
:otp_make_default_number,
:setup_voice_preference,
:recaptcha_token
:recaptcha_token,
:recaptcha_version

alias_method :setup_voice_preference?, :setup_voice_preference

Expand All @@ -31,6 +32,7 @@ def initialize(user:, analytics: nil, setup_voice_preference: false)
@otp_delivery_preference = user.otp_delivery_preference
@otp_make_default_number = false
@setup_voice_preference = setup_voice_preference
@recaptcha_version = 3
end

def submit(params)
Expand Down Expand Up @@ -129,7 +131,7 @@ def validate_not_premium_rate
end

def validate_recaptcha_token
return if !FeatureManagement.phone_recaptcha_enabled?
return if !phone_recaptcha_enabled?
return if recaptcha_validator.valid?(recaptcha_token)
errors.add(
:recaptcha_token,
Expand All @@ -139,7 +141,15 @@ def validate_recaptcha_token
end

def recaptcha_validator
@recaptcha_validator ||= PhoneRecaptchaValidator.new(parsed_phone:, analytics:)
@recaptcha_validator ||= PhoneRecaptchaValidator.new(
parsed_phone:,
recaptcha_version:,
analytics:,
)
end

def phone_recaptcha_enabled?
FeatureManagement.phone_recaptcha_enabled?
end

def parsed_phone
Expand All @@ -155,6 +165,7 @@ def ingest_submitted_params(params)
@otp_delivery_preference = delivery_prefs if delivery_prefs
@otp_make_default_number = true if default_prefs
@recaptcha_token = params[:recaptcha_token]
@recaptcha_version = 2 if params[:recaptcha_version].to_i == 2
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

is there a reason we have to hardcode it like this and not let recaptcha version be passed in?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The main idea was to limit the set of potential values we'd allow for constructing the RecaptchaValidator class, since we wouldn't want to allow invalid versions, e.g. 4, etc (related spec). Perhaps that should be handled a bit more strictly within the validator class itself, though.

Copy link
Copy Markdown
Contributor Author

@aduth aduth Feb 23, 2023

Choose a reason for hiding this comment

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

I think I'd like to keep this in the controller, but I also added some extra checks to the validator class in e4f0e1ac6 to add extra assurances that it's initialized correctly.

end

def confirmed_phone?
Expand Down
7 changes: 4 additions & 3 deletions app/services/phone_recaptcha_validator.rb
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
class PhoneRecaptchaValidator
attr_reader :parsed_phone, :analytics
attr_reader :parsed_phone, :recaptcha_version, :analytics

delegate :valid?, :exempt?, to: :validator

def initialize(parsed_phone:, analytics: nil)
def initialize(parsed_phone:, recaptcha_version:, analytics: nil)
@parsed_phone = parsed_phone
@analytics = analytics
@recaptcha_version = recaptcha_version
end

def self.exempt_countries
Expand All @@ -23,7 +24,7 @@ def score_threshold
private

def validator
@validator ||= RecaptchaValidator.new(score_threshold:, analytics:)
@validator ||= RecaptchaValidator.new(score_threshold:, recaptcha_version:, analytics:)
end

def score_threshold_country_override
Expand Down
51 changes: 39 additions & 12 deletions app/services/recaptcha_validator.rb
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
class RecaptchaValidator
VERIFICATION_ENDPOINT = 'https://www.google.com/recaptcha/api/siteverify'.freeze

EXEMPT_ERROR_CODES = ['missing-input-secret', 'invalid-input-secret']
VALID_RECAPTCHA_VERSIONS = [2, 3]

attr_reader :recaptcha_version, :score_threshold, :analytics

attr_reader :score_threshold, :analytics
def initialize(recaptcha_version: 3, score_threshold: 0.0, analytics: nil)
if !VALID_RECAPTCHA_VERSIONS.include?(recaptcha_version)
raise ArgumentError, "Invalid reCAPTCHA version #{recaptcha_version}, expected one of " \
"#{VALID_RECAPTCHA_VERSIONS}"
end

def initialize(score_threshold: 0.0, analytics: nil)
@score_threshold = score_threshold
@analytics = analytics
@recaptcha_version = recaptcha_version
end

def exempt?
Expand All @@ -20,10 +26,7 @@ def valid?(recaptcha_token)

response = faraday.post(
VERIFICATION_ENDPOINT,
URI.encode_www_form(
secret: IdentityConfig.store.recaptcha_secret_key,
response: recaptcha_token,
),
URI.encode_www_form(secret: recaptcha_secret_key, response: recaptcha_token),
) do |request|
request.options.context = { service_name: 'recaptcha' }
end
Expand All @@ -45,21 +48,45 @@ def faraday
end

def recaptcha_result_valid?(recaptcha_result)
if recaptcha_result.blank?
true
elsif recaptcha_result['success']
recaptcha_result['score'] >= score_threshold
return true if recaptcha_result.blank?
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I refactored this a bit in a7018b44a to extract a few methods for readability, based on team review discussion yesterday that the implied nil score handling from a reCAPTCHA v2 result was not very obvious.


success, score, error_codes = recaptcha_result.values_at('success', 'score', 'error-codes')
if success
recaptcha_score_meets_threshold?(score)
else
(recaptcha_result['error-codes'] - EXEMPT_ERROR_CODES).blank?
recaptcha_errors_exempt?(error_codes)
end
end

def recaptcha_score_meets_threshold?(score)
case recaptcha_version
when 2
true
when 3
score >= score_threshold
end
end

def recaptcha_errors_exempt?(error_codes)
(error_codes - EXEMPT_ERROR_CODES).blank?
end

def log_analytics(recaptcha_result: nil, error: nil)
analytics&.recaptcha_verify_result_received(
recaptcha_result:,
score_threshold:,
recaptcha_version:,
evaluated_as_valid: recaptcha_result_valid?(recaptcha_result),
exception_class: error&.class&.name,
)
end

def recaptcha_secret_key
case recaptcha_version
when 2
IdentityConfig.store.recaptcha_secret_key_v2
when 3
IdentityConfig.store.recaptcha_secret_key_v3
end
end
end
43 changes: 43 additions & 0 deletions app/views/users/phone_setup/spam_protection.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<% title t('titles.spam_protection') %>

<%= render PageHeadingComponent.new.with_content(t('titles.spam_protection')) %>

<p><%= t('forms.spam_protection.description') %></p>

<%= simple_form_for(@new_phone_form, url: request.url, method: :post) do |f| %>
<%= f.input :phone, as: :hidden %>
<%= f.input :international_code, as: :hidden %>
<%= f.input :otp_delivery_preference, as: :hidden %>
<%= f.input :otp_make_default_number, as: :hidden %>
<%= f.input :recaptcha_version, as: :hidden, input_html: { value: 2 } %>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

is this fallback page always version 2? is there a way to set this fro a controller or local variable from the form? just to minimize the number of places we hardcode things?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes it is. Although interestingly, Google enforces that the reCAPTCHA keys matches the configured version for the frontend display of the checkbox, but it appears that any reCAPTCHA key can be used for the verification endpoint.

There might be some option to configure this in the controller. One interesting thing is that all of these values will carry over from the previous submission, including recaptcha_version: 3 from the initial challenge. We'd want to make sure that we set that at the right point to make sure that we'd not be populating the wrong version in the form input.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think that alternative ends up looking like adding a line before this:

https://github.com/18F/identity-idp/blob/38e3643026574229cda5b230543e927954c3a9c4/app/controllers/users/phones_controller.rb#L23

...as:

@new_phone_form.recaptcha_version = 2

...but it feels a little awkward / unpredictable to modify the form like that ahead of rendering the view? I guess contrasted with how it's implemented here, I'm a bit more comfortable with forcefully overriding the value in the view.

<%= f.input :recaptcha_token, as: :hidden, input_html: { id: :recaptcha_token } %>
<%= javascript_tag(nonce: true) do %>
function onCaptchaResponse(token) {
const input = document.getElementById('recaptcha_token');
input.value = token;
input.closest('form').submit();
}
<% end %>
<div
class="g-recaptcha"
data-sitekey="<%= IdentityConfig.store.recaptcha_site_key_v2 %>"
data-callback="onCaptchaResponse"
></div>
<script src="https://www.google.com/recaptcha/api.js" async></script>
<% end %>

<%= render TroubleshootingOptionsComponent.new do |c| %>
<% c.header { t('components.troubleshooting_options.default_heading') } %>
<% if local_assigns[:two_factor_options_path].present? %>
<% c.option(
url: two_factor_options_path,
).with_content(t('two_factor_authentication.login_options_link_text')) %>
<% end %>
<% c.option(
url: MarketingSite.help_center_article_url(
category: 'get-started',
article: 'authentication-options',
),
new_tab: true,
).with_content(t('two_factor_authentication.phone_verification.troubleshooting.learn_more')) %>
<% end %>
6 changes: 4 additions & 2 deletions config/application.yml.default
Original file line number Diff line number Diff line change
Expand Up @@ -251,8 +251,10 @@ rack_mini_profiler: false
rack_timeout_service_timeout_seconds: 15
rails_mailer_previews_enabled: false
reauthn_window: 120
recaptcha_site_key: ''
recaptcha_secret_key: ''
recaptcha_site_key_v2: ''
recaptcha_site_key_v3: ''
recaptcha_secret_key_v2: ''
recaptcha_secret_key_v3: ''
recovery_code_length: 4
redis_irs_attempt_api_url: redis://localhost:6379/2
redis_throttle_url: redis://localhost:6379/1
Expand Down
3 changes: 3 additions & 0 deletions config/locales/forms/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ en:
labels:
email: Enter your email address
email_language: Select your email language preference
spam_protection:
description: We use reCAPTCHA to protect against automated spam. Check the box
below to continue.
ssn:
show: Show Social Security number
totp_delete:
Expand Down
3 changes: 3 additions & 0 deletions config/locales/forms/es.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ es:
labels:
email: Ingrese su dirección de correo electrónico
email_language: Seleccione su preferencia de idioma de correo electrónico
spam_protection:
description: Utilizamos reCAPTCHA como protección contra el correo no deseado
automatizado. Marque la casilla de abajo para continuar.
ssn:
show: Mostrar Número de Seguro Social
totp_delete:
Expand Down
3 changes: 3 additions & 0 deletions config/locales/forms/fr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ fr:
labels:
email: Entrez votre adresse email
email_language: Sélectionnez votre préférence de langue pour les e-mails
spam_protection:
description: Nous utilisons reCAPTCHA pour nous protéger contre les pourriels
automatisés. Cochez la case ci-dessous pour continuer.
ssn:
show: Afficher le numéro de sécurité sociale
totp_delete:
Expand Down
1 change: 1 addition & 0 deletions config/locales/titles/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ en:
completion_new_attributes: '%{sp} is requesting new information'
completion_new_sp: You are now signing in for the first time
confirmation: Continue to sign in
spam_protection: Protecting against spam
totp_setup:
new: Add authentication app
two_factor_setup: Two-factor authentication setup
Expand Down
1 change: 1 addition & 0 deletions config/locales/titles/es.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ es:
completion_new_attributes: '%{sp} está solicitando nueva información'
completion_new_sp: Acabas de iniciar sesión por primera vez
confirmation: Continuar para iniciar sesión
spam_protection: Protección contra el correo no deseado
totp_setup:
new: Agregar aplicación de autenticación
two_factor_setup: Configuración de autenticación de dos factores
Expand Down
1 change: 1 addition & 0 deletions config/locales/titles/fr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ fr:
completion_new_attributes: '%{sp} demande de nouvelles informations'
completion_new_sp: Vous vous connectez pour la première fois
confirmation: Continuer à vous connecter
spam_protection: Protection contre les pourriels
totp_setup:
new: Ajouter une application d’authentification
two_factor_setup: Configuration de l’authentification à deux facteurs
Expand Down
3 changes: 2 additions & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,8 @@
get '/second_mfa_setup' => 'users/mfa_selection#index'
patch '/second_mfa_setup' => 'users/mfa_selection#update'
get '/phone_setup' => 'users/phone_setup#index'
patch '/phone_setup' => 'users/phone_setup#create'
patch '/phone_setup' => 'users/phone_setup#create' # TODO: Remove after next deploy
post '/phone_setup' => 'users/phone_setup#create'
get '/users/two_factor_authentication' => 'users/two_factor_authentication#show',
as: :user_two_factor_authentication # route name is used by two_factor_authentication gem
get '/backup_code_refreshed' => 'users/backup_code_setup#refreshed'
Expand Down
6 changes: 4 additions & 2 deletions lib/feature_management.rb
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,10 @@ def self.log_to_stdout?
end

def self.phone_recaptcha_enabled?
IdentityConfig.store.recaptcha_site_key.present? &&
IdentityConfig.store.recaptcha_secret_key.present? &&
IdentityConfig.store.recaptcha_site_key_v2.present? &&
IdentityConfig.store.recaptcha_site_key_v3.present? &&
IdentityConfig.store.recaptcha_secret_key_v2.present? &&
IdentityConfig.store.recaptcha_secret_key_v3.present? &&
IdentityConfig.store.phone_recaptcha_score_threshold.positive?
end

Expand Down
6 changes: 4 additions & 2 deletions lib/identity_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -352,8 +352,10 @@ def self.build_store(config_map)
config.add(:rack_timeout_service_timeout_seconds, type: :integer)
config.add(:rails_mailer_previews_enabled, type: :boolean)
config.add(:reauthn_window, type: :integer)
config.add(:recaptcha_site_key, type: :string)
config.add(:recaptcha_secret_key, type: :string)
config.add(:recaptcha_site_key_v2, type: :string)
config.add(:recaptcha_site_key_v3, type: :string)
config.add(:recaptcha_secret_key_v2, type: :string)
config.add(:recaptcha_secret_key_v3, type: :string)
config.add(:recovery_code_length, type: :integer)
config.add(:recurring_jobs_disabled_names, type: :json)
config.add(:redis_irs_attempt_api_url)
Expand Down
4 changes: 2 additions & 2 deletions spec/components/captcha_submit_button_component_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@

context 'without configured recaptcha site key' do
before do
allow(IdentityConfig.store).to receive(:recaptcha_site_key).and_return(nil)
allow(IdentityConfig.store).to receive(:recaptcha_site_key_v3).and_return(nil)
end

it 'renders without recaptcha site key attribute' do
Expand All @@ -49,7 +49,7 @@
context 'with configured recaptcha site key' do
let(:recaptcha_site_key) { 'site_key' }
before do
allow(IdentityConfig.store).to receive(:recaptcha_site_key).and_return(recaptcha_site_key)
allow(IdentityConfig.store).to receive(:recaptcha_site_key_v3).and_return(recaptcha_site_key)
end

it 'renders with recaptcha site key attribute' do
Expand Down
Loading