Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Google reCAPTCHA #19

Merged
merged 7 commits into from
Nov 25, 2024
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
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