app/forms/recaptcha_form.rb
# frozen_string_literal: true
class RecaptchaForm
include ActiveModel::Model
include ActionView::Helpers::TranslationHelper
VERIFICATION_ENDPOINT = 'https://www.google.com/recaptcha/api/siteverify'
RESULT_ERRORS = ['missing-input-secret', 'invalid-input-secret'].freeze
attr_reader :recaptcha_action,
:recaptcha_token,
:score_threshold,
:analytics,
:extra_analytics_properties
validate :validate_token_exists
validate :validate_recaptcha_result
RecaptchaResult = Struct.new(:success, :score, :errors, :reasons, keyword_init: true) do
alias_method :success?, :success
def initialize(success:, score: nil, errors: [], reasons: [])
super
end
end
def initialize(
recaptcha_action: nil,
score_threshold: 0.0,
analytics: nil,
extra_analytics_properties: {}
)
@score_threshold = score_threshold
@analytics = analytics
@recaptcha_action = recaptcha_action
@extra_analytics_properties = extra_analytics_properties
end
def exempt?
!score_threshold.positive?
end
def submit(recaptcha_token)
@recaptcha_token = recaptcha_token
@recaptcha_result = recaptcha_result if !exempt? && recaptcha_token.present?
log_analytics(result: @recaptcha_result) if @recaptcha_result
FormResponse.new(success: valid?, errors:, serialize_error_details_only: true)
rescue Faraday::Error => error
log_analytics(error:)
FormResponse.new(success: true, serialize_error_details_only: true)
end
private
def validate_token_exists
return if exempt? || recaptcha_token.present?
errors.add(:recaptcha_token, :blank, message: t('errors.messages.invalid_recaptcha_token'))
end
def validate_recaptcha_result
return if @recaptcha_result.blank? || recaptcha_result_valid?(@recaptcha_result)
errors.add(:recaptcha_token, :invalid, message: t('errors.messages.invalid_recaptcha_token'))
end
def recaptcha_result
response = faraday.post(
VERIFICATION_ENDPOINT,
URI.encode_www_form(secret: recaptcha_secret_key, response: recaptcha_token),
) do |request|
request.options.context = { service_name: 'recaptcha' }
end
success, score, error_codes = response.body.values_at('success', 'score', 'error-codes')
errors, reasons = error_codes.to_a.partition { |error_code| is_result_error?(error_code) }
RecaptchaResult.new(success:, score:, errors:, reasons:)
end
def faraday
Faraday.new do |conn|
conn.request :instrumentation, name: 'request_log.faraday'
conn.response :json
end
end
def recaptcha_result_valid?(result)
return true if result.blank?
if result.success?
result.score >= score_threshold
else
result.errors.present?
end
end
def is_result_error?(error_code)
RESULT_ERRORS.include?(error_code)
end
def log_analytics(result: nil, error: nil)
analytics&.recaptcha_verify_result_received(
recaptcha_result: result.to_h.presence,
score_threshold:,
evaluated_as_valid: recaptcha_result_valid?(result),
exception_class: error&.class&.name,
form_class: self.class.name,
**extra_analytics_properties,
)
end
def recaptcha_secret_key
IdentityConfig.store.recaptcha_secret_key
end
end