devise-cloudflare-turnstile 0.9.0

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: 2bfd5d303bc2c0c645f0b809e2890ae0b7972d879b38a09e3f09dfe808efa6bd
4
+ data.tar.gz: 4acf8007cfbe3c925d342eb94c3428f0200786b6b9f7c3881291de610cf3f9da
5
+ SHA512:
6
+ metadata.gz: d32017c8ceeddcdeccac77ad949fa8f1a794a7ed6e348a28db0c02e05532f728ee08f41137b0815a44a4c86f57c97edc3fe0e482100ccb1556273a7948c65e2c
7
+ data.tar.gz: 86c2de3bed62f852e5a2cb7f40548bf10e989fc99d64797d1ddbd287781e4df91dd3d2f8d5e216c506b62ae30f3abb2b5616518aebaf4db50da3a6f3564770ce
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Vadim Kononov
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,235 @@
1
+ # Devise Cloudflare Turnstile
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/devise-cloudflare-turnstile.svg?label=Gem&logo=rubygems&logoColor=white)](https://rubygems.org/gems/devise-cloudflare-turnstile)
4
+ [![Ruby](https://img.shields.io/badge/Ruby-2.6%20to%204.0-CC342D?logo=ruby&logoColor=white)](https://github.com/vkononov/devise-cloudflare-turnstile/blob/main/.github/workflows/test.yml)
5
+ [![Rails](https://img.shields.io/badge/Rails-5.0%20to%208.1-D30001?logo=rubyonrails&logoColor=white)](https://github.com/vkononov/devise-cloudflare-turnstile/blob/main/Appraisals)
6
+ [![Test Matrix](https://img.shields.io/github/actions/workflow/status/vkononov/devise-cloudflare-turnstile/test.yml?branch=main&label=Test%20Matrix&logo=github)](https://github.com/vkononov/devise-cloudflare-turnstile/actions/workflows/test.yml)
7
+ [![Lint](https://img.shields.io/github/actions/workflow/status/vkononov/devise-cloudflare-turnstile/lint.yml?branch=main&label=Lint&logo=github)](https://github.com/vkononov/devise-cloudflare-turnstile/actions/workflows/lint.yml)
8
+ [![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
9
+
10
+ Automatically protect all Devise authentication forms with Cloudflare Turnstile. Add two helper tags to your layout, set your API keys, and every Devise form is protected — no changes to your Devise views or controllers.
11
+
12
+ Built on [cloudflare-turnstile-rails](https://github.com/vkononov/cloudflare-turnstile-rails) for verification, widget rendering, CSP nonces, and Turbo support.
13
+
14
+ Supports **Rails 5.0 → latest** and **Ruby 2.6 → latest**, with the full Rails/Ruby matrix tested daily in CI.
15
+
16
+ [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/yellow_img.png)](https://www.buymeacoffee.com/vkononov)
17
+
18
+ ## Features
19
+
20
+ * **No Devise view changes**: Auto-injects the Turnstile widget into Devise forms via JavaScript.
21
+ * **Automatic verification**: Checks Turnstile on every Devise `create` action.
22
+ * **Configurable**: Skip protection per controller or action, and set widget appearance globally.
23
+ * **Plays well with your stack**: Devise extensions (e.g. devise-invitable), Turbo/Turbolinks re-renders, and CSP nonces all work out of the box.
24
+ * **Built on [cloudflare-turnstile-rails](https://github.com/vkononov/cloudflare-turnstile-rails)**: verification, script loading, and i18n error messages.
25
+
26
+ ## Table of Contents
27
+
28
+ - [Getting Started](#getting-started)
29
+ - [Installation](#installation)
30
+ - [Getting API Keys](#getting-api-keys)
31
+ - [How It Works](#how-it-works)
32
+ - [Skipping Protection](#skipping-protection)
33
+ - [Customizing the Widget](#customizing-the-widget)
34
+ - [Passing Extra Verification Parameters](#passing-extra-verification-parameters)
35
+ - [How Protection Is Applied](#how-protection-is-applied)
36
+ - [Automated Testing of Your Integration](#automated-testing-of-your-integration)
37
+ - [Development](#development)
38
+ - [Setup](#setup)
39
+ - [Running the Test Suite](#running-the-test-suite)
40
+ - [Code Linting](#code-linting)
41
+ - [Contributing](#contributing)
42
+ - [License](#license)
43
+
44
+ ## Getting Started
45
+
46
+ ### Installation
47
+
48
+ Add the gem to your Gemfile:
49
+
50
+ ```ruby
51
+ gem 'devise-cloudflare-turnstile'
52
+ ```
53
+
54
+ Run bundle and the installer, which creates `config/initializers/cloudflare_turnstile.rb`:
55
+
56
+ ```bash
57
+ bundle install
58
+ bin/rails generate devise_cloudflare_turnstile:install
59
+ ```
60
+
61
+ Add these two helpers to your layout's `<head>` (e.g. `app/views/layouts/application.html.erb`):
62
+
63
+ ```erb
64
+ <%= devise_turnstile_meta_tag %>
65
+ <%= devise_turnstile_scripts %>
66
+ ```
67
+
68
+ Both render nothing on non-Devise pages, so they are safe to keep in a shared layout.
69
+
70
+ Set your Cloudflare Turnstile credentials via environment variables:
71
+
72
+ ```bash
73
+ export CLOUDFLARE_TURNSTILE_SITE_KEY=your_site_key
74
+ export CLOUDFLARE_TURNSTILE_SECRET_KEY=your_secret_key
75
+ ```
76
+
77
+ That's it — all Devise forms are now protected.
78
+
79
+ ### Getting API Keys
80
+
81
+ 1. Go to [Cloudflare Turnstile Dashboard](https://dash.cloudflare.com/turnstile)
82
+ 2. Create a new site
83
+ 3. Copy your Site Key and Secret Key
84
+
85
+ ### How It Works
86
+
87
+ * **Controller** — a `before_action` on every Devise controller verifies the Turnstile response on `create` and re-renders the form with an error if it fails.
88
+ * **View** — on Devise form pages, the two layout helpers emit the site key and load the injector JavaScript, which adds a `cf-turnstile` widget before each submit button.
89
+
90
+ Widget rendering, script loading, CSP nonces, and Turbo/Turbolinks handling are delegated to [cloudflare-turnstile-rails](https://github.com/vkononov/cloudflare-turnstile-rails).
91
+
92
+ ## Skipping Protection
93
+
94
+ Both the widget and server-side verification are applied per Devise action. You can disable them globally from the initializer or per controller.
95
+
96
+ ### Via configuration
97
+
98
+ Skip whole controllers or specific actions without creating custom controllers:
99
+
100
+ ```ruby
101
+ # config/initializers/cloudflare_turnstile.rb
102
+ Cloudflare::Turnstile::Rails.configure do |config|
103
+ # ...site_key, secret_key, etc...
104
+
105
+ config.skip :confirmations # every action
106
+ config.skip passwords: :create # a single action
107
+ config.skip unlocks: %i[new create] # a set of actions
108
+ end
109
+ ```
110
+
111
+ Controller keys match Devise's `controller_name` (`sessions`, `registrations`, `passwords`, `confirmations`, `unlocks`, `invitations`).
112
+
113
+ ### Via controller macro
114
+
115
+ If you already subclass a Devise controller, use the `skip_turnstile` macro:
116
+
117
+ ```ruby
118
+ # app/controllers/users/passwords_controller.rb
119
+ module Users
120
+ class PasswordsController < Devise::PasswordsController
121
+ skip_turnstile # every action
122
+ # skip_turnstile only: :create # a subset
123
+ # skip_turnstile except: :new # everything but a subset
124
+ end
125
+ end
126
+ ```
127
+
128
+ Then update your routes:
129
+
130
+ ```ruby
131
+ devise_for :users, controllers: { passwords: 'users/passwords' }
132
+ ```
133
+
134
+ Skipping an action suppresses both the widget injection and the server-side check for that action.
135
+
136
+ ## Customizing the Widget
137
+
138
+ Widget appearance (theme, language, size, and any other Cloudflare `data-*` option) is configured once in the initializer via the foundational gem's `config.default_data`. These defaults are applied to every auto-injected Devise widget:
139
+
140
+ ```ruby
141
+ # config/initializers/cloudflare_turnstile.rb
142
+ Cloudflare::Turnstile::Rails.configure do |config|
143
+ config.default_data = {
144
+ theme: 'dark',
145
+ language: -> { I18n.locale }
146
+ }
147
+ end
148
+ ```
149
+
150
+ To use invisible or managed widgets everywhere, configure the matching widget type on your Cloudflare site key — that choice is made in the Cloudflare dashboard, not in code.
151
+
152
+ For a single form that needs different options, render a widget yourself in a custom Devise view using `cloudflare_turnstile_tag`. The injector skips any form that already contains a `.cf-turnstile` element, so your manual widget wins:
153
+
154
+ ```erb
155
+ <%# app/views/users/sessions/new.html.erb %>
156
+ <%= cloudflare_turnstile_tag data: { theme: 'light' } %>
157
+ ```
158
+
159
+ ## Passing Extra Verification Parameters
160
+
161
+ To forward additional siteverify parameters (e.g. `remoteip`, `idempotency_key`) to Cloudflare, override `turnstile_verify_options` in a custom Devise controller:
162
+
163
+ ```ruby
164
+ module Users
165
+ class SessionsController < Devise::SessionsController
166
+ private
167
+
168
+ def turnstile_verify_options
169
+ { remoteip: request.remote_ip }
170
+ end
171
+ end
172
+ end
173
+ ```
174
+
175
+ ## How Protection Is Applied
176
+
177
+ | Situation | Behavior |
178
+ |-----------|----------|
179
+ | No custom controllers | All Devise controllers are protected automatically |
180
+ | Custom controller with `skip_turnstile` (or config `skip`) | That controller/action opts out of both widget and verification |
181
+ | No custom views | Devise's default views render; the widget still injects |
182
+ | Custom view without a widget | The widget is still injected before the submit button |
183
+ | Custom view with `cloudflare_turnstile_tag` | Your widget is used as-is; nothing is auto-injected |
184
+ | Invisible vs visible widget | Determined by the site key's widget type in the Cloudflare dashboard |
185
+
186
+ ## Automated Testing of Your Integration
187
+
188
+ Cloudflare provides dummy sitekeys and secret keys for development and testing. See the [cloudflare-turnstile-rails testing docs](https://github.com/vkononov/cloudflare-turnstile-rails#automated-testing-of-your-integration) for the full matrix.
189
+
190
+ | Key Type | Value | Behavior |
191
+ |----------|-------|----------|
192
+ | Site Key | `1x00000000000000000000AA` | Always passes (visible) |
193
+ | Secret Key | `1x0000000000000000000000000000000AA` | Always passes |
194
+ | Site Key | `2x00000000000000000000AB` | Always blocks (visible) |
195
+ | Secret Key | `2x0000000000000000000000000000000AA` | Always fails |
196
+
197
+ ## Development
198
+
199
+ ### Setup
200
+
201
+ Install Ruby and JavaScript dependencies in one step:
202
+
203
+ ```bash
204
+ bin/setup
205
+ npm install
206
+ ```
207
+
208
+ ### Running the Test Suite
209
+
210
+ [Appraisal](https://github.com/thoughtbot/appraisal) is used to run the test suite against multiple Rails versions:
211
+
212
+ ```bash
213
+ bundle exec appraisal install
214
+ bundle exec appraisal rake test
215
+ ```
216
+
217
+ > **CI Note:** The GitHub Action [.github/workflows/test.yml](.github/workflows/test.yml) runs this command across the supported Ruby matrix daily.
218
+
219
+ ### Code Linting
220
+
221
+ Run RuboCop and ESLint together:
222
+
223
+ ```bash
224
+ bin/lint
225
+ ```
226
+
227
+ > **CI Note:** We run this via [.github/workflows/lint.yml](.github/workflows/lint.yml) on the latest Ruby only.
228
+
229
+ ## Contributing
230
+
231
+ Bug reports and pull requests are welcome on GitHub at https://github.com/vkononov/devise-cloudflare-turnstile.
232
+
233
+ ## License
234
+
235
+ The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
@@ -0,0 +1,135 @@
1
+ (function() {
2
+ 'use strict';
3
+
4
+ var WIDGET_CLASS = 'cf-turnstile';
5
+
6
+ function siteKey() {
7
+ var meta = document.querySelector('meta[name="cf-turnstile-site-key"]');
8
+
9
+ return meta && meta.content ? meta.content : null;
10
+ }
11
+
12
+ function defaultData() {
13
+ var meta = document.querySelector('meta[name="cf-turnstile-default-data"]');
14
+
15
+ if (!meta || !meta.content) {
16
+ return {};
17
+ }
18
+
19
+ try {
20
+ return JSON.parse(meta.content) || {};
21
+ }
22
+ catch (e) {
23
+ // eslint-disable-next-line no-console
24
+ console.warn('devise-cloudflare-turnstile: invalid default data', e);
25
+
26
+ return {};
27
+ }
28
+ }
29
+
30
+ function applyDefaultData(widget) {
31
+ var data = defaultData();
32
+ var key;
33
+ var value;
34
+
35
+ for (key in data) {
36
+ if (Object.prototype.hasOwnProperty.call(data, key) && key !== 'sitekey') {
37
+ value = data[key];
38
+
39
+ if (value !== null && typeof value !== 'undefined') {
40
+ widget.setAttribute('data-' + key, value);
41
+ }
42
+ }
43
+ }
44
+ }
45
+
46
+ function renderWidget(widget) {
47
+ if (widget.dataset.initialized || widget.childElementCount > 0) {
48
+ return;
49
+ }
50
+
51
+ if (!window.turnstile) {
52
+ return;
53
+ }
54
+
55
+ try {
56
+ window.turnstile.render(widget);
57
+ widget.dataset.initialized = 'true';
58
+ }
59
+ catch (e) {
60
+ // eslint-disable-next-line no-console
61
+ console.warn('devise-cloudflare-turnstile: turnstile.render failed', e);
62
+ }
63
+ }
64
+
65
+ function injectWidgets() {
66
+ var key = siteKey();
67
+ var forms;
68
+ var form;
69
+ var widget;
70
+ var submit;
71
+ var i;
72
+
73
+ if (!key) {
74
+ return;
75
+ }
76
+
77
+ forms = document.querySelectorAll('form[method="post"], form[method="POST"]');
78
+
79
+ for (i = 0; i < forms.length; i++) {
80
+ form = forms[i];
81
+ widget = form.querySelector('.' + WIDGET_CLASS);
82
+
83
+ if (!widget) {
84
+ submit = form.querySelector('input[type="submit"], button[type="submit"], button:not([type])');
85
+
86
+ if (!submit) {
87
+ continue;
88
+ }
89
+
90
+ widget = document.createElement('div');
91
+ widget.className = WIDGET_CLASS;
92
+ applyDefaultData(widget);
93
+ widget.setAttribute('data-sitekey', key);
94
+ submit.parentNode.insertBefore(widget, submit);
95
+ }
96
+
97
+ /*
98
+ * Render here rather than waiting for cloudflare-turnstile-rails:
99
+ * on turbo:render the helper often runs before we inject the widget.
100
+ */
101
+ renderWidget(widget);
102
+ }
103
+ }
104
+
105
+ if (window._deviseTurnstileInjectorLoaded) {
106
+ injectWidgets();
107
+ }
108
+ else {
109
+ window._deviseTurnstileInjectorLoaded = true;
110
+
111
+ if (document.readyState === 'loading') {
112
+ document.addEventListener('DOMContentLoaded', injectWidgets);
113
+ }
114
+ else {
115
+ injectWidgets();
116
+ }
117
+
118
+ document.addEventListener('turbo:load', injectWidgets);
119
+ document.addEventListener('turbo:render', injectWidgets);
120
+ document.addEventListener('turbolinks:load', injectWidgets);
121
+
122
+ /*
123
+ * Same pattern as cloudflare-turnstile-rails: re-run after Turbo Streams
124
+ * replace DOM nodes (e.g. form re-renders).
125
+ */
126
+ document.addEventListener('turbo:before-stream-render', function(event) {
127
+ var originalRender = event.detail.render.bind(event.detail);
128
+
129
+ event.detail.render = function() {
130
+ originalRender.apply(this, arguments);
131
+ injectWidgets();
132
+ };
133
+ });
134
+ }
135
+ }());
@@ -0,0 +1,37 @@
1
+ require 'cloudflare/turnstile/rails'
2
+
3
+ # Adds a Devise-aware skip registry to cloudflare-turnstile-rails'
4
+ # configuration, so its configure block controls both the widget and which
5
+ # Devise controllers/actions to protect.
6
+ module Cloudflare
7
+ module Turnstile
8
+ module Rails
9
+ class Configuration
10
+ def skips
11
+ @skips ||= {}
12
+ end
13
+
14
+ # Registers Devise controllers/actions that should not be protected.
15
+ #
16
+ # config.skip :confirmations # every action
17
+ # config.skip passwords: :create # a single action
18
+ # config.skip unlocks: [:new, :create] # a set of actions
19
+ def skip(*controllers, **controller_actions)
20
+ controllers.each { |controller| skips[controller.to_s] = :all }
21
+
22
+ controller_actions.each do |controller, actions|
23
+ skips[controller.to_s] = Array(actions).map(&:to_s)
24
+ end
25
+ end
26
+
27
+ def skipped?(controller_name, action_name)
28
+ rule = skips[controller_name.to_s]
29
+ return false if rule.nil?
30
+ return true if rule == :all
31
+
32
+ rule.include?(action_name.to_s)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,86 @@
1
+ module Devise
2
+ module Cloudflare
3
+ module Turnstile
4
+ module ControllerConcern
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ # class_attribute's `default:` keyword arrived in Rails 5.2, so set it
9
+ # explicitly to keep Rails 5.0/5.1 compatibility.
10
+ class_attribute :_turnstile_skip_rules, instance_accessor: false
11
+ self._turnstile_skip_rules = []
12
+
13
+ before_action :verify_cloudflare_turnstile!, only: :create
14
+ before_action :set_turnstile_page_marker, if: :turnstile_form_action?
15
+ end
16
+
17
+ module ClassMethods
18
+ # Disables Turnstile for this controller.
19
+ #
20
+ # skip_turnstile # every action
21
+ # skip_turnstile only: :create # a subset
22
+ # skip_turnstile except: :new # everything but a subset
23
+ def skip_turnstile(only: nil, except: nil)
24
+ self._turnstile_skip_rules += [{
25
+ only: Array(only).map(&:to_s),
26
+ except: Array(except).map(&:to_s)
27
+ }]
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def verify_cloudflare_turnstile! # rubocop:disable Metrics/AbcSize
34
+ return if turnstile_skipped?
35
+
36
+ self.resource ||= resource_class.new
37
+ return if valid_turnstile?(model: resource, **turnstile_verify_options)
38
+
39
+ # Sessions (and some other Devise views) do not render resource errors.
40
+ # Always surface the failure via flash so the user sees feedback.
41
+ flash.now[:alert] = ::Cloudflare::Turnstile::Rails::ErrorMessage.default
42
+ clean_up_passwords(resource) if respond_to?(:clean_up_passwords, true)
43
+ set_minimum_password_length if respond_to?(:set_minimum_password_length, true)
44
+ set_turnstile_page_marker
45
+ render turnstile_failure_action, status: :unprocessable_entity
46
+ end
47
+
48
+ # Extra siteverify parameters forwarded to valid_turnstile?. Override in a
49
+ # custom Devise controller to add e.g. remoteip: request.remote_ip.
50
+ def turnstile_verify_options
51
+ {}
52
+ end
53
+
54
+ def set_turnstile_page_marker
55
+ @_devise_turnstile_protected = true
56
+ end
57
+
58
+ # Include create/update so failed submissions that re-render the form
59
+ # still emit the Turnstile meta tag and scripts. Without this, Turbo
60
+ # merges a head that omits them and the widget never comes back.
61
+ def turnstile_form_action?
62
+ return false if turnstile_skipped?
63
+
64
+ action_name.in?(%w[new edit create update])
65
+ end
66
+
67
+ def turnstile_skipped?
68
+ return true if ::Cloudflare::Turnstile::Rails.configuration.skipped?(controller_name, action_name)
69
+
70
+ self.class._turnstile_skip_rules.any? { |rule| turnstile_rule_matches?(rule) }
71
+ end
72
+
73
+ def turnstile_rule_matches?(rule)
74
+ return false if rule[:except].include?(action_name)
75
+ return true if rule[:only].empty?
76
+
77
+ rule[:only].include?(action_name)
78
+ end
79
+
80
+ def turnstile_failure_action
81
+ action_name.in?(%w[create]) ? :new : :edit
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,14 @@
1
+ module Devise
2
+ module Cloudflare
3
+ module Turnstile
4
+ class Engine < ::Rails::Engine
5
+ initializer 'devise_cloudflare_turnstile.assets' do |app|
6
+ next unless app.config.respond_to?(:assets)
7
+
8
+ app.config.assets.paths << Devise::Cloudflare::Turnstile::Engine.root.join('app/assets/javascripts')
9
+ app.config.assets.precompile += %w[devise_cloudflare_turnstile.js]
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,19 @@
1
+ module Devise
2
+ module Cloudflare
3
+ module Turnstile
4
+ class Railtie < ::Rails::Railtie
5
+ initializer 'devise_cloudflare_turnstile.controller_concern' do
6
+ ActiveSupport.on_load(:devise_controller) do
7
+ include Devise::Cloudflare::Turnstile::ControllerConcern
8
+ end
9
+ end
10
+
11
+ initializer 'devise_cloudflare_turnstile.view_helpers' do
12
+ ActiveSupport.on_load(:action_view) do
13
+ include Devise::Cloudflare::Turnstile::ViewHelpers
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,7 @@
1
+ module Devise
2
+ module Cloudflare
3
+ module Turnstile
4
+ VERSION = '0.9.0'.freeze
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,89 @@
1
+ require 'json'
2
+
3
+ module Devise
4
+ module Cloudflare
5
+ module Turnstile
6
+ module ViewHelpers
7
+ def devise_turnstile_meta_tag
8
+ return unless devise_turnstile_protected?
9
+
10
+ site_key = ::Cloudflare::Turnstile::Rails.configuration.site_key
11
+ return if site_key.nil? || site_key.empty?
12
+
13
+ # Use the positional tag(name, options) form for Rails 5.0 compatibility
14
+ # (tag.meta builder API arrived in Rails 5.1). Pass options as a Hash
15
+ # so Ruby 3 keyword separation does not break Rails 5.0's arity.
16
+ safe_join(
17
+ [
18
+ tag(:meta, { name: 'cf-turnstile-site-key', content: site_key }),
19
+ devise_turnstile_default_data_meta_tag
20
+ ].compact,
21
+ "\n"
22
+ )
23
+ end
24
+
25
+ def devise_turnstile_scripts
26
+ return unless devise_turnstile_protected?
27
+
28
+ nonce = content_security_policy_nonce if respond_to?(:content_security_policy_nonce)
29
+
30
+ safe_join(
31
+ [
32
+ cloudflare_turnstile_loader(nonce),
33
+ cloudflare_turnstile_injector(nonce)
34
+ ],
35
+ "\n"
36
+ )
37
+ end
38
+
39
+ private
40
+
41
+ def devise_turnstile_protected?
42
+ @_devise_turnstile_protected
43
+ end
44
+
45
+ # Emits the configured default data attributes so the injector can apply
46
+ # them to auto-injected widgets. Procs are evaluated at render time,
47
+ # matching cloudflare-turnstile-rails' cloudflare_turnstile_tag behavior.
48
+ def devise_turnstile_default_data_meta_tag
49
+ data = ::Cloudflare::Turnstile::Rails.configuration.default_data || {}
50
+ resolved = data.each_with_object({}) do |(key, value), memo|
51
+ memo[key] = value.respond_to?(:call) ? value.call : value
52
+ end
53
+ return if resolved.empty?
54
+
55
+ tag(:meta, { name: 'cf-turnstile-default-data', content: JSON.generate(resolved) })
56
+ end
57
+
58
+ # Loads the cloudflare-turnstile-rails runtime, which fetches the
59
+ # Cloudflare script and renders/re-renders every ".cf-turnstile" widget
60
+ # (including CSP nonce and Turbo handling).
61
+ def cloudflare_turnstile_loader(nonce)
62
+ # Pass options as a Hash so Ruby 3 keyword separation works with
63
+ # Rails 5.0's javascript_include_tag(*sources) signature.
64
+ javascript_include_tag(
65
+ 'cloudflare_turnstile_helper',
66
+ {
67
+ async: true,
68
+ defer: true,
69
+ nonce: nonce,
70
+ data: { 'script-url': ::Cloudflare::Turnstile::Rails.configuration.script_url }
71
+ }
72
+ )
73
+ end
74
+
75
+ # Injects a ".cf-turnstile" widget into every Devise form so no view
76
+ # changes are required; rendering is delegated to the runtime above.
77
+ def cloudflare_turnstile_injector(nonce)
78
+ javascript_include_tag(
79
+ 'devise_cloudflare_turnstile',
80
+ {
81
+ defer: true,
82
+ nonce: nonce
83
+ }
84
+ )
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,9 @@
1
+ require 'devise'
2
+ require 'cloudflare/turnstile/rails'
3
+
4
+ require_relative 'turnstile/version'
5
+ require_relative 'turnstile/configuration'
6
+ require_relative 'turnstile/controller_concern'
7
+ require_relative 'turnstile/view_helpers'
8
+ require_relative 'turnstile/engine'
9
+ require_relative 'turnstile/railtie'
@@ -0,0 +1,50 @@
1
+ require 'rails/generators'
2
+ require 'generators/cloudflare_turnstile/install_generator'
3
+
4
+ module DeviseCloudflareTurnstile
5
+ class InstallGenerator < Rails::Generators::Base
6
+ source_root File.expand_path('templates', __dir__)
7
+
8
+ INITIALIZER = 'config/initializers/cloudflare_turnstile.rb'.freeze
9
+
10
+ desc 'Creates the Cloudflare Turnstile initializer with Devise-specific options'
11
+
12
+ def check_dependencies
13
+ return if File.exist?(File.join(destination_root, 'config/initializers/devise.rb'))
14
+
15
+ say_status :error, 'Devise not installed. Run: rails generate devise:install', :red
16
+ raise SystemExit
17
+ end
18
+
19
+ def create_initializer
20
+ create_file INITIALIZER, combined_initializer
21
+ end
22
+
23
+ def show_layout_instructions
24
+ say "\nAdd these helpers to your layout's <head> (e.g. app/views/layouts/application.html.erb):", :green
25
+ say <<~ERB
26
+
27
+ <%= devise_turnstile_meta_tag %>
28
+ <%= devise_turnstile_scripts %>
29
+ ERB
30
+ end
31
+
32
+ private
33
+
34
+ # Reuses cloudflare-turnstile-rails' initializer verbatim (single source of
35
+ # truth) and injects our Devise-specific options into its config block,
36
+ # indented to match. Add new options by editing templates/devise_options.rb.
37
+ def combined_initializer
38
+ foundational_initializer.sub(/\nend\s*\z/, "\n\n#{devise_options}end\n")
39
+ end
40
+
41
+ def foundational_initializer
42
+ template = File.expand_path('cloudflare_turnstile.rb', CloudflareTurnstile::Generators::InstallGenerator.source_root)
43
+ File.read(template)
44
+ end
45
+
46
+ def devise_options
47
+ File.read(File.expand_path('devise_options.rb', self.class.source_root)).gsub(/^(?=.)/, ' ')
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,4 @@
1
+ # Disable Turnstile for specific Devise controllers or actions.
2
+ # config.skip :confirmations # every action
3
+ # config.skip passwords: :create # a single action
4
+ # config.skip unlocks: [:new, :create] # a set of actions
metadata ADDED
@@ -0,0 +1,85 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: devise-cloudflare-turnstile
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.9.0
5
+ platform: ruby
6
+ authors:
7
+ - Vadim Kononov
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: cloudflare-turnstile-rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '1.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: devise
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '4.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '4.0'
40
+ description: Automatically protect Devise authentication forms with Cloudflare Turnstile,
41
+ without changing your Devise views or controllers.
42
+ email:
43
+ - vadim@konoson.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - LICENSE.txt
49
+ - README.md
50
+ - app/assets/javascripts/devise_cloudflare_turnstile.js
51
+ - lib/devise/cloudflare/turnstile.rb
52
+ - lib/devise/cloudflare/turnstile/configuration.rb
53
+ - lib/devise/cloudflare/turnstile/controller_concern.rb
54
+ - lib/devise/cloudflare/turnstile/engine.rb
55
+ - lib/devise/cloudflare/turnstile/railtie.rb
56
+ - lib/devise/cloudflare/turnstile/version.rb
57
+ - lib/devise/cloudflare/turnstile/view_helpers.rb
58
+ - lib/generators/devise_cloudflare_turnstile/install_generator.rb
59
+ - lib/generators/devise_cloudflare_turnstile/templates/devise_options.rb
60
+ homepage: https://github.com/vkononov/devise-cloudflare-turnstile
61
+ licenses:
62
+ - MIT
63
+ metadata:
64
+ homepage_uri: https://github.com/vkononov/devise-cloudflare-turnstile
65
+ source_code_uri: https://github.com/vkononov/devise-cloudflare-turnstile
66
+ changelog_uri: https://github.com/vkononov/devise-cloudflare-turnstile/releases
67
+ rubygems_mfa_required: 'true'
68
+ rdoc_options: []
69
+ require_paths:
70
+ - lib
71
+ required_ruby_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: 2.6.0
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ requirements: []
82
+ rubygems_version: 4.0.10
83
+ specification_version: 4
84
+ summary: Cloudflare Turnstile integration for Devise
85
+ test_files: []