require 'cgi/util'
require 'net/http'
require 'action_view'
module Incline
##
# A helper class for reCAPTCHA.
#
# To use reCAPTCHA, you will need to define +recaptcha_public+ and +recaptcha_private+ in your 'config/secrets.yml'.
# If you need to use a proxy server, you will need to configure the proxy settings as well.
#
# # config/secrets.yml
# default: &default
# recaptcha_public: SomeBase64StringFromGoogle
# recaptcha_private: AnotherBase64StringFromGoogle
# recaptcha_proxy:
# host: 10.10.10.10
# port: 1000
# user: username
# password: top_secret
#
class Recaptcha
##
# Defines a reCAPTCHA tag that can be used to supply a field in a model with a hash of values.
#
# Basically we define two fields for the model attribute, one for :remote_ip and one for :response.
# The :remote_ip field is set automatically and shouldn't be changed.
# The :response field is set when the user completes the challenge.
#
# Incline::Recaptcha::Tag.new(my_model, :is_robot).render
#
#
#
#
# Incline::Recaptcha::verify model: my_model, attribute: :is_robot
class Tag < ActionView::Helpers::Tags::Base
##
# Generates the reCAPTCHA data.
def render
remote_ip =
if @template_object&.respond_to?(:request) && @template_object.send(:request)&.respond_to?(:remote_ip)
@template_object.request.remote_ip
else
ENV['REMOTE_ADDR']
end
if Incline::Recaptcha::disabled?
# very simple, if recaptcha is disabled, send the IP and 'disabled' to the form.
# for validation, recaptcha must still be disabled or it will fail.
return tag('input', type: 'hidden', id: tag_id, name: tag_name, value: "#{remote_ip}|disabled")
end
# reCAPTCHA is not disabled, so put everything we need into the form.
ret = tag('input', type: 'hidden', id: tag_id, name: tag_name, value: remote_ip)
ret += "\n"
div_id = tag_id + '_div'
ret += tag('div', { class: 'form-group' }, true)
ret += tag('div', { id: div_id }, true)
ret += "\n".html_safe
sitekey = CGI::escape_html(Incline::Recaptcha::public_key)
onload = 'onload_' + tag_id
callback = 'update_' + tag_id
tabindex = @options[:tab_index].to_s.to_i
theme = make_valid(@options[:theme], VALID_THEMES, :light).to_s
type = make_valid(@options[:type], VALID_TYPES, :image).to_s
size = make_valid(@options[:size], VALID_SIZES, :normal).to_s
ret += <<-EOS.html_safe
EOS
Incline::Recaptcha::onload_callbacks << onload
ret.html_safe
end
private
def make_valid(value, valid, default)
return default if value.blank?
value = value.to_sym
return default unless valid.include?(value)
value
end
end
##
# Gets the valid themes for the reCAPTCHA field.
VALID_THEMES = [ :dark, :light ]
##
# Gets the valid types for the reCAPTCHA field.
VALID_TYPES = [ :audio, :image ]
##
# Gets the valid sizes for the reCAPTCHA field.
VALID_SIZES = [ :compact, :normal ]
##
# A string that will validated when reCAPTCHA is disabled.
DISABLED = '0.0.0.0|disabled'
##
# Determines if recaptcha is disabled either due to a test environment or because :recaptcha_public or :recaptcha_private is not defined in +secrets.yml+.
def self.disabled?
temp_lock || public_key.blank? || private_key.blank? || (Rails.env.test? && !enabled_for_testing?)
end
##
# Gets the public key.
def self.public_key
@public_key ||= Rails.application.secrets[:recaptcha_public].to_s.strip
end
##
# Gets the private key.
def self.private_key
@private_key ||= Rails.application.secrets[:recaptcha_private].to_s.strip
end
##
# Gets the proxy configuration (if any).
def self.proxy
@proxy ||= (Rails.application.secrets[:recaptcha_proxy] || {}).symbolize_keys
end
##
# Generates the bare minimum code needed to include a reCAPTCHA challenge in a form.
def self.add
unless disabled?
"
\n ".html_safe
end
end
##
# Verifies the response from a reCAPTCHA challenge.
#
# Valid options:
# model::
# Sets the model that this challenge is verifying.
# attribute::
# If a model is provided, you can supply an attribute to retrieve the response data from.
# This attribute should return a hash with :response and :remote_ip keys.
# If this is provided, then the remaining options are ignored.
# response::
# If specified, defines the response from the reCAPTCHA challenge that we want to verify.
# If not specified, then the request parameters (if any) are searched for the "g-recaptcha-response" value.
# remote_ip::
# If specified, defines the remote IP of the user that was challenged.
# If not specified, then the remote IP from the request (if any) is used.
# request::
# Specifies the request to use for information.
# This must be provided unless :response and :remote_ip are both specified.
# This is the default option if an object other than a Hash is provided to #verify.
#
# Returns true on success, or false on failure.
#
def self.verify(options = {})
return true if temp_lock
options = { request: options } unless options.is_a?(::Hash)
model = options[:model]
response =
if model && options[:attribute] && model.respond_to?(options[:attribute])
model.send(options[:attribute])
else
nil
end
remote_ip = nil
if response.is_a?(::Hash)
remote_ip = response[:remote_ip]
response = response[:response]
end
# model must respond to the 'errors' message and the result of that must respond to 'add'
if !model || !model.respond_to?('errors') || !model.send('errors').respond_to?('add')
model = nil
end
response ||= options[:response]
remote_ip ||= options[:remote_ip]
if response.blank? || remote_ip.blank?
request = options[:request]
raise ArgumentError, 'Either :request must be specified or both :response and :remote_ip must be specified.' unless request
response = request.params['g-recaptcha-response']
remote_ip = request.respond_to?(:remote_ip) ? request.send(:remote_ip) : ENV['REMOTE_ADDR']
end
if disabled?
# In tests or environments where reCAPTCHA is disabled,
# the response should be 'disabled' to verify successfully.
return response == 'disabled'
else
begin
if proxy.blank?
http = Net::HTTP
else
http = Net::HTTP::Proxy(proxy.host, proxy.port, proxy.user, proxy.password)
end
verify_hash = {
secret: private_key,
remoteip: remote_ip,
response: response
}
recaptcha = nil
Timeout::timeout(5) do
uri = URI.parse('https://www.google.com/recaptcha/api/siteverify')
http_instance = http.new(uri.host, uri.port)
if uri.port == 443
http_instance.use_ssl = true
end
request = Net::HTTP::Post.new(uri.request_uri)
request.set_form_data(verify_hash)
recaptcha = http_instance.request(request)
end
answer = JSON.parse(recaptcha.body)
unless answer['success'].to_s.downcase == 'true'
if model
model.errors.add(options[:attribute] || :base, 'Recaptcha verification failed.')
end
return false
end
return true
rescue Timeout::Error
if model
model.errors.add(:base, 'Recaptcha unreachable.')
end
end
end
false
end
##
# Contains a collection of onload callbacks for explicit reCAPTCHA fields.
#
# Used by the Incline::Recaptcha::Tag helper.
def self.onload_callbacks
# FIXME: Should probably move this to the session.
@onload_callbacks ||= []
end
##
# Generates a script block to load reCAPTCHA and activate any reCAPTCHA fields.
def self.script_block
if onload_callbacks.any?
ret = "\n"
# clear the cache.
onload_callbacks.clear
ret.html_safe
end
end
##
# Pauses reCAPTCHA validation for the specified block of code.
def self.pause_for(&block)
# already paused, so just call the block.
return block.call if paused?
# otherwise pause and then call the block.
self.temp_lock = true
begin
return block.call
ensure
self.temp_lock = false
end
end
##
# Determines if reCAPTCHA validation is currently paused.
def self.paused?
temp_lock
end
private
def self.enable_for_testing(pub_key = nil, priv_key = nil)
raise 'This method is only valid when testing.' unless Rails.env.test?
@enabled_for_testing = true
@public_key = pub_key
@private_key = priv_key
begin
yield if block_given?
ensure
@private_key = nil
@public_key = nil
@enabled_for_testing = false
end
end
def self.enabled_for_testing?
@enabled_for_testing ||= false
end
def self.temp_lock
@temp_lock ||= false
end
def self.temp_lock=(bool)
@temp_lock = bool
end
end
end