turnstile-ruby 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +41 -0
- data/LICENSE.txt +21 -0
- data/README.md +165 -52
- data/lib/turnstile/adapters/controller_methods.rb +33 -31
- data/lib/turnstile/adapters/view_methods.rb +14 -4
- data/lib/turnstile/configuration.rb +18 -12
- data/lib/turnstile/helper.js +60 -0
- data/lib/turnstile/helpers.rb +105 -98
- data/lib/turnstile/rails.rb +2 -2
- data/lib/turnstile/railtie.rb +3 -3
- data/lib/turnstile/response.rb +53 -0
- data/lib/turnstile/testing.rb +50 -0
- data/lib/turnstile/version.rb +1 -1
- data/lib/turnstile-ruby.rb +4 -0
- data/lib/turnstile.rb +80 -29
- data/sig/turnstile.rbs +68 -0
- metadata +23 -151
data/lib/turnstile/helpers.rb
CHANGED
|
@@ -1,139 +1,146 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "erb"
|
|
4
|
+
|
|
3
5
|
module Turnstile
|
|
4
6
|
module Helpers
|
|
5
7
|
DEFAULT_ERRORS = {
|
|
6
|
-
turnstile_unreachable:
|
|
7
|
-
verification_failed:
|
|
8
|
+
turnstile_unreachable: "Oops, we failed to validate your Turnstile response. Please try again.",
|
|
9
|
+
verification_failed: "Turnstile verification failed, please try again."
|
|
8
10
|
}.freeze
|
|
9
11
|
|
|
12
|
+
# Options rendered as data-* attributes on the widget, see
|
|
13
|
+
# https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/widget-configurations/
|
|
14
|
+
WIDGET_OPTIONS = %i[
|
|
15
|
+
action cdata callback error_callback expired_callback timeout_callback before_interactive_callback
|
|
16
|
+
after_interactive_callback unsupported_callback theme language tabindex response_field response_field_name
|
|
17
|
+
size retry retry_interval refresh_expired refresh_timeout appearance execution feedback_enabled
|
|
18
|
+
offlabel_show_privacy offlabel_show_help
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
# The only widget options where +false+ is a value, everywhere else it means "leave it out".
|
|
22
|
+
BOOLEAN_OPTIONS = %i[response_field feedback_enabled offlabel_show_privacy offlabel_show_help].freeze
|
|
23
|
+
|
|
24
|
+
# Options that control the helper itself and never end up as HTML attributes.
|
|
25
|
+
SETTINGS = %i[
|
|
26
|
+
env site_key sitekey hl onload render script_async script_defer script external_script inline_script
|
|
27
|
+
nonce turbo ui text
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
HELPER_SCRIPT = File.read(File.expand_path("helper.js", __dir__)).gsub(%r{^\s*//.*\n}, "").gsub(/^\s+/, "").freeze
|
|
31
|
+
|
|
10
32
|
def self.to_error_message(key)
|
|
11
|
-
default = DEFAULT_ERRORS.fetch(key) { raise ArgumentError "Unknown Turnstile captcha error - #{key}" }
|
|
33
|
+
default = DEFAULT_ERRORS.fetch(key) { raise ArgumentError, "Unknown Turnstile captcha error - #{key}" }
|
|
12
34
|
to_message("turnstile.errors.#{key}", default)
|
|
13
35
|
end
|
|
14
36
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
I18n.translate(key, default: default)
|
|
18
|
-
end
|
|
19
|
-
else
|
|
20
|
-
def self.to_message(_key, default)
|
|
21
|
-
default
|
|
22
|
-
end
|
|
37
|
+
def self.to_message(key, default)
|
|
38
|
+
defined?(I18n) ? I18n.translate(key, default: default) : default
|
|
23
39
|
end
|
|
24
40
|
|
|
25
|
-
def self.turnstile_tags(options)
|
|
41
|
+
def self.turnstile_tags(options = {})
|
|
26
42
|
if options.key?(:ssl)
|
|
27
43
|
raise(TurnstileError, "SSL is now always true. Please remove 'ssl' from your calls to turnstile_tags.")
|
|
28
44
|
end
|
|
29
45
|
|
|
30
|
-
|
|
31
|
-
|
|
46
|
+
options = options.dup
|
|
47
|
+
settings = extract(options, SETTINGS)
|
|
48
|
+
attributes = data_attributes(options, settings)
|
|
49
|
+
attributes["class"] = ["cf-turnstile", options.delete(:class)].compact.join(" ")
|
|
32
50
|
|
|
33
|
-
html
|
|
51
|
+
html = script_tags(settings, helper: turbo?(settings) && settings[:render].to_s != "explicit")
|
|
52
|
+
html << %(<div #{tag_attributes(attributes.merge(options))}></div>\n)
|
|
53
|
+
safe(html)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Renders a submit button that runs the challenge when the form is submitted and only then submits it.
|
|
57
|
+
def self.invisible_turnstile_tags(custom = {})
|
|
58
|
+
options = { ui: :button, appearance: "interaction-only" }.merge(custom)
|
|
59
|
+
settings = extract(options, SETTINGS)
|
|
60
|
+
tabindex = options.delete(:tabindex)
|
|
61
|
+
widget = data_attributes(options, settings).merge("data-turnstile-invisible" => true)
|
|
62
|
+
options[:tabindex] = tabindex if tabindex
|
|
63
|
+
|
|
64
|
+
html = script_tags(settings, helper: true)
|
|
65
|
+
html << %(<div #{tag_attributes(widget)}></div>\n)
|
|
66
|
+
html << submit_tag(settings, options)
|
|
67
|
+
safe(html)
|
|
34
68
|
end
|
|
35
69
|
|
|
36
|
-
def self.
|
|
37
|
-
|
|
38
|
-
text = options.delete(:text)
|
|
39
|
-
html, tag_attributes = components(options.dup)
|
|
40
|
-
html << default_callback(options) if default_callback_required?(options)
|
|
70
|
+
private_class_method def self.submit_tag(settings, attributes)
|
|
71
|
+
text = escape(settings[:text])
|
|
41
72
|
|
|
42
|
-
case
|
|
73
|
+
case settings[:ui]
|
|
43
74
|
when :button
|
|
44
|
-
|
|
75
|
+
%(<button #{tag_attributes({ "type" => "submit" }.merge(attributes))}>#{text}</button>\n)
|
|
45
76
|
when :input
|
|
46
|
-
|
|
77
|
+
%(<input #{tag_attributes({ "type" => "submit" }.merge(attributes))} value="#{text}"/>\n)
|
|
47
78
|
else
|
|
48
|
-
raise(TurnstileError, "Turnstile ui `#{
|
|
79
|
+
raise(TurnstileError, "Turnstile ui `#{settings[:ui]}` is not valid.")
|
|
49
80
|
end
|
|
50
|
-
html.respond_to?(:html_safe) ? html.html_safe : html
|
|
51
81
|
end
|
|
52
82
|
|
|
53
|
-
private_class_method def self.
|
|
54
|
-
|
|
55
|
-
|
|
83
|
+
private_class_method def self.data_attributes(options, settings)
|
|
84
|
+
options[:cdata] = options.delete(:cData) if options.key?(:cData)
|
|
85
|
+
options[:language] ||= settings[:hl] if settings[:hl]
|
|
86
|
+
values = extract(options, WIDGET_OPTIONS)
|
|
87
|
+
return {} if Turnstile.skip_env?(settings[:env])
|
|
56
88
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
site_key = options.delete(:site_key)
|
|
61
|
-
hl = options.delete(:hl)
|
|
62
|
-
onload = options.delete(:onload)
|
|
63
|
-
render = options.delete(:render)
|
|
64
|
-
script_async = options.delete(:script_async)
|
|
65
|
-
script_defer = options.delete(:script_defer)
|
|
66
|
-
skip_script = (options.delete(:script) == false) || (options.delete(:external_script) == false)
|
|
67
|
-
ui = options.delete(:ui)
|
|
68
|
-
|
|
69
|
-
data_attribute_keys = %i[sitekey action cData callback expired_callback
|
|
70
|
-
timeout_callback error_callback theme language response_field
|
|
71
|
-
response_field_name size retry retry_interval refresh_expired]
|
|
72
|
-
# data_attribute_keys << :tabindex unless ui == :button # TODO:: Remove me
|
|
73
|
-
data_attributes = {}
|
|
74
|
-
data_attribute_keys.each do |data_attribute|
|
|
75
|
-
value = options.delete(data_attribute)
|
|
76
|
-
data_attributes["data-#{data_attribute.to_s.tr('_', '-')}"] = value if value
|
|
77
|
-
end
|
|
89
|
+
site_key = settings[:site_key] || settings[:sitekey] || Turnstile.configuration.site_key!
|
|
90
|
+
values.each_with_object("data-sitekey" => site_key) do |(key, value), attributes|
|
|
91
|
+
next if value.nil? || (value == false && !BOOLEAN_OPTIONS.include?(key))
|
|
78
92
|
|
|
79
|
-
|
|
80
|
-
site_key ||= Turnstile.configuration.site_key!
|
|
81
|
-
script_url = Turnstile.configuration.api_server_url
|
|
82
|
-
query_params = hash_to_query(
|
|
83
|
-
hl: hl,
|
|
84
|
-
onload: onload,
|
|
85
|
-
render: render
|
|
86
|
-
)
|
|
87
|
-
script_url += "?#{query_params}" unless query_params.empty?
|
|
88
|
-
async_attr = "async" if script_async != false
|
|
89
|
-
defer_attr = "defer" if script_defer != false
|
|
90
|
-
html << %(<script src="#{script_url}" #{async_attr} #{defer_attr}></script>\n) unless skip_script
|
|
91
|
-
attributes["data-sitekey"] = site_key
|
|
92
|
-
attributes.merge! data_attributes
|
|
93
|
+
attributes["data-#{key.to_s.tr("_", "-")}"] = value.to_s
|
|
93
94
|
end
|
|
95
|
+
end
|
|
94
96
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
97
|
+
private_class_method def self.script_tags(settings, helper:)
|
|
98
|
+
html = +""
|
|
99
|
+
return html if Turnstile.skip_env?(settings[:env]) || settings[:script] == false
|
|
98
100
|
|
|
99
|
-
|
|
101
|
+
html << api_script_tag(settings) unless settings[:external_script] == false
|
|
102
|
+
html << helper_script_tag(settings) if helper && settings[:inline_script] != false
|
|
103
|
+
html
|
|
100
104
|
end
|
|
101
105
|
|
|
102
|
-
private_class_method def self.
|
|
103
|
-
|
|
106
|
+
private_class_method def self.api_script_tag(settings)
|
|
107
|
+
query = { onload: settings[:onload], render: settings[:render] }.reject { |_, value| value.to_s.empty? }
|
|
108
|
+
url = Turnstile.configuration.api_server_url
|
|
109
|
+
url += "?#{URI.encode_www_form(query)}" unless query.empty?
|
|
110
|
+
|
|
111
|
+
attributes = { "src" => url }
|
|
112
|
+
attributes["async"] = true unless settings[:script_async] == false
|
|
113
|
+
attributes["defer"] = true unless settings[:script_defer] == false
|
|
114
|
+
attributes["nonce"] = settings[:nonce] || nil
|
|
115
|
+
|
|
116
|
+
%(<script #{tag_attributes(attributes)}></script>\n)
|
|
104
117
|
end
|
|
105
118
|
|
|
106
|
-
private_class_method def self.
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
options[:script] != false &&
|
|
110
|
-
options[:inline_script] != false
|
|
119
|
+
private_class_method def self.helper_script_tag(settings)
|
|
120
|
+
nonce = settings[:nonce] ? %( nonce="#{escape(settings[:nonce])}") : ""
|
|
121
|
+
%(<script#{nonce}>#{HELPER_SCRIPT}</script>\n)
|
|
111
122
|
end
|
|
112
123
|
|
|
113
|
-
private_class_method def self.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
};
|
|
135
|
-
</script>
|
|
136
|
-
HTML
|
|
124
|
+
private_class_method def self.turbo?(settings)
|
|
125
|
+
settings.fetch(:turbo) { Turnstile.configuration.turbo } != false
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private_class_method def self.extract(options, keys)
|
|
129
|
+
keys.each_with_object({}) { |key, found| found[key] = options.delete(key) if options.key?(key) }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private_class_method def self.tag_attributes(attributes)
|
|
133
|
+
attributes.reject { |_, value| value.nil? }.map do |name, value|
|
|
134
|
+
value == true ? escape(name) : %(#{escape(name)}="#{escape(value)}")
|
|
135
|
+
end.join(" ")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private_class_method def self.escape(value)
|
|
139
|
+
ERB::Util.html_escape(value.to_s)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private_class_method def self.safe(html)
|
|
143
|
+
html.respond_to?(:html_safe) ? html.html_safe : html
|
|
137
144
|
end
|
|
138
145
|
end
|
|
139
146
|
end
|
data/lib/turnstile/rails.rb
CHANGED
data/lib/turnstile/railtie.rb
CHANGED
|
@@ -10,7 +10,7 @@ module Turnstile
|
|
|
10
10
|
include Turnstile::Adapters::ControllerMethods
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
initializer
|
|
13
|
+
initializer "turnstile" do |app|
|
|
14
14
|
Turnstile::Railtie.instance_eval do
|
|
15
15
|
pattern = pattern_from app.config.i18n.available_locales
|
|
16
16
|
|
|
@@ -22,13 +22,13 @@ module Turnstile
|
|
|
22
22
|
protected
|
|
23
23
|
|
|
24
24
|
def add(pattern)
|
|
25
|
-
files = Dir[File.join(File.dirname(__FILE__),
|
|
25
|
+
files = Dir[File.join(File.dirname(__FILE__), "../..", pattern)]
|
|
26
26
|
I18n.load_path.concat(files)
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def pattern_from(args)
|
|
30
30
|
array = Array(args || [])
|
|
31
|
-
array.blank? ?
|
|
31
|
+
array.blank? ? "*" : "{#{array.join ","}}"
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Turnstile
|
|
4
|
+
# The outcome of a token verification, wrapping Cloudflare's siteverify reply.
|
|
5
|
+
#
|
|
6
|
+
# result = Turnstile.verify(token)
|
|
7
|
+
# result.success? # => false
|
|
8
|
+
# result.error_codes # => ["invalid-input-response"]
|
|
9
|
+
# result.hostname # => "example.com"
|
|
10
|
+
#
|
|
11
|
+
class Response
|
|
12
|
+
def initialize(reply, extra_errors: [])
|
|
13
|
+
@reply = reply
|
|
14
|
+
@extra_errors = extra_errors
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def success?
|
|
18
|
+
@reply["success"].to_s == "true" && @extra_errors.empty?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Cloudflare's error codes, plus "hostname-mismatch" / "action-mismatch" when the
|
|
22
|
+
# reply does not match the hostname or action you asked to validate.
|
|
23
|
+
def error_codes
|
|
24
|
+
Array(@reply["error-codes"]) + @extra_errors
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def hostname
|
|
28
|
+
@reply["hostname"]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def action
|
|
32
|
+
@reply["action"]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def cdata
|
|
36
|
+
@reply["cdata"]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# ISO 8601 timestamp of the challenge, as sent by Cloudflare.
|
|
40
|
+
def challenge_ts
|
|
41
|
+
@reply["challenge_ts"]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def metadata
|
|
45
|
+
@reply["metadata"] || {}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The raw siteverify reply.
|
|
49
|
+
def to_h
|
|
50
|
+
@reply
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "turnstile"
|
|
4
|
+
|
|
5
|
+
module Turnstile
|
|
6
|
+
# Helpers for your own test suite. Not loaded by default:
|
|
7
|
+
#
|
|
8
|
+
# require "turnstile/testing"
|
|
9
|
+
#
|
|
10
|
+
# Turnstile::Testing.failure! # every verification fails
|
|
11
|
+
# Turnstile::Testing.success! # every verification passes
|
|
12
|
+
# Turnstile::Testing.reset! # back to normal
|
|
13
|
+
# Turnstile::Testing.with_failure { post signup_path } # block form, resets afterwards
|
|
14
|
+
#
|
|
15
|
+
# While a stub is active no request is sent to Cloudflare, and `verify_turnstile`
|
|
16
|
+
# uses the stub even in environments listed in `skip_verify_env`.
|
|
17
|
+
module Testing
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def success!(reply = {})
|
|
21
|
+
Turnstile.stubbed_response = Response.new({ "success" => true, "error-codes" => [] }.merge(reply))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def failure!(*error_codes)
|
|
25
|
+
error_codes = ["invalid-input-response"] if error_codes.empty?
|
|
26
|
+
Turnstile.stubbed_response = Response.new({ "success" => false, "error-codes" => error_codes.flatten })
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def reset!
|
|
30
|
+
Turnstile.stubbed_response = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def with_success(reply = {}, &block)
|
|
34
|
+
with_stub(block) { success!(reply) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def with_failure(*error_codes, &block)
|
|
38
|
+
with_stub(block) { failure!(*error_codes) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def with_stub(block)
|
|
42
|
+
previous = Turnstile.stubbed_response
|
|
43
|
+
yield
|
|
44
|
+
block.call
|
|
45
|
+
ensure
|
|
46
|
+
Turnstile.stubbed_response = previous
|
|
47
|
+
end
|
|
48
|
+
private_class_method :with_stub
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/turnstile/version.rb
CHANGED
data/lib/turnstile.rb
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
6
|
|
|
7
|
-
require
|
|
8
|
-
require
|
|
9
|
-
require
|
|
10
|
-
require
|
|
7
|
+
require "turnstile/version"
|
|
8
|
+
require "turnstile/configuration"
|
|
9
|
+
require "turnstile/response"
|
|
10
|
+
require "turnstile/helpers"
|
|
11
|
+
require "turnstile/adapters/controller_methods"
|
|
12
|
+
require "turnstile/adapters/view_methods"
|
|
11
13
|
|
|
12
|
-
if defined?(Rails)
|
|
13
|
-
require 'turnstile/railtie'
|
|
14
|
-
end
|
|
14
|
+
require "turnstile/railtie" if defined?(Rails::Railtie)
|
|
15
15
|
|
|
16
16
|
module Turnstile
|
|
17
17
|
DEFAULT_TIMEOUT = 3
|
|
@@ -22,6 +22,16 @@ module Turnstile
|
|
|
22
22
|
class VerifyError < TurnstileError
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# Raised when Cloudflare answers with something that is not a siteverify reply (a 5xx page, an empty body, ...).
|
|
26
|
+
class UnreachableError < TurnstileError
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# A Turnstile::Response returned by every verification instead of calling Cloudflare.
|
|
31
|
+
# Set through Turnstile::Testing (require "turnstile/testing").
|
|
32
|
+
attr_accessor :stubbed_response
|
|
33
|
+
end
|
|
34
|
+
|
|
25
35
|
# Gives access to the current Configuration.
|
|
26
36
|
def self.configuration
|
|
27
37
|
@configuration ||= Configuration.new
|
|
@@ -29,10 +39,6 @@ module Turnstile
|
|
|
29
39
|
|
|
30
40
|
# Allows easy setting of multiple configuration options. See Configuration
|
|
31
41
|
# for all available options.
|
|
32
|
-
#--
|
|
33
|
-
# The temp assignment is only used to get a nicer rdoc. Feel free to remove
|
|
34
|
-
# this hack.
|
|
35
|
-
#++
|
|
36
42
|
def self.configure
|
|
37
43
|
config = configuration
|
|
38
44
|
yield(config)
|
|
@@ -51,28 +57,50 @@ module Turnstile
|
|
|
51
57
|
original_config.each { |key, value| configuration.send("#{key}=", value) }
|
|
52
58
|
end
|
|
53
59
|
|
|
60
|
+
def self.site_key
|
|
61
|
+
configuration.site_key
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.secret_key
|
|
65
|
+
configuration.secret_key
|
|
66
|
+
end
|
|
67
|
+
|
|
54
68
|
def self.skip_env?(env)
|
|
55
69
|
configuration.skip_verify_env.include?(env || configuration.default_env)
|
|
56
70
|
end
|
|
57
71
|
|
|
58
72
|
def self.invalid_response?(resp)
|
|
59
|
-
resp.empty? || resp.length > configuration.response_limit
|
|
73
|
+
!resp.is_a?(String) || resp.empty? || resp.length > configuration.response_limit
|
|
60
74
|
end
|
|
61
75
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
76
|
+
# Verifies a token with Cloudflare and returns a Turnstile::Response.
|
|
77
|
+
#
|
|
78
|
+
# result = Turnstile.verify(params["cf-turnstile-response"], remote_ip: request.remote_ip)
|
|
79
|
+
# result.success? # => true / false
|
|
80
|
+
# result.error_codes # => ["timeout-or-duplicate"]
|
|
81
|
+
#
|
|
82
|
+
# Options: +secret_key+, +remote_ip+, +idempotency_key+, +timeout+, +hostname+ and +action+.
|
|
83
|
+
# A missing or oversized token fails without a network call. Network timeouts raise Timeout::Error and
|
|
84
|
+
# unusable replies raise Turnstile::UnreachableError.
|
|
85
|
+
def self.verify(token, options = {})
|
|
86
|
+
return stubbed_response if stubbed_response
|
|
87
|
+
return failure(missing_token_code(token)) if invalid_response?(token)
|
|
88
|
+
|
|
89
|
+
reply = api_verification(verify_params(token, options), timeout: options[:timeout])
|
|
90
|
+
errors = []
|
|
91
|
+
errors << "hostname-mismatch" unless hostname_valid?(reply["hostname"], options[:hostname])
|
|
92
|
+
errors << "action-mismatch" unless action_valid?(reply["action"], options[:action])
|
|
93
|
+
Response.new(reply, extra_errors: errors)
|
|
94
|
+
end
|
|
66
95
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
action_valid?(reply['action'], options[:action])
|
|
96
|
+
# Kept for backwards compatibility, prefer Turnstile.verify.
|
|
97
|
+
def self.verify_via_api_call(response, options)
|
|
98
|
+
result = verify(response, options)
|
|
71
99
|
|
|
72
100
|
if options[:with_reply] == true
|
|
73
|
-
[success
|
|
101
|
+
[result.success?, result.to_h]
|
|
74
102
|
else
|
|
75
|
-
success
|
|
103
|
+
result.success?
|
|
76
104
|
end
|
|
77
105
|
end
|
|
78
106
|
|
|
@@ -89,12 +117,12 @@ module Turnstile
|
|
|
89
117
|
def self.action_valid?(action, expected_action)
|
|
90
118
|
case expected_action
|
|
91
119
|
when nil, FalseClass then true
|
|
92
|
-
else action == expected_action
|
|
120
|
+
else action == expected_action.to_s
|
|
93
121
|
end
|
|
94
122
|
end
|
|
95
123
|
|
|
96
124
|
def self.http_client_for(uri:, timeout: nil)
|
|
97
|
-
timeout ||= DEFAULT_TIMEOUT
|
|
125
|
+
timeout ||= configuration.timeout || DEFAULT_TIMEOUT
|
|
98
126
|
http = if configuration.proxy
|
|
99
127
|
proxy_server = URI.parse(configuration.proxy)
|
|
100
128
|
Net::HTTP::Proxy(proxy_server.host, proxy_server.port, proxy_server.user, proxy_server.password)
|
|
@@ -110,9 +138,32 @@ module Turnstile
|
|
|
110
138
|
|
|
111
139
|
def self.api_verification(verify_hash, timeout: nil)
|
|
112
140
|
uri = URI.parse(configuration.verify_url)
|
|
113
|
-
http_instance = http_client_for(uri: uri, timeout:
|
|
141
|
+
http_instance = http_client_for(uri: uri, timeout: timeout)
|
|
114
142
|
request = Net::HTTP::Post.new(uri.request_uri)
|
|
115
143
|
request.set_form_data verify_hash
|
|
116
|
-
JSON.parse(http_instance.request(request).body)
|
|
144
|
+
reply = JSON.parse(http_instance.request(request).body.to_s)
|
|
145
|
+
raise UnreachableError, "Unexpected reply from Turnstile: #{reply.inspect}" unless reply.is_a?(Hash)
|
|
146
|
+
|
|
147
|
+
reply
|
|
148
|
+
rescue JSON::ParserError
|
|
149
|
+
raise UnreachableError, "Turnstile did not return a JSON reply."
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def self.verify_params(token, options)
|
|
153
|
+
params = { "secret" => options.fetch(:secret_key) { configuration.secret_key! }, "response" => token }
|
|
154
|
+
params["remoteip"] = options[:remote_ip].to_s if options[:remote_ip]
|
|
155
|
+
params["idempotency_key"] = options[:idempotency_key].to_s if options[:idempotency_key]
|
|
156
|
+
params
|
|
157
|
+
end
|
|
158
|
+
private_class_method :verify_params
|
|
159
|
+
|
|
160
|
+
def self.failure(error_code)
|
|
161
|
+
Response.new({ "success" => false, "error-codes" => [error_code] })
|
|
162
|
+
end
|
|
163
|
+
private_class_method :failure
|
|
164
|
+
|
|
165
|
+
def self.missing_token_code(token)
|
|
166
|
+
token.nil? || token == "" ? "missing-input-response" : "invalid-input-response"
|
|
117
167
|
end
|
|
168
|
+
private_class_method :missing_token_code
|
|
118
169
|
end
|
data/sig/turnstile.rbs
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
module Turnstile
|
|
2
|
+
VERSION: String
|
|
3
|
+
DEFAULT_TIMEOUT: Integer
|
|
4
|
+
|
|
5
|
+
class TurnstileError < StandardError
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
class VerifyError < TurnstileError
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class UnreachableError < TurnstileError
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
attr_accessor self.stubbed_response: Response?
|
|
15
|
+
|
|
16
|
+
def self.configuration: () -> Configuration
|
|
17
|
+
def self.configure: () { (Configuration) -> void } -> void
|
|
18
|
+
def self.with_configuration: (Hash[Symbol, untyped] config) ?{ () -> void } -> void
|
|
19
|
+
def self.site_key: () -> String?
|
|
20
|
+
def self.secret_key: () -> String?
|
|
21
|
+
def self.skip_env?: (String? env) -> bool
|
|
22
|
+
def self.verify: (untyped token, ?Hash[Symbol, untyped] options) -> Response
|
|
23
|
+
def self.verify_via_api_call: (untyped response, Hash[Symbol, untyped] options) -> (bool | [bool, Hash[String, untyped]])
|
|
24
|
+
|
|
25
|
+
class Configuration
|
|
26
|
+
attr_accessor default_env: String?
|
|
27
|
+
attr_accessor skip_verify_env: Array[String]
|
|
28
|
+
attr_accessor proxy: String?
|
|
29
|
+
attr_accessor secret_key: String?
|
|
30
|
+
attr_accessor site_key: String?
|
|
31
|
+
attr_accessor handle_timeouts_gracefully: bool
|
|
32
|
+
attr_accessor hostname: (String | ^(String?) -> boolish)?
|
|
33
|
+
attr_accessor response_limit: Integer
|
|
34
|
+
attr_accessor timeout: Numeric
|
|
35
|
+
attr_accessor turbo: bool
|
|
36
|
+
attr_accessor api_server_url: String
|
|
37
|
+
attr_accessor verify_url: String
|
|
38
|
+
|
|
39
|
+
def secret_key!: () -> String
|
|
40
|
+
def site_key!: () -> String
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class Response
|
|
44
|
+
def initialize: (Hash[String, untyped] reply, ?extra_errors: Array[String]) -> void
|
|
45
|
+
def success?: () -> bool
|
|
46
|
+
def error_codes: () -> Array[String]
|
|
47
|
+
def hostname: () -> String?
|
|
48
|
+
def action: () -> String?
|
|
49
|
+
def cdata: () -> String?
|
|
50
|
+
def challenge_ts: () -> String?
|
|
51
|
+
def metadata: () -> Hash[String, untyped]
|
|
52
|
+
def to_h: () -> Hash[String, untyped]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
module Helpers
|
|
56
|
+
def self.turnstile_tags: (?Hash[Symbol, untyped] options) -> String
|
|
57
|
+
def self.invisible_turnstile_tags: (?Hash[Symbol, untyped] options) -> String
|
|
58
|
+
def self.to_error_message: (Symbol key) -> String
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
module Testing
|
|
62
|
+
def self.success!: (?Hash[String, untyped] reply) -> Response
|
|
63
|
+
def self.failure!: (*String error_codes) -> Response
|
|
64
|
+
def self.reset!: () -> nil
|
|
65
|
+
def self.with_success: (?Hash[String, untyped] reply) { () -> untyped } -> untyped
|
|
66
|
+
def self.with_failure: (*String error_codes) { () -> untyped } -> untyped
|
|
67
|
+
end
|
|
68
|
+
end
|