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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +235 -0
- data/app/assets/javascripts/devise_cloudflare_turnstile.js +135 -0
- data/lib/devise/cloudflare/turnstile/configuration.rb +37 -0
- data/lib/devise/cloudflare/turnstile/controller_concern.rb +86 -0
- data/lib/devise/cloudflare/turnstile/engine.rb +14 -0
- data/lib/devise/cloudflare/turnstile/railtie.rb +19 -0
- data/lib/devise/cloudflare/turnstile/version.rb +7 -0
- data/lib/devise/cloudflare/turnstile/view_helpers.rb +89 -0
- data/lib/devise/cloudflare/turnstile.rb +9 -0
- data/lib/generators/devise_cloudflare_turnstile/install_generator.rb +50 -0
- data/lib/generators/devise_cloudflare_turnstile/templates/devise_options.rb +4 -0
- metadata +85 -0
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
|
+
[](https://rubygems.org/gems/devise-cloudflare-turnstile)
|
|
4
|
+
[](https://github.com/vkononov/devise-cloudflare-turnstile/blob/main/.github/workflows/test.yml)
|
|
5
|
+
[](https://github.com/vkononov/devise-cloudflare-turnstile/blob/main/Appraisals)
|
|
6
|
+
[](https://github.com/vkononov/devise-cloudflare-turnstile/actions/workflows/test.yml)
|
|
7
|
+
[](https://github.com/vkononov/devise-cloudflare-turnstile/actions/workflows/lint.yml)
|
|
8
|
+
[](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
|
+
[](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,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
|
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: []
|