add_auth 0.2.1
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/CHANGELOG.md +125 -0
- data/CODE_OF_CONDUCT.md +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +545 -0
- data/ROADMAP.md +244 -0
- data/SECURITY.md +75 -0
- data/app/controllers/add_auth/assets_controller.rb +54 -0
- data/app/controllers/add_auth/passkeys_controller.rb +144 -0
- data/app/controllers/add_auth/reauthentications_controller.rb +81 -0
- data/app/controllers/add_auth/recoveries_controller.rb +50 -0
- data/app/controllers/add_auth/sessions_controller.rb +62 -0
- data/app/controllers/add_auth/sign_ins_controller.rb +65 -0
- data/app/helpers/add_auth/sign_ins_helper.rb +42 -0
- data/app/javascript/controllers/.keep +0 -0
- data/app/jobs/add_auth/delivery_job.rb +31 -0
- data/app/jobs/add_auth/email_delivery_job.rb +15 -0
- data/app/jobs/add_auth/email_request_job.rb +21 -0
- data/app/jobs/add_auth/security_notification_job.rb +13 -0
- data/app/mailers/add_auth/security_mailer.rb +16 -0
- data/app/mailers/add_auth/sign_in_mailer.rb +36 -0
- data/app/models/concerns/.keep +0 -0
- data/app/views/.keep +0 -0
- data/app/views/add_auth/passkeys/_controls.html.erb +17 -0
- data/app/views/add_auth/passkeys/_list.html.erb +32 -0
- data/app/views/add_auth/reauthentications/_check_email.html.erb +3 -0
- data/app/views/add_auth/reauthentications/_confirmation.html.erb +7 -0
- data/app/views/add_auth/reauthentications/_different_browser.html.erb +3 -0
- data/app/views/add_auth/reauthentications/_form.html.erb +27 -0
- data/app/views/add_auth/reauthentications/_invalid_link.html.erb +3 -0
- data/app/views/add_auth/recoveries/_check_email.html.erb +3 -0
- data/app/views/add_auth/recoveries/_confirmation.html.erb +9 -0
- data/app/views/add_auth/recoveries/_form.html.erb +11 -0
- data/app/views/add_auth/recoveries/_invalid_link.html.erb +3 -0
- data/app/views/add_auth/security_mailer/notice.text.erb +3 -0
- data/app/views/add_auth/sessions/_list.html.erb +25 -0
- data/app/views/add_auth/sessions/_revoke_all.html.erb +18 -0
- data/app/views/add_auth/sessions/index.html.erb +12 -0
- data/app/views/add_auth/sign_in_mailer/link.text.erb +5 -0
- data/app/views/add_auth/sign_ins/_check_email.html.erb +5 -0
- data/app/views/add_auth/sign_ins/_confirmation.html.erb +9 -0
- data/app/views/add_auth/sign_ins/_different_browser.html.erb +3 -0
- data/app/views/add_auth/sign_ins/_form.html.erb +34 -0
- data/app/views/add_auth/sign_ins/_invalid_link.html.erb +3 -0
- data/app/views/add_auth/sign_ins/show.html.erb +1 -0
- data/app/views/layouts/add_auth/authentication.html.erb +19 -0
- data/lib/add_auth/configuration.rb +61 -0
- data/lib/add_auth/core/access_policy.rb +55 -0
- data/lib/add_auth/core/browser_binding.rb +28 -0
- data/lib/add_auth/core/challenge/base.rb +100 -0
- data/lib/add_auth/core/challenge/http.rb +130 -0
- data/lib/add_auth/core/challenge/null.rb +21 -0
- data/lib/add_auth/core/challenge/recaptcha.rb +76 -0
- data/lib/add_auth/core/challenge/test.rb +32 -0
- data/lib/add_auth/core/challenge/turnstile.rb +46 -0
- data/lib/add_auth/core/delivery.rb +55 -0
- data/lib/add_auth/core/digest/base.rb +34 -0
- data/lib/add_auth/core/digest/hmac.rb +41 -0
- data/lib/add_auth/core/intake.rb +59 -0
- data/lib/add_auth/core/maintenance.rb +45 -0
- data/lib/add_auth/core/rate_limit.rb +30 -0
- data/lib/add_auth/core/security_events.rb +48 -0
- data/lib/add_auth/core/sessions.rb +287 -0
- data/lib/add_auth/core/step_up.rb +129 -0
- data/lib/add_auth/core/strategies/email_link.rb +201 -0
- data/lib/add_auth/core/strategies/passkey.rb +286 -0
- data/lib/add_auth/rails/authentication.rb +67 -0
- data/lib/add_auth/rails/authentication_pages.rb +66 -0
- data/lib/add_auth/rails/delivery_cipher.rb +33 -0
- data/lib/add_auth/rails/doctor.rb +154 -0
- data/lib/add_auth/rails/ejection.rb +138 -0
- data/lib/add_auth/rails/elevation.rb +35 -0
- data/lib/add_auth/rails/engine.rb +34 -0
- data/lib/add_auth/rails/password_entry.rb +34 -0
- data/lib/add_auth/rails/runtime.rb +216 -0
- data/lib/add_auth/rails/stores/account_lock.rb +40 -0
- data/lib/add_auth/rails/stores/delivery_state.rb +21 -0
- data/lib/add_auth/rails/stores/email_tokens.rb +92 -0
- data/lib/add_auth/rails/stores/maintenance.rb +46 -0
- data/lib/add_auth/rails/stores/passkeys.rb +63 -0
- data/lib/add_auth/rails/stores/security_events.rb +40 -0
- data/lib/add_auth/rails/stores/sessions.rb +62 -0
- data/lib/add_auth/rails/user_lifecycle.rb +35 -0
- data/lib/add_auth/result.rb +55 -0
- data/lib/add_auth/testing.rb +30 -0
- data/lib/add_auth/version.rb +5 -0
- data/lib/add_auth.rb +52 -0
- data/lib/generators/add_auth/challenge/challenge_generator.rb +48 -0
- data/lib/generators/add_auth/challenge/templates/recaptcha.rb.tt +28 -0
- data/lib/generators/add_auth/challenge/templates/turnstile.rb.tt +24 -0
- data/lib/generators/add_auth/controllers/controllers_generator.rb +14 -0
- data/lib/generators/add_auth/ejection.rb +18 -0
- data/lib/generators/add_auth/email_link/email_link_generator.rb +56 -0
- data/lib/generators/add_auth/email_link/templates/add_add_auth_email_binding.rb.tt +5 -0
- data/lib/generators/add_auth/email_link/templates/add_add_auth_email_delivery.rb.tt +10 -0
- data/lib/generators/add_auth/email_link/templates/add_auth.css +28 -0
- data/lib/generators/add_auth/email_link/templates/add_auth_challenge.js +129 -0
- data/lib/generators/add_auth/email_tokens/email_tokens_generator.rb +35 -0
- data/lib/generators/add_auth/email_tokens/templates/add_auth_sign_in_token.rb +4 -0
- data/lib/generators/add_auth/email_tokens/templates/create_add_auth_sign_in_tokens.rb.tt +18 -0
- data/lib/generators/add_auth/install/install_generator.rb +27 -0
- data/lib/generators/add_auth/install/templates/initializer.rb +46 -0
- data/lib/generators/add_auth/javascript/javascript_generator.rb +14 -0
- data/lib/generators/add_auth/javascript/templates/application.js +2 -0
- data/lib/generators/add_auth/javascript/templates/codec.js +28 -0
- data/lib/generators/add_auth/javascript/templates/passkey.js +81 -0
- data/lib/generators/add_auth/mailer_views/mailer_views_generator.rb +14 -0
- data/lib/generators/add_auth/notifications/notifications_generator.rb +32 -0
- data/lib/generators/add_auth/notifications/templates/add_auth_security_event.rb +4 -0
- data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +18 -0
- data/lib/generators/add_auth/passkeys/passkeys_generator.rb +75 -0
- data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +45 -0
- data/lib/generators/add_auth/passkeys/templates/add_auth_ceremony.rb +4 -0
- data/lib/generators/add_auth/passkeys/templates/add_auth_credential.rb +4 -0
- data/lib/generators/add_auth/session_upgrade/session_upgrade_generator.rb +79 -0
- data/lib/generators/add_auth/session_upgrade/templates/add_add_auth_elevation.rb.tt +12 -0
- data/lib/generators/add_auth/session_upgrade/templates/extend_sessions_for_add_auth.rb.tt +14 -0
- data/lib/generators/add_auth/step_up/step_up_generator.rb +44 -0
- data/lib/generators/add_auth/step_up/templates/add_add_auth_reauthentication.rb.tt +9 -0
- data/lib/generators/add_auth/views/views_generator.rb +16 -0
- data/lib/tasks/add_auth.rake +38 -0
- metadata +361 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :challenge, :challenge_on,
|
|
6
|
+
:digest_secret, :eligible, :stylesheet, :css_classes, :mail_from, :base_url, :rate_limit_store, :trusted_recovery_address, :support_url
|
|
7
|
+
attr_writer :session_token_digest, :sign_in_token_digest
|
|
8
|
+
|
|
9
|
+
SessionOptions = Struct.new(:enabled, :lifetime, :idle_timeout, :legacy_bridge_until)
|
|
10
|
+
EmailOptions = Struct.new(:enabled, :token_lifetime, :same_browser)
|
|
11
|
+
StepUpOptions = Struct.new(:enabled, :purposes, :fresh_for, :strong_for)
|
|
12
|
+
PasskeyOptions = Struct.new(:enabled, :rp_id, :origins, :name, :anonymous_limit)
|
|
13
|
+
NotificationOptions = Struct.new(:enabled)
|
|
14
|
+
MaintenanceOptions = Struct.new(:batch_size, :session_retention, :email_retention, :notification_retention)
|
|
15
|
+
attr_accessor :passwords_enabled
|
|
16
|
+
attr_reader :maintenance, :notifications, :passkeys, :session, :email_link, :step_up, :challenge_when_unavailable
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@passwords_enabled = true
|
|
20
|
+
@session = SessionOptions.new(enabled: false, lifetime: 43_200, idle_timeout: 1800)
|
|
21
|
+
@email_link = EmailOptions.new(enabled: false, token_lifetime: 1200, same_browser: false)
|
|
22
|
+
@notifications = NotificationOptions.new(enabled: false)
|
|
23
|
+
@maintenance = MaintenanceOptions.new(batch_size: 100)
|
|
24
|
+
@passkeys = PasskeyOptions.new(enabled: false, origins: [], name: "Your account", anonymous_limit: 1000)
|
|
25
|
+
@trusted_recovery_address = ->(_user) {}
|
|
26
|
+
@step_up = StepUpOptions.new(enabled: false, purposes: {}, fresh_for: 600, strong_for: 300)
|
|
27
|
+
@eligible = ->(_user) { true } # Hosts supply their confirmed/locked/disabled policy here.
|
|
28
|
+
@stylesheet = "/add_auth.css"
|
|
29
|
+
@css_classes = {}
|
|
30
|
+
@challenge = Core::Challenge::Null.new
|
|
31
|
+
@challenge_on = []
|
|
32
|
+
@challenge_when_unavailable = :closed
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def session_token_digest
|
|
36
|
+
@session_token_digest ||= default_digest("add_auth.session-token-digest.v1")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def sign_in_token_digest
|
|
40
|
+
@sign_in_token_digest ||= default_digest("add_auth.sign-in-token-digest.v1")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def challenge_when_unavailable=(policy)
|
|
44
|
+
value = policy.to_sym
|
|
45
|
+
raise ArgumentError, "challenge_when_unavailable must be :closed or :open" unless %i[closed open].include?(value)
|
|
46
|
+
|
|
47
|
+
@challenge_when_unavailable = value
|
|
48
|
+
rescue NoMethodError
|
|
49
|
+
raise ArgumentError, "challenge_when_unavailable must be :closed or :open"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def default_digest(salt)
|
|
55
|
+
unless digest_secret.respond_to?(:call)
|
|
56
|
+
raise AddAuth::Error, "configure digest_secret or inject a digest adapter outside a Rails app"
|
|
57
|
+
end
|
|
58
|
+
Core::Digest::Hmac.new(salt: salt, secret: digest_secret.call)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Core
|
|
5
|
+
# Account policy survives feature toggles; disabling passkeys cannot restore
|
|
6
|
+
# weaker access to an account that explicitly adopted strict policy.
|
|
7
|
+
class AccessPolicy
|
|
8
|
+
def initialize(credentials:, passkeys_enabled:, email_enabled:, trusted_recovery_address:, password_enabled: true)
|
|
9
|
+
@credentials, @passkeys_enabled, @email_enabled = credentials, passkeys_enabled, email_enabled
|
|
10
|
+
@trusted_recovery_address = trusted_recovery_address
|
|
11
|
+
@password_enabled = password_enabled
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def strict?(user) = user.respond_to?(:add_auth_strict) && user.add_auth_strict == true
|
|
15
|
+
def version(user) = user.respond_to?(:add_auth_policy_version) ? user.add_auth_policy_version : 0
|
|
16
|
+
|
|
17
|
+
def sign_in_allowed?(user, method)
|
|
18
|
+
return false if strict?(user) && method.to_sym != :passkey
|
|
19
|
+
case method.to_sym
|
|
20
|
+
when :password then @password_enabled && user.respond_to?(:password_digest) && user.password_digest.is_a?(String) && !user.password_digest.empty?
|
|
21
|
+
when :email_link then @email_enabled
|
|
22
|
+
when :passkey then @passkeys_enabled
|
|
23
|
+
else false
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def methods_for(user)
|
|
28
|
+
StepUp::METHODS.select { |method| sign_in_allowed?(user, method) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def recovery_address(user)
|
|
32
|
+
address = @trusted_recovery_address.call(user)
|
|
33
|
+
address if address.is_a?(String) && address.valid_encoding? && address.bytesize.between?(3, 254) && address == user.email_address
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def recoverable?(user) = !strict?(user) && @email_enabled && !recovery_address(user).nil?
|
|
37
|
+
def fallback?(user) = !strict?(user) && (sign_in_allowed?(user, :password) || recoverable?(user))
|
|
38
|
+
|
|
39
|
+
def credential_current?(user:, id:)
|
|
40
|
+
credential = @credentials.call(id)
|
|
41
|
+
credential && credential.user_id == user.id && !credential.revoked_at
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def session_allowed?(user, row)
|
|
45
|
+
return false if row.respond_to?(:authentication_policy_version) && row.authentication_policy_version != version(user)
|
|
46
|
+
if row.authenticated_with == "passkey"
|
|
47
|
+
return false unless row.authentication_uv && credential_current?(user: user, id: row.authentication_credential_id)
|
|
48
|
+
end
|
|
49
|
+
return true unless strict?(user)
|
|
50
|
+
(row.authenticated_with == "passkey" && row.authentication_uv) ||
|
|
51
|
+
(row.elevated_with == "passkey" && row.elevation_uv && credential_current?(user: user, id: row.elevation_credential_id))
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Core
|
|
7
|
+
# A browser secret is transport state, never identity or stronger proof.
|
|
8
|
+
class BrowserBinding
|
|
9
|
+
def initialize(digest:)
|
|
10
|
+
@digest = digest
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def generate = SecureRandom.urlsafe_base64(32)
|
|
14
|
+
|
|
15
|
+
def digest(secret)
|
|
16
|
+
@digest.digest("browser:#{secret}") if valid?(secret)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def matches?(stored, secret)
|
|
20
|
+
valid?(secret) && stored.is_a?(String) && @digest.matches?(stored, "browser:#{secret}")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def valid?(secret)
|
|
24
|
+
secret.is_a?(String) && secret.bytesize == 43 && secret.ascii_only? && /\A[A-Za-z0-9_-]{43}\z/.match?(secret)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Core
|
|
5
|
+
module Challenge
|
|
6
|
+
# The adapter contract every challenge provider implements. See
|
|
7
|
+
# docs/authentication-gem-plan.md section 9 for the full design,
|
|
8
|
+
# including the decision that matters most here: #verify returns a
|
|
9
|
+
# result with a real `unavailable?` state, distinct from `rejected?`.
|
|
10
|
+
# A provider outage and a failed human challenge are different events
|
|
11
|
+
# and most integrations in the wild conflate them -- either locking out
|
|
12
|
+
# every user during an outage, or waving everyone through. The host
|
|
13
|
+
# chooses which way to fail via `Configuration#challenge_when_unavailable`.
|
|
14
|
+
#
|
|
15
|
+
# Concrete adapters (Turnstile, reCAPTCHA) use only Ruby's standard
|
|
16
|
+
# library and are loaded with Core. A host opts in with
|
|
17
|
+
# `bin/rails g add_auth:challenge turnstile`, which generates the
|
|
18
|
+
# initializer stanza and route; no provider call happens until the host
|
|
19
|
+
# supplies keys and includes the action in `challenge_on`.
|
|
20
|
+
class Base
|
|
21
|
+
Verification = Data.define(:status, :reason) do
|
|
22
|
+
def initialize(status:, reason: nil)
|
|
23
|
+
unless %i[success rejected unavailable].include?(status)
|
|
24
|
+
raise ArgumentError, "unknown challenge status"
|
|
25
|
+
end
|
|
26
|
+
super
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def success? = status == :success
|
|
30
|
+
def rejected? = status == :rejected
|
|
31
|
+
def unavailable? = status == :unavailable
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def site_key
|
|
35
|
+
raise NotImplementedError, "#{self.class} must implement #site_key"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def script_url
|
|
39
|
+
raise NotImplementedError, "#{self.class} must implement #script_url"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def stimulus_controller
|
|
43
|
+
raise NotImplementedError, "#{self.class} must implement #stimulus_controller"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @param token [String, nil] the provider's response token from the form.
|
|
47
|
+
# @param remote_ip [String, nil]
|
|
48
|
+
# @param action [Symbol] one of Configuration#challenge_on's entries;
|
|
49
|
+
# used to bind the token to the form it was minted for.
|
|
50
|
+
# @return [Verification]
|
|
51
|
+
def verify(token:, remote_ip:, action:)
|
|
52
|
+
raise NotImplementedError, "#{self.class} must implement #verify"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def inspect = "#<#{self.class} [FILTERED]>"
|
|
56
|
+
attr_reader :allowed_hostnames
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def require_value(value, name, max_bytes:)
|
|
61
|
+
unless value.is_a?(String) && value.valid_encoding? && value.bytesize.between?(1, max_bytes)
|
|
62
|
+
raise ArgumentError, "challenge #{name} must be present"
|
|
63
|
+
end
|
|
64
|
+
value.dup.freeze
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def normalize_hostnames(hostnames)
|
|
68
|
+
values = hostnames.is_a?(String) ? hostnames.split(",") : Array(hostnames)
|
|
69
|
+
values.map { |hostname| hostname.to_s.strip.downcase }.reject(&:empty?).uniq.freeze
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def normalize_action(action)
|
|
73
|
+
value = action.to_s
|
|
74
|
+
raise ArgumentError, "challenge action must be present" unless value.match?(/\A[a-zA-Z0-9._:-]{1,64}\z/)
|
|
75
|
+
value
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def hostname_allowed?(hostname)
|
|
79
|
+
hostname.is_a?(String) && !hostname.empty? &&
|
|
80
|
+
(@allowed_hostnames.empty? || @allowed_hostnames.include?(hostname.downcase))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def response_failure(payload)
|
|
84
|
+
return unavailable(:verification_service) unless [true, false].include?(payload["success"])
|
|
85
|
+
return if payload["success"] == true
|
|
86
|
+
errors = payload["error-codes"]
|
|
87
|
+
valid_errors = errors.nil? || (errors.is_a?(Array) && errors.all? { |value| value.is_a?(String) })
|
|
88
|
+
return unavailable(:verification_service) unless valid_errors
|
|
89
|
+
service_errors = %w[internal-error missing-input-secret invalid-input-secret bad-request]
|
|
90
|
+
return unavailable(:verification_service) if (Array(errors) & service_errors).any?
|
|
91
|
+
rejected(:challenge_rejected)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def success = Verification.new(status: :success)
|
|
95
|
+
def rejected(reason) = Verification.new(status: :rejected, reason:)
|
|
96
|
+
def unavailable(reason) = Verification.new(status: :unavailable, reason:)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "timeout"
|
|
7
|
+
|
|
8
|
+
module AddAuth
|
|
9
|
+
module Core
|
|
10
|
+
module Challenge
|
|
11
|
+
# Small, dependency-free HTTP boundary shared by provider adapters. It
|
|
12
|
+
# deliberately returns protocol states instead of raising provider errors
|
|
13
|
+
# into an authentication request. A provider outage and a malformed
|
|
14
|
+
# response are both unavailable; an invalid user token is rejected.
|
|
15
|
+
class Http
|
|
16
|
+
Response = Data.define(:status, :payload)
|
|
17
|
+
|
|
18
|
+
DEFAULT_OPEN_TIMEOUT = 3
|
|
19
|
+
DEFAULT_READ_TIMEOUT = 3
|
|
20
|
+
MAX_TOKEN_BYTES = 2048
|
|
21
|
+
MAX_RESPONSE_BYTES = 16_384
|
|
22
|
+
TOTAL_TIMEOUT = 10
|
|
23
|
+
|
|
24
|
+
def initialize(endpoint:, secret_key:, transport: nil, allowed_hosts: nil,
|
|
25
|
+
open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT)
|
|
26
|
+
@allowed_hosts = allowed_hosts&.map { |host| host.to_s.downcase }&.freeze
|
|
27
|
+
@endpoint = validate_endpoint(endpoint)
|
|
28
|
+
@secret_key = validate_secret(secret_key)
|
|
29
|
+
@transport = transport || method(:request)
|
|
30
|
+
@open_timeout = bounded_timeout(open_timeout, "open_timeout")
|
|
31
|
+
@read_timeout = bounded_timeout(read_timeout, "read_timeout")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call(token:, remote_ip: nil)
|
|
35
|
+
return Response.new(status: :rejected, payload: nil) unless valid_token?(token)
|
|
36
|
+
|
|
37
|
+
parameters = {"secret" => @secret_key, "response" => token}
|
|
38
|
+
parameters["remoteip"] = remote_ip if remote_ip.is_a?(String) && !remote_ip.empty?
|
|
39
|
+
raw = @transport.call(uri: @endpoint, params: parameters)
|
|
40
|
+
status, body = extract(raw)
|
|
41
|
+
return Response.new(status: :unavailable, payload: nil) unless status.between?(200, 299)
|
|
42
|
+
|
|
43
|
+
return Response.new(status: :unavailable, payload: nil) if body.bytesize > MAX_RESPONSE_BYTES
|
|
44
|
+
payload = JSON.parse(body, max_nesting: 10)
|
|
45
|
+
return Response.new(status: :unavailable, payload: nil) unless payload.is_a?(Hash)
|
|
46
|
+
|
|
47
|
+
Response.new(status: :ok, payload: payload)
|
|
48
|
+
rescue JSON::ParserError, TypeError, ArgumentError
|
|
49
|
+
Response.new(status: :unavailable, payload: nil)
|
|
50
|
+
rescue
|
|
51
|
+
# Network, TLS and provider client failures must not disclose
|
|
52
|
+
# endpoint details or secret material to the authentication caller.
|
|
53
|
+
Response.new(status: :unavailable, payload: nil)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def inspect = "#<AddAuth::Core::Challenge::Http [FILTERED]>"
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def validate_endpoint(endpoint)
|
|
61
|
+
uri = URI.parse(endpoint.to_s)
|
|
62
|
+
unless uri.is_a?(URI::HTTPS) && uri.host && uri.userinfo.nil? && uri.query.nil? && uri.fragment.nil?
|
|
63
|
+
raise ArgumentError, "challenge endpoint must be HTTPS"
|
|
64
|
+
end
|
|
65
|
+
if @allowed_hosts && !@allowed_hosts.include?(uri.host.downcase)
|
|
66
|
+
raise ArgumentError, "challenge endpoint host is not allowed"
|
|
67
|
+
end
|
|
68
|
+
uri
|
|
69
|
+
rescue URI::InvalidURIError
|
|
70
|
+
raise ArgumentError, "challenge endpoint must be a valid HTTPS URL"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def validate_secret(secret)
|
|
74
|
+
value = secret.to_s
|
|
75
|
+
raise ArgumentError, "challenge secret_key must be present" if value.empty? || value.bytesize > 512
|
|
76
|
+
|
|
77
|
+
value
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def bounded_timeout(value, name)
|
|
81
|
+
number = Float(value)
|
|
82
|
+
raise ArgumentError, "#{name} must be between 0.1 and 30 seconds" unless number.between?(0.1, 30)
|
|
83
|
+
|
|
84
|
+
number
|
|
85
|
+
rescue ArgumentError, TypeError
|
|
86
|
+
raise ArgumentError, "#{name} must be between 0.1 and 30 seconds"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def valid_token?(token)
|
|
90
|
+
token.is_a?(String) && token.valid_encoding? && token.bytesize.between?(1, MAX_TOKEN_BYTES)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def extract(raw)
|
|
94
|
+
if raw.respond_to?(:code) && raw.respond_to?(:body)
|
|
95
|
+
[Integer(raw.code), raw.body.to_s]
|
|
96
|
+
elsif raw.is_a?(Array) && raw.size == 2
|
|
97
|
+
[Integer(raw.fetch(0)), raw.fetch(1).to_s]
|
|
98
|
+
else
|
|
99
|
+
raise TypeError, "invalid challenge transport response"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def request(uri:, params:)
|
|
104
|
+
client = Net::HTTP.new(uri.host, uri.port)
|
|
105
|
+
client.use_ssl = true
|
|
106
|
+
client.open_timeout = @open_timeout
|
|
107
|
+
client.read_timeout = @read_timeout
|
|
108
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
109
|
+
request.set_form_data(params)
|
|
110
|
+
client.write_timeout = @read_timeout
|
|
111
|
+
client.max_retries = 0
|
|
112
|
+
Timeout.timeout(TOTAL_TIMEOUT) do
|
|
113
|
+
result = nil
|
|
114
|
+
client.start do |http|
|
|
115
|
+
http.request(request) do |response|
|
|
116
|
+
body = +""
|
|
117
|
+
response.read_body do |chunk|
|
|
118
|
+
raise IOError, "challenge response too large" if body.bytesize + chunk.bytesize > MAX_RESPONSE_BYTES
|
|
119
|
+
body << chunk
|
|
120
|
+
end
|
|
121
|
+
result = [response.code.to_i, body]
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
result
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Core
|
|
5
|
+
module Challenge
|
|
6
|
+
# The default adapter: no challenge configured, every verification
|
|
7
|
+
# succeeds, and the view helper renders nothing. This is what makes
|
|
8
|
+
# `add_auth_challenge_tag` safe to leave in a generated view whether or
|
|
9
|
+
# not the host has configured Turnstile/reCAPTCHA (section 9).
|
|
10
|
+
class Null < Base
|
|
11
|
+
def site_key = nil
|
|
12
|
+
def script_url = nil
|
|
13
|
+
def stimulus_controller = nil
|
|
14
|
+
|
|
15
|
+
def verify(token:, remote_ip:, action:)
|
|
16
|
+
success
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "add_auth/core/challenge/http"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Core
|
|
7
|
+
module Challenge
|
|
8
|
+
# Google reCAPTCHA Siteverify adapter for both v2 checkbox and v3 score
|
|
9
|
+
# keys. v2 deliberately ignores action/score fields because Google does
|
|
10
|
+
# not make the v3 contract available for that mode.
|
|
11
|
+
class Recaptcha < Base
|
|
12
|
+
ENDPOINT = "https://www.google.com/recaptcha/api/siteverify"
|
|
13
|
+
SCRIPT_URL = "https://www.google.com/recaptcha/api.js"
|
|
14
|
+
|
|
15
|
+
def initialize(site_key:, secret_key:, version: :v3, allowed_hostnames: [], minimum_score: 0.5,
|
|
16
|
+
expected_action: nil, transport: nil, endpoint: ENDPOINT,
|
|
17
|
+
open_timeout: Http::DEFAULT_OPEN_TIMEOUT, read_timeout: Http::DEFAULT_READ_TIMEOUT)
|
|
18
|
+
@site_key = require_value(site_key, "site_key", max_bytes: 512)
|
|
19
|
+
@version = normalize_version(version)
|
|
20
|
+
@allowed_hostnames = normalize_hostnames(allowed_hostnames)
|
|
21
|
+
@minimum_score = normalize_score(minimum_score)
|
|
22
|
+
@expected_action = expected_action.nil? ? nil : normalize_action(expected_action)
|
|
23
|
+
@http = Http.new(endpoint: endpoint, secret_key: secret_key, allowed_hosts: ["www.google.com"], transport: transport,
|
|
24
|
+
open_timeout: open_timeout, read_timeout: read_timeout)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
attr_reader :site_key, :version, :minimum_score, :expected_action
|
|
28
|
+
|
|
29
|
+
def script_url = "#{SCRIPT_URL}?render=#{(@version == :v3) ? URI.encode_www_form_component(site_key) : "explicit"}"
|
|
30
|
+
def stimulus_controller = "add_auth--challenge-recaptcha"
|
|
31
|
+
|
|
32
|
+
def verify(token:, remote_ip:, action:)
|
|
33
|
+
response = @http.call(token: token, remote_ip: remote_ip)
|
|
34
|
+
return rejected(:invalid_token) if response.status == :rejected
|
|
35
|
+
return unavailable(:verification_service) unless response.status == :ok
|
|
36
|
+
|
|
37
|
+
payload = response.payload
|
|
38
|
+
failure = response_failure(payload)
|
|
39
|
+
return failure if failure
|
|
40
|
+
return rejected(:challenge_rejected) unless hostname_allowed?(payload["hostname"])
|
|
41
|
+
return success if @version == :v2
|
|
42
|
+
|
|
43
|
+
expected = @expected_action || normalize_action(action)
|
|
44
|
+
return rejected(:challenge_rejected) unless payload["action"] == expected
|
|
45
|
+
return rejected(:challenge_rejected) unless score_at_least?(payload["score"])
|
|
46
|
+
|
|
47
|
+
success
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def normalize_version(version)
|
|
53
|
+
value = version.to_sym
|
|
54
|
+
raise ArgumentError, "reCAPTCHA version must be :v2 or :v3" unless %i[v2 v3].include?(value)
|
|
55
|
+
|
|
56
|
+
value
|
|
57
|
+
rescue NoMethodError
|
|
58
|
+
raise ArgumentError, "reCAPTCHA version must be :v2 or :v3"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def normalize_score(score)
|
|
62
|
+
value = Float(score)
|
|
63
|
+
raise ArgumentError, "minimum_score must be between 0 and 1" unless value.between?(0, 1)
|
|
64
|
+
|
|
65
|
+
value
|
|
66
|
+
rescue ArgumentError, TypeError
|
|
67
|
+
raise ArgumentError, "minimum_score must be between 0 and 1"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def score_at_least?(score)
|
|
71
|
+
score.is_a?(Numeric) && score.finite? && score.between?(@minimum_score, 1)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Core
|
|
5
|
+
module Challenge
|
|
6
|
+
# Deterministic adapter for specs. Install the desired mode in test setup
|
|
7
|
+
# and restore the previous configuration after each example. This exercises
|
|
8
|
+
# Core outcomes without coupling host tests to provider HTTP stubs.
|
|
9
|
+
class Test < Base
|
|
10
|
+
def initialize(mode: :success)
|
|
11
|
+
@mode = mode
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
attr_accessor :mode
|
|
15
|
+
|
|
16
|
+
def site_key = "test-site-key"
|
|
17
|
+
def script_url = nil
|
|
18
|
+
def stimulus_controller = "add_auth--challenge-test"
|
|
19
|
+
|
|
20
|
+
def verify(token:, remote_ip:, action:)
|
|
21
|
+
case @mode
|
|
22
|
+
when :success then success
|
|
23
|
+
when :rejected then rejected(:challenge_rejected)
|
|
24
|
+
when :unavailable then unavailable(:verification_service)
|
|
25
|
+
else
|
|
26
|
+
raise ArgumentError, "unknown Test challenge mode: #{@mode.inspect}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "add_auth/core/challenge/http"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Core
|
|
7
|
+
module Challenge
|
|
8
|
+
# Cloudflare Turnstile's server-side Siteverify adapter. The browser
|
|
9
|
+
# widget is optional and host-rendered; this class owns the security
|
|
10
|
+
# decision made from Cloudflare's response.
|
|
11
|
+
class Turnstile < Base
|
|
12
|
+
ENDPOINT = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
|
13
|
+
SCRIPT_URL = "https://challenges.cloudflare.com/turnstile/v0/api.js"
|
|
14
|
+
|
|
15
|
+
def initialize(site_key:, secret_key:, allowed_hostnames: [], transport: nil,
|
|
16
|
+
endpoint: ENDPOINT, open_timeout: Http::DEFAULT_OPEN_TIMEOUT,
|
|
17
|
+
read_timeout: Http::DEFAULT_READ_TIMEOUT)
|
|
18
|
+
@site_key = require_value(site_key, "site_key", max_bytes: 512)
|
|
19
|
+
@allowed_hostnames = normalize_hostnames(allowed_hostnames)
|
|
20
|
+
@http = Http.new(endpoint: endpoint, secret_key: secret_key, allowed_hosts: ["challenges.cloudflare.com"], transport: transport,
|
|
21
|
+
open_timeout: open_timeout, read_timeout: read_timeout)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
attr_reader :site_key
|
|
25
|
+
|
|
26
|
+
def script_url = "#{SCRIPT_URL}?render=explicit"
|
|
27
|
+
def stimulus_controller = "add_auth--challenge-turnstile"
|
|
28
|
+
|
|
29
|
+
def verify(token:, remote_ip:, action:)
|
|
30
|
+
expected_action = normalize_action(action)
|
|
31
|
+
response = @http.call(token: token, remote_ip: remote_ip)
|
|
32
|
+
return rejected(:invalid_token) if response.status == :rejected
|
|
33
|
+
return unavailable(:verification_service) unless response.status == :ok
|
|
34
|
+
|
|
35
|
+
payload = response.payload
|
|
36
|
+
failure = response_failure(payload)
|
|
37
|
+
return failure if failure
|
|
38
|
+
return rejected(:challenge_rejected) unless payload["action"] == expected_action
|
|
39
|
+
return rejected(:challenge_rejected) unless hostname_allowed?(payload["hostname"])
|
|
40
|
+
|
|
41
|
+
success
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Core
|
|
5
|
+
# Shared leased-delivery state machine. The including strategy supplies
|
|
6
|
+
# rejection(user, record) and delivery_details(user, record); the store owns
|
|
7
|
+
# locking and persistence. Transport always runs after this lock returns.
|
|
8
|
+
module Delivery
|
|
9
|
+
def claim_delivery(digest:)
|
|
10
|
+
@store.with_token(digest: digest) do |user, record|
|
|
11
|
+
next if rejection(user, record) || record.delivery_payload.nil? || record.delivered_at ||
|
|
12
|
+
(record.delivery_lease_until && record.delivery_lease_until > @clock.now)
|
|
13
|
+
details = delivery_details(user, record)
|
|
14
|
+
next unless details
|
|
15
|
+
key = @random.hex(16)
|
|
16
|
+
@store.lease(record: record, key: key, until_time: @clock.now + 300)
|
|
17
|
+
details.merge(lease: key)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def delivery_failed(digest:, lease: nil)
|
|
22
|
+
@store.with_token(digest: digest) do |user, record|
|
|
23
|
+
next if lease && record && record.delivery_lease_key != lease
|
|
24
|
+
@store.revoke(record: record, at: @clock.now) unless rejection(user, record)
|
|
25
|
+
end
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def delivery_succeeded(digest:, lease:)
|
|
30
|
+
finish_delivery(digest: digest, lease: lease, outcome: :delivered)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def finish_delivery(digest:, lease:, outcome:)
|
|
34
|
+
raise ArgumentError, "unsupported delivery outcome" unless %i[delivered cancelled].include?(outcome)
|
|
35
|
+
return false unless lease.is_a?(String) && !lease.empty?
|
|
36
|
+
@store.with_token(digest: digest) do |_user, record|
|
|
37
|
+
next false unless record && record.delivery_lease_key == lease
|
|
38
|
+
if outcome == :delivered
|
|
39
|
+
@store.delivered(record: record, at: @clock.now)
|
|
40
|
+
else
|
|
41
|
+
@store.revoke(record: record, at: @clock.now)
|
|
42
|
+
@store.lease(record: record, key: nil, until_time: nil)
|
|
43
|
+
end
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def delivery_retry(digest:, lease:)
|
|
49
|
+
@store.with_token(digest: digest) do |_user, record|
|
|
50
|
+
@store.lease(record: record, key: nil, until_time: nil) if record && record.delivery_lease_key == lease
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Core
|
|
5
|
+
module Digest
|
|
6
|
+
# The adapter contract every token/session digest implementation
|
|
7
|
+
# satisfies. See docs/authentication-gem-plan.md section 2's
|
|
8
|
+
# "Pluggable cryptography" subsection: unlike the Challenge adapter,
|
|
9
|
+
# there is no credible reason to swap this for a *security* property --
|
|
10
|
+
# the reason to override it is organizational (a host mandated to run
|
|
11
|
+
# OpenSSL in FIPS mode, or to route HMAC operations through an HSM).
|
|
12
|
+
# Any object responding to #digest and #matches? satisfies this
|
|
13
|
+
# contract; Base exists to document it and to fail loudly if a
|
|
14
|
+
# concrete adapter forgets a method, the same role Challenge::Base
|
|
15
|
+
# plays for challenge providers.
|
|
16
|
+
class Base
|
|
17
|
+
# @param token [String] the raw, high-entropy secret (a session
|
|
18
|
+
# token, a sign-in token) as presented by the client.
|
|
19
|
+
# @return [String] an opaque digest safe to store at rest.
|
|
20
|
+
def digest(token)
|
|
21
|
+
raise NotImplementedError, "#{self.class} must implement #digest"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param digest [String, nil] the stored digest to compare against.
|
|
25
|
+
# @param token [String, nil] the raw token presented by the client.
|
|
26
|
+
# @return [Boolean] true only for a byte-exact match of the digest
|
|
27
|
+
# the same token would produce, compared in constant time.
|
|
28
|
+
def matches?(digest, token)
|
|
29
|
+
raise NotImplementedError, "#{self.class} must implement #matches?"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|