Blog

Thoughts from my daily grind

Both reCaptcha V2 and V3 in same Rails Project

Posted by Ziyan Junaideen |Published: 28 June 2020 |Category: Code
Default Upload |

Have you tried to use different versions of reCaptcha in a Rails project? I tried and seemed not possible using the recaptcha gem. The reason is it only accepts one set of API credentials.

This is some thing I experienced. We were using reCaptcha V3 on a project and there was a need to implement a form that challenged the user. Given that reCaptcha V3 doesn't challenge the user and because V2 doesn't rank the user we decided to have both V2 and V3 side by side.

In a matter of hours I had a working solution. It does the job, but does need some refinement. If I have the time I will update this post with the updates I make.

We need a way to config the module with the site key and the secret key. This is the approach I take when I make a gem that requires configuration. While this is not a gem, its good to have a config like that since I planned to move it to a gem.

# frozen_string_literal: true
# app/system/invisible_recaptcha/config.rb

module InvisibleRecaptcha
  module Config
    class << self
      attr_accessor :configuration
    end

    def self.configure
      self.configuration ||= Configuration.new
      yield(configuration)
    end

    class Configuration
      attr_accessor :site_key, :secret_key
    end
  end
end

Then we need to config it with some thing like...

# config/initializers/recaptcha.rb

InvisibleRecaptcha::Config.configure do |config|
  config.site_key = "<MY_SITE_KEY>"
  config.secret_key = "<MY_SECRET>"
end

Then we need a way to call the Google API and get a response. This is a HTML wrapper. Note that we are going to get the configuration (secret) from the config instance. We are also using a Response class which will be defined next.

# frozen_string_literal: true

module InvisibleRecaptcha
  # HTTP wrapper for the API
  class HTTP
    ENDPOINT = "https://www.google.com/recaptcha/api".freeze

    include HTTParty
    base_uri ENDPOINT

    attr_accessor :token, :ip

    def initialize(token, ip = null)
      @token = token
      @ip = ip
    end

    def check
      response = self.class.post "/siteverify", options
      body = JSON.parse(response.body)
      @response = InvisibleRecaptcha::Response.fromHTTPResponse(body)
    end

    def options
      options = {}

      options[:body] = {
        secret: InvisibleRecaptcha::Config.configuration.secret_key,
        response: token,
        remoteip: ip
      }

      options
    end

    def self.check(response, ip = null)
      new(response, ip).check
    end
  end
end
# frozen_string_literal: true

module InvisibleRecaptcha
  class Response
    ERROR_CODES = {
      missing_input_secret: "The secret parameter is missing",
      invalid_input_secret: "The secret parameter is invalid of malformed",
      missing_input_response: "The response parameter is missing",
      invalid_input_response: "The response parameter is invalid or malformed",
      bad_request: "The request is invalid or malformed",
      timeout_or_duplicate: "The response is no longer valid: either too old or has been used previously"
    }.freeze

    attr_accessor :success, :challenge_time_stamp, :hostname, :error_codes

    alias success? success

    def initialize(success:, challenge_time_stamp:, hostname:, error_codes:)
      @success = success
      @challenge_time_stamp = challenge_time_stamp
      @hostname = hostname
      @error_codes = error_codes
    end

    def error_messages
      error_codes.map { |code| ERROR_CODES[code] }
    end

    def self.fromHTTPResponse(response)
      error_codes = if response["error-codes"]
                      response["error-codes"].map { |code| code.gsub("-", "_") }
                    else
                      []
                    end

      new(
        success: response["success"],
        challenge_time_stamp: response["challenge_ts"],
        hostname: response["hostname"],
        error_codes: error_codes
      )
    end

    def success?
      @success.to_b
    end
  end
end

There are 2 modules that are to be included in application_controller.rb and application_helper.rb.

# frozen_string_literal: true
# app/system/invisible_recaptcha/extensions/controller.rb

module InvisibleRecaptcha
  module Extensions
    module Controller
      def verify_invisible_recaptcha
        token = params["g-recaptcha-response"]
        @invisible_recaptcha_response = InvisibleRecaptcha::HTTP.check(token, request.remote_ip)
        @invisible_recaptcha_response.success?
      end
    end
  end
end
# frozen_string_literal: true
# apps/system/invisible_recaptcha/extensions/helper.rb

module InvisibleRecaptcha
  module Extensions
    module Helper
      def recaptcha_v2_invisible(form, text, options = {})
        klass = options.delete(:class)
        site_key = InvisibleRecaptcha::Config.configuration.site_key
        element = form.submit(text, class: "#{klass} g-recaptcha", data: { sitekey: site_key, callback: "InvisibleRecaptchaSubmit" })
        script = recaptcha_v2_invisible_script
        "#{script}\n#{element}".html_safe
      end

      private

      def recaptcha_v2_invisible_script
        <<-HTML
          <script src="https://www.google.com/recaptcha/api.js" async defer></script>

          <script>
            var InvisibleRecaptchaSubmit = function() {
              var button = $('.g-recaptcha');
              var form = button.closest('form');
              form.submit();
            }
          </script>
        HTML
      end
    end
  end
end

Then in the views you can use it like:

= form_for @user, url: users_path, method: :post, class: 'registration-form' do |f|
  = f.invisible_captcha :name
  // more form fields
  .form-actions
    = recaptcha_v2_invisible(f, 'Create Account', class: 'btn btn-primary pull-right')
Tags
About the Author

Ziyan Junaideen -

Ziyan is an expert Ruby on Rails web developer with 8 years of experience specializing in SaaS applications. He spends his free time he writes blogs, drawing on his iPad, shoots photos.

Comments