verify_human 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0b8f03bb8489d979c45ce89d32a7060cddad065f4e10519c3e66a83349855076
4
+ data.tar.gz: aab710a58a80595c172ecf3587e398e7ea3295570e052b8fc68a2ff11d5cd163
5
+ SHA512:
6
+ metadata.gz: 48d7bdce8bd369f27077241dcb8fee2118cae1575ea1fb38de40e5b102da1d8984c83ab86e8e7f96209255370b69871f43cade994e5ad219945e6fea005baf38
7
+ data.tar.gz: 5634039ac132fc72b2e57185a259e1159f496527a09661699133b2f118270a4dd8a1188949166a7c3a4f78e309f820cd06f2cee617855765ab6cefeae582f935
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marc Köhlbrugge
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # verify_human
2
+
3
+ Human verification for Rails forms. Cloudflare Turnstile under the hood; provider-agnostic API on the surface.
4
+
5
+ This is an opinionated gem: it does human verification the way I like it in my own Rails apps. It's shared in case that way suits you too, not as a general-purpose library that tries to fit every setup.
6
+
7
+ ## Installation
8
+
9
+ ```ruby
10
+ gem "verify_human"
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```ruby
16
+ # ApplicationController (or a specific controller)
17
+ include HumanVerification
18
+
19
+ # Any controller with a form bots abuse. Guards :create by default;
20
+ # declare AFTER rate_limit so the cheap local check runs first.
21
+ rate_limit to: 5, within: 15.minutes, only: :create
22
+ verify_human "sign_in"
23
+ ```
24
+
25
+ ```erb
26
+ <%# In the form. Invisible for ~98% of visitors (interaction-only Managed
27
+ widget); shows a one-click checkbox only when Cloudflare requires it. %>
28
+ <%= human_verification_tag "sign_in" %>
29
+
30
+ <%# Submit the surrounding form as soon as a token is issued: %>
31
+ <%= human_verification_tag "apply", auto_submit: true %>
32
+
33
+ <%# Never show UI at all (separate invisible-mode widget; visitors who can't
34
+ pass silently post tokenless -- pair with a retry/interstitial flow): %>
35
+ <%= human_verification_tag "apply", invisible: true %>
36
+ ```
37
+
38
+ Override the failure response per controller (default: `redirect_back` with an alert):
39
+
40
+ ```ruby
41
+ def human_verification_failed
42
+ flash.now[:alert] = "We couldn't verify you're human. Please try again."
43
+ render :new, status: :unprocessable_entity # frame-safe, preserves input
44
+ end
45
+ ```
46
+
47
+ ## Configuration
48
+
49
+ Reads Rails credentials; verification no-ops unless BOTH keys are present (dev/test are zero-config):
50
+
51
+ ```yaml
52
+ turnstile:
53
+ site_key: ...
54
+ secret_key: ...
55
+ invisible_site_key: ... # only if you use invisible: true
56
+ invisible_secret_key: ...
57
+ ```
58
+
59
+ Create Managed-mode (and optionally invisible-mode) widgets in the Cloudflare dashboard or via API. Development can use Cloudflare's always-pass test keys. Invisible mode requires referencing Cloudflare's Turnstile Privacy Addendum in your privacy policy.
60
+
61
+ ## Behavior worth knowing
62
+
63
+ - Fails open on Cloudflare outages (2s/3s timeouts, under a 20s rack-timeout budget); a blank token is rejected without an HTTP call.
64
+ - The siteverify `action` is enforced per form name: a token minted on one widget can't be spent on another endpoint.
65
+ - The Stimulus controller is Turbo-safe: container cleared around Turbo caching (TurnstileError 300030), script-load `onerror` recovery, no render-after-disconnect, and a fresh widget after a Turbo morph (e.g. a form re-rendered with 422 on a page using `turbo_refreshes_with method: :morph`).
66
+ - Users who block challenges.cloudflare.com cannot pass (fail closed by nature of CAPTCHAs) -- keep failure paths friendly.
67
+
68
+ ## Tests
69
+
70
+ ```ruby
71
+ require "verify_human/test_helpers" # needs WebMock
72
+
73
+ include HumanVerification::TestHelpers
74
+ with_human_verification_configured { ... }
75
+ stub_human_verification(success: true, action: "sign_in")
76
+ stub_human_verification_outage
77
+ ```
78
+
79
+ ## Contributing
80
+
81
+ Bug reports are welcome as [GitHub issues](https://github.com/marckohlbrugge/verify_human/issues). I don't accept pull requests: the gem stays as opinionated as it is, so please fork it if you want it to work differently.
82
+
83
+ ## Releasing
84
+
85
+ Bump `VerifyHuman::VERSION`, tag `vX.Y.Z`, push the tag. GitHub Actions publishes to rubygems.org as a trusted publisher, so no API key is stored anywhere.
86
+
87
+ ## License
88
+
89
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,90 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Renders a Cloudflare Turnstile widget. Invisible for most visitors
4
+ // (appearance: interaction-only); only shows a checkbox when Cloudflare
5
+ // requires interaction. With autoSubmit, the surrounding form is submitted
6
+ // as soon as a token is issued.
7
+ let scriptPromise
8
+
9
+ function loadTurnstile() {
10
+ if (window.turnstile) return Promise.resolve()
11
+
12
+ scriptPromise ||= new Promise((resolve) => {
13
+ const script = document.createElement("script")
14
+ script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
15
+ script.async = true
16
+ script.onload = resolve
17
+ script.onerror = () => {
18
+ // Clear the memoized promise so a later connect() can retry, and resolve
19
+ // so awaiting controllers proceed (render is guarded on window.turnstile).
20
+ scriptPromise = undefined
21
+ resolve()
22
+ }
23
+ document.head.appendChild(script)
24
+ })
25
+
26
+ return scriptPromise
27
+ }
28
+
29
+ export default class extends Controller {
30
+ static values = { siteKey: String, action: String, autoSubmit: Boolean }
31
+
32
+ connect() {
33
+ this.disconnected = false
34
+ // Turbo must never snapshot a rendered widget: restoring one leaves stale
35
+ // iframe markup in the container, and rendering into a non-empty container
36
+ // crashes the widget (TurnstileError 300030, no token ever minted).
37
+ this.clearBeforeCache = () => this.reset()
38
+ document.addEventListener("turbo:before-cache", this.clearBeforeCache)
39
+
40
+ // A Turbo morph (page refresh, or a form re-rendered with 422 on a page
41
+ // using turbo_refreshes_with :morph) strips the widget's markup but keeps
42
+ // this controller connected, and the token it held is already spent.
43
+ this.renderAfterMorph = (event) => {
44
+ if (event.target === this.element) this.render()
45
+ }
46
+ this.element.addEventListener("turbo:morph-element", this.renderAfterMorph)
47
+
48
+ this.render()
49
+ }
50
+
51
+ disconnect() {
52
+ this.disconnected = true
53
+ document.removeEventListener("turbo:before-cache", this.clearBeforeCache)
54
+ this.element.removeEventListener("turbo:morph-element", this.renderAfterMorph)
55
+ this.reset()
56
+ }
57
+
58
+ async render() {
59
+ await loadTurnstile()
60
+ if (this.disconnected || !window.turnstile) return
61
+
62
+ this.reset() // defense against an already-cached snapshot or a morphed container
63
+ this.widgetId = window.turnstile.render(this.element, {
64
+ sitekey: this.siteKeyValue,
65
+ action: this.actionValue,
66
+ appearance: "interaction-only",
67
+ "response-field-name": "cf_turnstile_response",
68
+ callback: () => this.tokenReceived(),
69
+ // A crashed widget can't mint a token; reset so the form stays usable
70
+ // and the server-side retry flow takes over on submit.
71
+ "error-callback": () => {
72
+ this.reset()
73
+ return true
74
+ }
75
+ })
76
+ }
77
+
78
+ tokenReceived() {
79
+ if (this.disconnected) return
80
+ if (this.autoSubmitValue) this.element.closest("form")?.requestSubmit()
81
+ }
82
+
83
+ reset() {
84
+ if (this.widgetId) {
85
+ window.turnstile.remove(this.widgetId)
86
+ this.widgetId = null
87
+ }
88
+ this.element.replaceChildren()
89
+ }
90
+ }
@@ -0,0 +1,69 @@
1
+ module HumanVerification
2
+ extend ActiveSupport::Concern
3
+
4
+ VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
5
+
6
+ class_methods do
7
+ # Declare after any rate_limit so the cheap local check runs first.
8
+ # Guards :create by default -- the public form submissions bots abuse.
9
+ # Pass only:/except: to protect other actions (their forms need a widget).
10
+ def verify_human(name = nil, **options)
11
+ options[:only] = :create unless options.key?(:only) || options.key?(:except)
12
+ before_action(**options) { verify_turnstile(name) }
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ def verify_turnstile(expected_name = nil)
19
+ return unless human_verification_enabled?
20
+ return if turnstile_token_valid?(expected_name)
21
+
22
+ human_verification_failed
23
+ end
24
+
25
+ # Override in controllers that need a custom failure response (e.g. forms
26
+ # inside a turbo frame, where a redirect yields "Content missing" and
27
+ # discards the user's input).
28
+ def human_verification_failed
29
+ redirect_back fallback_location: root_path,
30
+ alert: "We couldn't verify you're human. Please try again."
31
+ end
32
+
33
+ # Both keys or nothing: enforcing with only a secret_key would reject every
34
+ # submission, because human_verification_tag renders no widget without a site_key.
35
+ # (HumanVerificationHelper duplicates this check for view code.)
36
+ def human_verification_enabled?
37
+ %i[site_key secret_key].all? { |key| Rails.application.credentials.dig(:turnstile, key).present? }
38
+ end
39
+
40
+ # Tokens are widget-specific: a page may run the invisible-mode widget while
41
+ # other forms run the managed one. The form says which one minted the token
42
+ # (spoofing the param just fails verification against the wrong secret).
43
+ def turnstile_secret_key
44
+ key = (params[:cf_turnstile_widget] == "invisible") ? :invisible_secret_key : :secret_key
45
+ Rails.application.credentials.dig(:turnstile, key)
46
+ end
47
+
48
+ def turnstile_token_valid?(expected_name)
49
+ return false if params[:cf_turnstile_response].blank?
50
+
51
+ response = Faraday.post(VERIFY_URL,
52
+ secret: turnstile_secret_key,
53
+ response: params[:cf_turnstile_response],
54
+ remoteip: request.remote_ip) do |req|
55
+ # Well under rack-timeout's 20s budget, so a slow Cloudflare fails open
56
+ # via the rescue below instead of 500ing the request.
57
+ req.options.open_timeout = 2
58
+ req.options.timeout = 3
59
+ end
60
+
61
+ # Cloudflare calls the per-widget name "action" in its API. Its testing
62
+ # keys (development) answer without one, flagged in the metadata.
63
+ data = JSON.parse(response.body)
64
+ data["success"] && (expected_name.nil? || data["action"] == expected_name || data.dig("metadata", "result_with_testing_key") == true)
65
+ rescue Faraday::Error, JSON::ParserError => error
66
+ Rails.error.report(error)
67
+ true # Fail open: never lock users out because Cloudflare is unreachable
68
+ end
69
+ end
@@ -0,0 +1,28 @@
1
+ module HumanVerificationHelper
2
+ # Renders nothing when no site key is configured (development/test).
3
+ # With auto_submit: true the surrounding form is submitted as soon as
4
+ # Cloudflare issues a token.
5
+ # With invisible: true, an invisible-mode widget runs instead: it never
6
+ # shows UI, and when it cannot pass silently the form simply posts without
7
+ # a token and the app's failure path takes over. The paired hidden field tells
8
+ # the server which widget's secret to verify against.
9
+ def human_verification_tag(name = nil, auto_submit: false, invisible: false, **options)
10
+ return unless human_verification_enabled?
11
+
12
+ widget = tag.div(**options, data: {
13
+ controller: "turnstile",
14
+ turnstile_site_key_value: Rails.application.credentials.dig(:turnstile, invisible ? :invisible_site_key : :site_key),
15
+ turnstile_action_value: name,
16
+ turnstile_auto_submit_value: auto_submit
17
+ })
18
+ invisible ? widget + hidden_field_tag(:cf_turnstile_widget, "invisible") : widget
19
+ end
20
+
21
+ # Both keys or nothing, matching HumanVerification#human_verification_enabled?
22
+ # (intentionally the same name): rendering a widget without a secret_key
23
+ # would collect tokens the server never checks, and enforcing without a
24
+ # site_key would reject every submission.
25
+ def human_verification_enabled?
26
+ %i[site_key secret_key].all? { |key| Rails.application.credentials.dig(:turnstile, key).present? }
27
+ end
28
+ end
@@ -0,0 +1 @@
1
+ pin "controllers/turnstile_controller", to: "verify_human/turnstile_controller.js", preload: false
@@ -0,0 +1,21 @@
1
+ module VerifyHuman
2
+ # Non-isolated engine: app/controllers/concerns and app/helpers merge into
3
+ # the host app's autoload and helper paths, so apps keep writing
4
+ # `include HumanVerification` and calling `human_verification_tag` with no
5
+ # namespace changes.
6
+ class Engine < ::Rails::Engine
7
+ initializer "verify_human.assets" do |app|
8
+ if app.config.respond_to?(:assets)
9
+ app.config.assets.paths << root.join("app/assets/javascripts")
10
+ # Sprockets needs an explicit precompile entry; Propshaft ignores this.
11
+ app.config.assets.precompile += %w[verify_human/turnstile_controller.js] if app.config.assets.respond_to?(:precompile)
12
+ end
13
+ end
14
+
15
+ # Pinned under controllers/ so stimulus-loading's lazyLoadControllersFrom
16
+ # discovers and registers the controller exactly like an app-local one.
17
+ initializer "verify_human.importmap", before: "importmap" do |app|
18
+ app.config.importmap.paths << root.join("config/importmap.rb") if defined?(Importmap)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,44 @@
1
+ # Test support for apps using verify_human. Requires WebMock.
2
+ #
3
+ # require "verify_human/test_helpers"
4
+ # include HumanVerification::TestHelpers
5
+ module HumanVerification
6
+ module TestHelpers
7
+ # Swaps in credentials that answer turnstile keys while delegating every
8
+ # other lookup to the real credentials.
9
+ def with_human_verification_configured(&block)
10
+ credentials = Class.new(SimpleDelegator) do
11
+ def dig(*keys)
12
+ case keys
13
+ in [:turnstile, :secret_key] then "test-secret"
14
+ in [:turnstile, :site_key] then "test-site-key"
15
+ in [:turnstile, :invisible_secret_key] then "test-invisible-secret"
16
+ in [:turnstile, :invisible_site_key] then "test-invisible-site-key"
17
+ else super
18
+ end
19
+ end
20
+
21
+ # EncryptedConfiguration claims to respond to everything, which makes
22
+ # Minitest's stub treat this object as a callable and replace it with
23
+ # the result of #call. Deny :call so stub returns the object itself.
24
+ def respond_to?(name, include_private = false)
25
+ return false if name == :call
26
+ super
27
+ end
28
+ end.new(Rails.application.credentials)
29
+
30
+ Rails.application.stub(:credentials, credentials, &block)
31
+ end
32
+
33
+ def stub_human_verification(success:, action: nil)
34
+ body = {success: success}
35
+ body[:action] = action if action
36
+ stub_request(:post, HumanVerification::VERIFY_URL)
37
+ .to_return(body: body.to_json, headers: {"Content-Type" => "application/json"})
38
+ end
39
+
40
+ def stub_human_verification_outage
41
+ stub_request(:post, HumanVerification::VERIFY_URL).to_timeout
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,3 @@
1
+ module VerifyHuman
2
+ VERSION = "0.1.5"
3
+ end
@@ -0,0 +1,5 @@
1
+ require "verify_human/version"
2
+ require "verify_human/engine"
3
+
4
+ module VerifyHuman
5
+ end
metadata ADDED
@@ -0,0 +1,80 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: verify_human
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.5
5
+ platform: ruby
6
+ authors:
7
+ - Marc Köhlbrugge
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: faraday
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ description: verify_human class macro, human_verification_tag view helper, and a Turbo-safe
41
+ Stimulus controller. Interaction-only Managed widgets by default, optional invisible-mode
42
+ widgets, fail-open verification with tight timeouts, per-form action enforcement.
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - LICENSE
48
+ - README.md
49
+ - app/assets/javascripts/verify_human/turnstile_controller.js
50
+ - app/controllers/concerns/human_verification.rb
51
+ - app/helpers/human_verification_helper.rb
52
+ - config/importmap.rb
53
+ - lib/verify_human.rb
54
+ - lib/verify_human/engine.rb
55
+ - lib/verify_human/test_helpers.rb
56
+ - lib/verify_human/version.rb
57
+ homepage: https://github.com/marckohlbrugge/verify_human
58
+ licenses:
59
+ - MIT
60
+ metadata:
61
+ source_code_uri: https://github.com/marckohlbrugge/verify_human
62
+ bug_tracker_uri: https://github.com/marckohlbrugge/verify_human/issues
63
+ rdoc_options: []
64
+ require_paths:
65
+ - lib
66
+ required_ruby_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: '3.2'
71
+ required_rubygems_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ requirements: []
77
+ rubygems_version: 3.6.9
78
+ specification_version: 4
79
+ summary: Human verification for Rails forms; Cloudflare Turnstile under the hood
80
+ test_files: []