Skip to content

Commit

Permalink
Add Google reCAPTCHA (#19)
Browse files Browse the repository at this point in the history
* Add Recaptcha service. Add specs.
* Add js recaptcha_controller #validate method
* Rework submit button to first call recaptcha validations.
* Obfuscate recaptcha_token in logs
* Add specs for pledges#create request
* Add recaptcha script to <head>
* Modify RuboCop string literal cop to include spec directory
* Run cops on spec directory
* Update example env file and README.
* Add redis to GitHub CI workflow.
* Update test command in GitHub CI workflow.
* Remove screenshot storage in GitHub CI workflow.
  • Loading branch information
sarmstead authored Nov 25, 2024
1 parent d6408cb commit 28a6bde
Show file tree
Hide file tree
Showing 13 changed files with 186 additions and 29 deletions.
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ SMTP_PORT=1025
SMTP_DOMAIN=localhost
SMTP_ADDRESS=localhost
DB_PASSWORD=
G_RECAPTCHA_SITE_KEY=
G_RECAPTCHA_SECRET_KEY=
G_RECAPTCHA_SCORE_THRESHOLD=0.8
20 changes: 6 additions & 14 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,11 @@ jobs:
- 5432:5432
options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3

# redis:
# image: redis
# ports:
# - 6379:6379
# options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
redis:
image: redis
ports:
- 6379:6379
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5

steps:
- name: Install packages
Expand All @@ -88,12 +88,4 @@ jobs:
env:
RAILS_ENV: test
RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
run: bin/rails db:test:prepare test test:system

- name: Keep screenshots from failed system tests
uses: actions/upload-artifact@v4
if: failure()
with:
name: screenshots
path: ${{ github.workspace }}/tmp/screenshots
if-no-files-found: ignore
run: bin/rails db:prepare && bin/bundle exec rspec
15 changes: 10 additions & 5 deletions .rubocop.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
# Omakase Ruby styling for Rails
inherit_gem: { rubocop-rails-omakase: rubocop.yml }

# Overwrite or add rules to create your own house style
#
# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]`
# Layout/SpaceInsideArrayLiteralBrackets:
# Enabled: false
Style/StringLiterals:
Enabled: true
EnforcedStyle: double_quotes
Include:
- "app/**/*"
- "config/**/*"
- "lib/**/*"
- "test/**/*"
- "Gemfile"
- "spec/**/*"
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Now, let's get you set up:

1. Install [Docker Desktop](https://docs.docker.com/desktop/) on your local machine. This allows you to run the database, Redis store, and [Mailpit](https://mailpit.axllent.org/) containers locally. You can, of course, skip this step if Docker's not your thing. Oh whale 🐳!
1. Pull down this repository to your local machine.
1. At the root of the project, run `cp .env.example .env`. Then slide on over to your new `.env` file and fill out the empty environment variables. If you're using Docker, feel free to put whatever you want for `SMTP_USERNAME`, `SMTP_PASSWORD`, and `DB_PASSWORD`.
1. At the root of the project, run `cp .env.example .env`. Then slide on over to your new `.env` file and fill out the empty environment variables. If you're using Docker, feel free to put whatever you want for `SMTP_USERNAME`, `SMTP_PASSWORD`, and `DB_PASSWORD`. Make sure to use reCAPTCHA v3 for all `G_RECAPTCHA_**` values. You can generate your own reCAPTCHA credentials at [https://www.google.com/recaptcha/](https://www.google.com/recaptcha/).
1. Open the Docker Desktop application. Then, in your termnial, run `docker compose up`. This will create and start the containers for PostgreSQL, Redis, and Mailpit.
1. In your terminal, run `bin/setup` to install your dependcies and set up your database.
1. In your terminal, run `bin/rails s` to fire up the development server.
Expand Down
8 changes: 7 additions & 1 deletion app/controllers/pledges_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ def new
def create
@pledge = Pledge.new(pledge_params)

if @pledge.save
if safe_submission? && @pledge.save
flash[:success] = "Thanks for signing! 🥳"
PledgeMailer.new_pledge(
first_name: pledge_params[:first_name],
Expand All @@ -24,4 +24,10 @@ def create
def pledge_params
params.require(:pledge).permit(:first_name, :last_name, :email)
end

def safe_submission?
::Recaptcha.new(
token: params[:g_recaptcha_response]
).call
end
end
26 changes: 26 additions & 0 deletions app/javascript/controllers/recaptcha_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
validate(event) {
event.preventDefault();

const siteKey = document
.getElementById("recaptcha-initlializer")
.src.split("?render=")
.slice(-1)[0];
const form = this.element;
const tokenInput = form.querySelector("input[name='g_recaptcha_response']");
const submitButton = form.querySelector("button[type='submit']");

grecaptcha.ready(function () {
grecaptcha
.execute(siteKey, {
action: "submit",
})
.then(function (token) {
tokenInput.value = token;
form.requestSubmit(submitButton);
});
});
}
}
31 changes: 31 additions & 0 deletions app/services/recaptcha.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
require "net/http"
require "uri"

class Recaptcha
def initialize(token:)
@token = token
end

def call
endpoint = "https://www.google.com/recaptcha/api/siteverify"
data = {
secret: ENV["G_RECAPTCHA_SECRET_KEY"],
response: @token
}
response = Net::HTTP.post_form URI(endpoint), data
payload = JSON.parse(response.body)
threshold = ENV.fetch("G_RECAPTCHA_SCORE_THRESHOLD", "0.5").to_f

if payload["success"] && payload["score"] > threshold
Rails.logger.info "reCAPTCHA evaluated as safe: payload => #{payload}"
true
else
Rails.logger.warn "reCAPTCHA evaluated as unsafe: payload => #{payload}"
false
end

rescue StandardError => exception
Rails.logger.error "Form response failed to validate with reCAPTCHA: exception => #{exception}, payload => #{payload}"
false
end
end
1 change: 1 addition & 0 deletions app/views/layouts/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
<script id="recaptcha-initlializer" src="https://www.google.com/recaptcha/api.js?render=<%= ENV['G_RECAPTCHA_SITE_KEY'] %>"></script>
</head>

<body>
Expand Down
5 changes: 3 additions & 2 deletions app/views/pledges/form/_index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<li class="text-white text-base leading-8">I will <span class="font-bold underline">cultivate</span> relationships.</li>
</ul>
</section>
<%= form_for @pledge, html: { class: "flex flex-col justify-center gap-5 w-full max-w-content px-5 md:px-10 lg:px-0" } do |form| %>
<%= form_for @pledge, html: { class: "flex flex-col justify-center gap-5 w-full max-w-content px-5 md:px-10 lg:px-0", data: { controller: "recaptcha" } } do |form| %>
<section class="flex flex-col gap-5 sm:flex-row">
<div class="flex flex-col w-full">
<%= form.label :first_name, class: "bg-soil w-fit text-white font-medium italic px-3 uppercase ml-8 -mb-[10px] z-10 text-xs" %>
Expand All @@ -24,8 +24,9 @@
<%= form.label :email, class: "bg-soil w-fit text-white font-medium italic px-3 uppercase ml-8 -mb-[10px] z-10 text-xs" %>
<%= form.text_field :email, required: true, class: "bg-transparent border-2 border-mango rounded-none h-11 px-3 text-white focus:outline-0 focus:border-yellow-400" %>
</section>
<input type="hidden" name="g_recaptcha_response" />
<section class="flex flex-col gap-3 items-end">
<%= form.submit "Sign", class: "flex justify-center items-center px-8 min-h-11 bg-mango font-medium italic uppercase text-white text-lg cursor-pointer rounded-none border-2 border-mango hover:bg-persimmon hover:border-persimmon focus:outline-0 focus:border-yellow-400" %>
<%= form.submit "Sign", class: "flex justify-center items-center px-8 min-h-11 bg-mango font-medium italic uppercase text-white text-lg cursor-pointer rounded-none border-2 border-mango hover:bg-persimmon hover:border-persimmon focus:outline-0 focus:border-yellow-400", data: { action: "recaptcha#validate" } %>
</section>
<% end %>
<%= render "pledges/form/swirly_arrow" %>
Expand Down
2 changes: 1 addition & 1 deletion config/initializers/filter_parameter_logging.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@
# Use this to limit dissemination of sensitive information.
# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
Rails.application.config.filter_parameters += [
:passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn
:passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :g_recaptcha_response
]
11 changes: 6 additions & 5 deletions spec/rails_helper.rb
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
# This file is copied to spec/ when you run 'rails generate rspec:install'
require 'spec_helper'
ENV['RAILS_ENV'] ||= 'test'
require_relative '../config/environment'
require "spec_helper"
ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
# Prevent database truncation if the environment is production
abort("The Rails environment is running in production mode!") if Rails.env.production?
# Uncomment the line below in case you have `--require rails_helper` in the `.rspec` file
# that will avoid rails generators crashing because migrations haven't been run yet
# return unless Rails.env.test?
require 'rspec/rails'
require "rspec/rails"
# Add additional requires below this line. Rails is not loaded until this point!
ActiveJob::Base.queue_adapter = :test

# Requires supporting ruby files with custom matchers and macros, etc, in
# spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are
Expand All @@ -35,7 +36,7 @@
RSpec.configure do |config|
# Remove this line if you're not using ActiveRecord or ActiveRecord fixtures
config.fixture_paths = [
Rails.root.join('spec/fixtures')
Rails.root.join("spec/fixtures")
]

# If you're not using ActiveRecord, or you'd prefer not to run each of your
Expand Down
41 changes: 41 additions & 0 deletions spec/requests/pledges_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
require "rails_helper"

describe PledgesController do
include ActionMailer::TestHelper
include ActiveJob::TestHelper

describe "#create" do
let(:first_name) { "Isaac" }
let(:last_name) { "Newton" }
let(:email) { "inewton@example.net" }
let(:token) { "motion_token" }
let(:params) do
{
pledge: {
first_name: first_name,
last_name: last_name,
email: email,
g_recaptcha_response: token
}
}
end
let(:recaptcha_stub) { double call: true }

before do
allow(Recaptcha).to receive(:new).and_return recaptcha_stub
post "/pledges", params: params
end

it "creates a new pledge" do
expect(Pledge.count).to eq 1
end

it "enqueues an email job" do
expect(enqueued_jobs.first[:job]).to eq ActionMailer::MailDeliveryJob
end

it "redirects to the root" do
expect(response.code).to eq "302"
end
end
end
50 changes: 50 additions & 0 deletions spec/services/recaptcha_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
require "rails_helper"

describe Recaptcha do
subject { described_class.new(token: double) }

describe "#call" do
let(:response) { double body: "{\n \"success\": true,\n \"challenge_ts\": \"2024-11-25T00:03:52Z\",\n \"hostname\": \"example.net\",\n \"score\": 0.9,\n \"action\": \"submit\"\n}" }

before do
allow(Rails.logger).to receive_messages(info: nil, warn: nil, error: nil)
allow(Net::HTTP).to receive(:post_form).and_return response
end

context "safe form submission" do
it "logs an info message and returns true" do
expect(subject.call).to eq true
expect(Rails.logger).to have_received(:info)
end
end

context "unsafe form submission" do
let(:response) { double body: "{\n \"success\": false,\n \"challenge_ts\": \"2024-11-25T00:03:52Z\",\n \"hostname\": \"example.net\",\n \"score\": 0.1,\n \"action\": \"submit\"\n}" }

it "logs a warning message and returns false" do
expect(subject.call).to eq false
expect(Rails.logger).to have_received(:warn)
end
end

context "invalid reCAPTCHA call" do
let(:response) { double body: "{\n \"success\": false,\n \"error-codes\": [\n \"invalid-input-response\"\n ]\n}" }

it "logs a warning message and returns false" do
expect(subject.call).to eq false
expect(Rails.logger).to have_received(:warn)
end
end

context "StandardError" do
before do
allow(Net::HTTP).to receive(:post_form).and_raise(StandardError)
end

it "logs an error message and returns false" do
expect(subject.call).to eq false
expect(Rails.logger).to have_received(:error)
end
end
end
end

0 comments on commit 28a6bde

Please sign in to comment.