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,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "add_auth/rails/ejection"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Rails
|
|
7
|
+
class Doctor
|
|
8
|
+
attr_reader :ejections
|
|
9
|
+
|
|
10
|
+
SESSION_COLUMNS = %w[user_id token_digest authenticated_with authenticated_at expires_at last_seen_at revoked_at
|
|
11
|
+
elevated_at elevated_with elevation_purpose elevation_credential_id elevation_uv].freeze
|
|
12
|
+
TOKEN_COLUMNS = %w[user_id digest identifier_digest purpose expires_at consumed_at revoked_at delivery_payload
|
|
13
|
+
request_id delivery_lease_key delivery_lease_until delivered_at].freeze
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
@problems = []
|
|
17
|
+
config = AddAuth.configuration
|
|
18
|
+
check("Configure valid maintenance batch size and retention durations") do
|
|
19
|
+
Core::Maintenance.new(stores: {}, options: config.maintenance, enqueue: ->(*) {})
|
|
20
|
+
end
|
|
21
|
+
if config.session.enabled
|
|
22
|
+
columns(::Session, SESSION_COLUMNS, "session")
|
|
23
|
+
unique_index(::Session, "token_digest")
|
|
24
|
+
unique_index(::User, "email_address")
|
|
25
|
+
check("Include AddAuth authentication after the host Authentication concern") do
|
|
26
|
+
::ApplicationController.instance_method(:find_session_by_cookie).owner == Authentication
|
|
27
|
+
end
|
|
28
|
+
%i[add_auth_write_cookie add_auth_accept add_auth_replacement_session terminate_session require_add_auth_authentication].each do |hook|
|
|
29
|
+
check("Restore the hardened cookie/session hook #{hook}") do
|
|
30
|
+
::ApplicationController.instance_method(hook).owner == Authentication
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
check("Include AddAuth::Rails::UserLifecycle in User") { ::User < UserLifecycle }
|
|
34
|
+
check("Set passwords_enabled to true or false") { [true, false].include?(config.passwords_enabled) }
|
|
35
|
+
if config.passwords_enabled || defined?(::SessionsController)
|
|
36
|
+
check("Install the shared password entry in SessionsController") { ::SessionsController < PasswordEntry }
|
|
37
|
+
end
|
|
38
|
+
if config.passwords_enabled
|
|
39
|
+
check("Keep Rails password authentication on User when passwords are enabled") do
|
|
40
|
+
::User.column_names.include?("password_digest") && ::User.respond_to?(:authenticate_by)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
%w[/sign-in /add_auth.css /add_auth/boot.js /add_auth/turbo.js /add_auth/stimulus.js /add_auth/challenge.js /sessions].each do |path|
|
|
44
|
+
check("Install route #{path}") { ::Rails.application.routes.recognize_path(path, method: :get) }
|
|
45
|
+
end
|
|
46
|
+
check("Configure valid session timeouts and digest adapters") { Runtime.sessions }
|
|
47
|
+
cache = config.rate_limit_store || ::Rails.cache
|
|
48
|
+
check("Configure an atomic rate-limit cache") do
|
|
49
|
+
key = "add_auth:doctor:#{SecureRandom.hex(16)}"
|
|
50
|
+
begin
|
|
51
|
+
cache.increment(key, 1, expires_in: 30, initial: 0) == 1 && cache.increment(key, 1, expires_in: 30, initial: 0) == 2
|
|
52
|
+
ensure
|
|
53
|
+
cache.delete(key)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
if ::Rails.env.production?
|
|
57
|
+
check("Enable CSRF protection for authentication") { ::ApplicationController.allow_forgery_protection }
|
|
58
|
+
check("Use a shared rate-limit cache in production") do
|
|
59
|
+
!cache.is_a?(ActiveSupport::Cache::MemoryStore) && !cache.is_a?(ActiveSupport::Cache::NullStore)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
if config.email_link.enabled
|
|
64
|
+
check("Enable session_upgrade before email_link") { config.session.enabled }
|
|
65
|
+
check("Configure base_url as a fixed HTTPS origin (HTTP only outside production)") { Runtime.sign_in_url("validation") }
|
|
66
|
+
columns(defined?(::AddAuthSignInToken) && ::AddAuthSignInToken, TOKEN_COLUMNS, "email delivery")
|
|
67
|
+
if defined?(::AddAuthSignInToken)
|
|
68
|
+
unique_index(::AddAuthSignInToken, "digest")
|
|
69
|
+
unique_index(::AddAuthSignInToken, "request_id")
|
|
70
|
+
end
|
|
71
|
+
if config.email_link.same_browser
|
|
72
|
+
columns(defined?(::AddAuthSignInToken) && ::AddAuthSignInToken, ["browser_digest"], "email browser binding")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
if config.email_link.enabled || config.notifications.enabled
|
|
76
|
+
check("Configure mail_from") { config.mail_from.is_a?(String) && !config.mail_from.strip.empty? }
|
|
77
|
+
check("Enable Action Mailer delivery") { ActionMailer::Base.perform_deliveries && ActionMailer::Base.raise_delivery_errors }
|
|
78
|
+
if ::Rails.env.production?
|
|
79
|
+
check("Use a durable job adapter in production") { !ActiveJob::Base.queue_adapter.class.name.match?(/Async|Inline|Test/) }
|
|
80
|
+
check("Configure production mail delivery") { !%i[test file].include?(ActionMailer::Base.delivery_method) }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
maintenance_needed = config.passkeys.enabled || config.email_link.enabled || config.notifications.enabled || config.maintenance.session_retention
|
|
84
|
+
if ::Rails.env.production? && maintenance_needed
|
|
85
|
+
check("Schedule add_auth:deliver_pending every minute; no successful cleanup in the last two minutes") { Runtime.maintenance_current? }
|
|
86
|
+
end
|
|
87
|
+
if config.passkeys.enabled
|
|
88
|
+
check("Configure passkey prerequisites, RP ID, exact origins, anonymous_limit and a safe support path") { Runtime.passkeys }
|
|
89
|
+
columns(::User, %w[webauthn_id add_auth_strict add_auth_policy_version], "passkeys")
|
|
90
|
+
columns(::Session, %w[authentication_policy_version authentication_credential_id authentication_uv], "passkeys")
|
|
91
|
+
columns(defined?(::AddAuthCredential) && ::AddAuthCredential,
|
|
92
|
+
%w[user_id external_id public_key sign_count revoked_at backup_eligible backup_state], "passkeys")
|
|
93
|
+
columns(defined?(::AddAuthCeremony) && ::AddAuthCeremony,
|
|
94
|
+
%w[digest challenge kind browser_digest configuration_digest session_id session_digest expires_at consumed_at], "passkeys")
|
|
95
|
+
unique_index(::User, "webauthn_id")
|
|
96
|
+
unique_index(::AddAuthCredential, "external_id") if defined?(::AddAuthCredential)
|
|
97
|
+
unique_index(::AddAuthCeremony, "digest") if defined?(::AddAuthCeremony)
|
|
98
|
+
check("Supply a trusted recovery address callback") { config.trusted_recovery_address.respond_to?(:call) }
|
|
99
|
+
%w[/passkeys /recover /add_auth/passkey.js /add_auth/codec.js].each do |path|
|
|
100
|
+
check("Install route #{path}") { ::Rails.application.routes.recognize_path(path, method: :get) }
|
|
101
|
+
end
|
|
102
|
+
check("Keep a support route for strict accounts") do
|
|
103
|
+
!::User.exists?(add_auth_strict: true) || Core::Sessions.safe_return(config.support_url)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
if config.notifications.enabled
|
|
107
|
+
columns(defined?(::AddAuthSecurityEvent) && ::AddAuthSecurityEvent,
|
|
108
|
+
%w[kind digest delivery_payload delivery_lease_key delivery_lease_until delivered_at revoked_at expires_at], "security notifications")
|
|
109
|
+
check("Enable security notification delivery errors") { ::AddAuth::SecurityMailer.raise_delivery_errors }
|
|
110
|
+
end
|
|
111
|
+
if config.step_up.enabled
|
|
112
|
+
check("Enable session and email prerequisites for step_up") { config.session.enabled && config.email_link.enabled }
|
|
113
|
+
columns(::Session, %w[elevation_version elevation_expires_at], "reauthentication")
|
|
114
|
+
columns(defined?(::AddAuthSignInToken) && ::AddAuthSignInToken,
|
|
115
|
+
%w[browser_digest session_id session_digest authentication_purpose], "reauthentication")
|
|
116
|
+
check("Declare valid step-up purposes and safe return destinations") { Runtime.step_up_purposes.any? && Runtime.step_up_policy }
|
|
117
|
+
%w[/reauthenticate /reauthenticate/link].each do |path|
|
|
118
|
+
check("Install route #{path}") { ::Rails.application.routes.recognize_path(path, method: :get) }
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
if config.challenge_on.any?
|
|
122
|
+
check("Unknown challenge actions") { (config.challenge_on - %i[sign_in email_link reauthenticate passkey_enrollment]).empty? }
|
|
123
|
+
check("Configure a challenge provider before setting challenge_on") { !config.challenge.is_a?(Core::Challenge::Null) }
|
|
124
|
+
if ::Rails.env.production?
|
|
125
|
+
check("Configure challenge hostname restrictions") { config.challenge.allowed_hostnames&.any? }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
check("Review the ejection manifest and changed upstream files") do
|
|
129
|
+
@ejections = Ejection.new(host_root: ::Rails.root).report
|
|
130
|
+
@ejections.none? { |entry| entry[:missing] || entry[:upstream_changed] }
|
|
131
|
+
end
|
|
132
|
+
@problems
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def columns(model, required, feature)
|
|
138
|
+
check("Run pending #{feature} migrations (required: #{required.join(", ")})") { model && (required - model.column_names).empty? }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def unique_index(model, column)
|
|
142
|
+
check("Add a unique #{model.name}.#{column} index") do
|
|
143
|
+
model.connection.indexes(model.table_name).any? { |index| index.unique && index.columns == [column] && !index.where }
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def check(message)
|
|
148
|
+
@problems << message unless yield
|
|
149
|
+
rescue
|
|
150
|
+
@problems << message
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module AddAuth
|
|
8
|
+
module Rails
|
|
9
|
+
class Ejection
|
|
10
|
+
MANIFEST = "config/add_auth-ejections.json"
|
|
11
|
+
VIEW_GROUPS = {"email_link" => %w[sign_ins], "sessions" => %w[sessions],
|
|
12
|
+
"step_up" => %w[reauthentications], "passkeys" => %w[passkeys recoveries]}.freeze
|
|
13
|
+
|
|
14
|
+
def initialize(host_root:, engine_root: File.expand_path("../../..", __dir__))
|
|
15
|
+
@host, @engine = host_root.to_s, engine_root.to_s
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def files(kind:, only: "all")
|
|
19
|
+
case kind
|
|
20
|
+
when :views
|
|
21
|
+
groups = (only == "all") ? VIEW_GROUPS.keys : only.split(",")
|
|
22
|
+
raise ArgumentError, "unknown view group" unless (groups - VIEW_GROUPS.keys).empty?
|
|
23
|
+
groups.flat_map { |group| VIEW_GROUPS.fetch(group) }.flat_map do |directory|
|
|
24
|
+
glob("app/views/add_auth/#{directory}/**/*.*erb")
|
|
25
|
+
end.concat(glob("app/views/layouts/add_auth/**/*.*erb")).uniq.to_h { |path| [path, path] }
|
|
26
|
+
when :controllers
|
|
27
|
+
glob("app/controllers/add_auth/*_controller.rb").reject { |path| path.end_with?("assets_controller.rb") }.to_h { |path| [path, path] }
|
|
28
|
+
when :javascript
|
|
29
|
+
%w[application codec passkey].to_h do |name|
|
|
30
|
+
["app/javascript/add_auth/#{name}.js", "lib/generators/add_auth/javascript/templates/#{name}.js"]
|
|
31
|
+
end.merge("app/javascript/add_auth/challenge.js" => "lib/generators/add_auth/email_link/templates/add_auth_challenge.js")
|
|
32
|
+
when :mailer_views
|
|
33
|
+
glob("app/views/add_auth/*_mailer/*.*erb").to_h { |path| [path, path] }
|
|
34
|
+
else
|
|
35
|
+
raise ArgumentError, "unknown ejection kind"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def install(kind:, only: "all")
|
|
40
|
+
manifest = read_manifest
|
|
41
|
+
results = files(kind: kind, only: only).map do |destination, source|
|
|
42
|
+
body = File.read(File.join(@engine, source))
|
|
43
|
+
generated = decorate(body, destination)
|
|
44
|
+
path = File.join(@host, destination)
|
|
45
|
+
existed = File.exist?(path)
|
|
46
|
+
unless existed
|
|
47
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
48
|
+
File.write(path, generated)
|
|
49
|
+
end
|
|
50
|
+
# Reruns never advance the baseline of a file already owned by a host.
|
|
51
|
+
manifest[destination] ||= {"version" => AddAuth::VERSION, "source" => source,
|
|
52
|
+
"source_hash" => hash(body), "generated_hash" => hash(generated), "baseline" => body}
|
|
53
|
+
{path: destination, preserved: existed}
|
|
54
|
+
end
|
|
55
|
+
path = File.join(@host, MANIFEST)
|
|
56
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
57
|
+
temporary = "#{path}.#{Process.pid}.tmp"
|
|
58
|
+
File.write(temporary, JSON.pretty_generate({"files" => manifest}) + "\n")
|
|
59
|
+
File.rename(temporary, path)
|
|
60
|
+
results
|
|
61
|
+
ensure
|
|
62
|
+
FileUtils.rm_f(temporary) if temporary
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def report
|
|
66
|
+
inventory = %i[views controllers javascript mailer_views].each_with_object({}) { |kind, all| all.merge!(files(kind: kind)) }
|
|
67
|
+
read_manifest.map do |destination, entry|
|
|
68
|
+
unless inventory[destination] == entry["source"] && entry["baseline"].is_a?(String) && hash(entry["baseline"]) == entry["source_hash"]
|
|
69
|
+
raise AddAuth::Error, "invalid ejection manifest entry"
|
|
70
|
+
end
|
|
71
|
+
source = File.read(File.join(@engine, entry.fetch("source")))
|
|
72
|
+
path = File.join(@host, destination)
|
|
73
|
+
upstream_changed = hash(source) != entry.fetch("source_hash")
|
|
74
|
+
{path: destination, missing: !File.file?(path), customized: File.file?(path) && hash(File.read(path)) != entry["generated_hash"],
|
|
75
|
+
upstream_changed: upstream_changed,
|
|
76
|
+
diff: upstream_changed ? difference(destination, entry.fetch("baseline"), source, entry.fetch("version")) : nil}
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def asset(name)
|
|
81
|
+
destination = "app/javascript/add_auth/#{name}.js"
|
|
82
|
+
source = files(kind: :javascript).fetch(destination)
|
|
83
|
+
host_path = File.join(@host, destination)
|
|
84
|
+
File.file?(host_path) ? host_path : File.join(@engine, source)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def glob(pattern)
|
|
90
|
+
Dir.glob(File.join(@engine, pattern)).sort.map { |path| path.delete_prefix(@engine + "/") }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def hash(body) = ::Digest::SHA256.hexdigest(body)
|
|
94
|
+
|
|
95
|
+
def decorate(body, path)
|
|
96
|
+
text = "AddAuth ejection: #{AddAuth::VERSION}; source SHA256: #{hash(body)}"
|
|
97
|
+
prefix = if path.end_with?(".erb")
|
|
98
|
+
"<%# #{text} %>"
|
|
99
|
+
elsif path.end_with?(".js")
|
|
100
|
+
"// #{text}"
|
|
101
|
+
else
|
|
102
|
+
"# #{text}"
|
|
103
|
+
end
|
|
104
|
+
"#{prefix}\n#{body}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def read_manifest
|
|
108
|
+
path = File.join(@host, MANIFEST)
|
|
109
|
+
return {} unless File.exist?(path)
|
|
110
|
+
raise AddAuth::Error, "ejection manifest is too large" if File.size(path) > 5_000_000
|
|
111
|
+
value = JSON.parse(File.read(path)).fetch("files")
|
|
112
|
+
raise AddAuth::Error, "invalid ejection manifest" unless value.is_a?(Hash) && value.values.all? { |entry| entry.is_a?(Hash) }
|
|
113
|
+
value
|
|
114
|
+
rescue JSON::ParserError, KeyError
|
|
115
|
+
raise AddAuth::Error, "invalid ejection manifest"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# A valid unified replacement hunk, trimmed to three unchanged context
|
|
119
|
+
# lines at each end. Compare pristine upstream versions, never host secrets.
|
|
120
|
+
def difference(path, before, after, version)
|
|
121
|
+
old, current = before.lines, after.lines
|
|
122
|
+
prefix = 0
|
|
123
|
+
prefix += 1 while prefix < [old.size, current.size].min && old[prefix] == current[prefix]
|
|
124
|
+
suffix = 0
|
|
125
|
+
suffix += 1 while suffix < [old.size, current.size].min - prefix && old[-suffix - 1] == current[-suffix - 1]
|
|
126
|
+
first = [prefix - 3, 0].max
|
|
127
|
+
tail = [suffix - 3, 0].max
|
|
128
|
+
old_part = old[first...(old.size - tail)]
|
|
129
|
+
new_part = current[first...(current.size - tail)]
|
|
130
|
+
old_start = old_part.empty? ? first : first + 1
|
|
131
|
+
new_start = new_part.empty? ? first : first + 1
|
|
132
|
+
header = "--- #{path} (#{version})\n+++ #{path} (#{AddAuth::VERSION})\n"
|
|
133
|
+
header += "@@ -#{old_start},#{old_part.size} +#{new_start},#{new_part.size} @@\n"
|
|
134
|
+
header + old_part.map { |line| "-#{line}" }.join + new_part.map { |line| "+#{line}" }.join
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "add_auth/rails/runtime"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Rails
|
|
7
|
+
module Elevation
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
class_methods do
|
|
11
|
+
# A navigation guard, not mutation authorization. Use the block helper
|
|
12
|
+
# below for writes, alongside the host's resource authorization.
|
|
13
|
+
def require_elevated_session(purpose:, **options)
|
|
14
|
+
before_action(**options) { add_auth_require_elevation(purpose) }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def with_elevated_session(purpose:, &block)
|
|
21
|
+
raise ArgumentError, "a database mutation block is required" unless block
|
|
22
|
+
Runtime.sessions.with_elevation(user: Current.user, session: Current.session,
|
|
23
|
+
purpose: purpose, policy: Runtime.step_up_policy, &block)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def add_auth_require_elevation(purpose)
|
|
27
|
+
return require_authentication unless authenticated?
|
|
28
|
+
result = Runtime.sessions.with_elevation(user: Current.user, session: Current.session,
|
|
29
|
+
purpose: purpose, policy: Runtime.step_up_policy)
|
|
30
|
+
return if result.success?
|
|
31
|
+
redirect_to "/reauthenticate?#{URI.encode_www_form(purpose: purpose)}", status: request.get? ? :found : :see_other
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "turbo-rails"
|
|
4
|
+
require "add_auth/rails/authentication_pages"
|
|
5
|
+
|
|
6
|
+
module AddAuth
|
|
7
|
+
module Rails
|
|
8
|
+
# Non-isolated: host controllers/views remain in the host namespace.
|
|
9
|
+
class Engine < ::Rails::Engine
|
|
10
|
+
engine_name "add_auth"
|
|
11
|
+
|
|
12
|
+
initializer "add_auth.filter_parameters" do |app|
|
|
13
|
+
app.config.filter_parameters += [:token, :password, :delivery_payload, :token_digest, :encrypted_identifier, :browser_digest, :session_digest, :elevation_version, :add_auth_browser, :credential, :transaction, :challenge, :external_id, :public_key, :"cf-turnstile-response", :"g-recaptcha-response"]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
config.to_prepare do
|
|
17
|
+
if AddAuth.configuration.session.enabled
|
|
18
|
+
require "add_auth/rails/password_entry"
|
|
19
|
+
require "add_auth/rails/elevation"
|
|
20
|
+
::ApplicationController.include AddAuth::Rails::Elevation
|
|
21
|
+
if AddAuth.configuration.passwords_enabled || defined?(::SessionsController)
|
|
22
|
+
::SessionsController.include AddAuth::Rails::PasswordEntry
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
initializer "add_auth.configuration", before: :load_config_initializers do |app|
|
|
28
|
+
AddAuth.configuration.digest_secret ||= -> {
|
|
29
|
+
app.key_generator.generate_key("add_auth.digest-base-secret.v1", 32)
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "add_auth/rails/authentication_pages"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Rails
|
|
7
|
+
# Protects every route to the generated SessionsController#create, including
|
|
8
|
+
# host aliases. Keep the host file intact, but make the shared entry point the
|
|
9
|
+
# authority. Verify CSRF explicitly before rendering halts later callbacks.
|
|
10
|
+
module PasswordEntry
|
|
11
|
+
extend ActiveSupport::Concern
|
|
12
|
+
include AuthenticationPages
|
|
13
|
+
|
|
14
|
+
included do
|
|
15
|
+
helper AddAuth::SignInsHelper
|
|
16
|
+
protect_from_forgery with: :exception
|
|
17
|
+
prepend_before_action :add_auth_password_entry, only: %i[new create]
|
|
18
|
+
rescue_from AddAuth::Error, with: :service_unavailable
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def add_auth_password_entry
|
|
24
|
+
if action_name == "new"
|
|
25
|
+
private_response
|
|
26
|
+
return page("form")
|
|
27
|
+
end
|
|
28
|
+
verify_authenticity_token
|
|
29
|
+
private_response
|
|
30
|
+
add_auth_password_sign_in
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require "add_auth/rails/stores/sessions"
|
|
5
|
+
require "add_auth/rails/stores/email_tokens"
|
|
6
|
+
require "add_auth/rails/delivery_cipher"
|
|
7
|
+
|
|
8
|
+
module AddAuth
|
|
9
|
+
module Rails
|
|
10
|
+
module Runtime
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def config = AddAuth.configuration
|
|
14
|
+
|
|
15
|
+
def sessions
|
|
16
|
+
Core::Sessions.new(store: Stores::Sessions.new(user_model: ::User, session_model: ::Session),
|
|
17
|
+
digest: config.session_token_digest, eligible: config.eligible, lifetime: config.session.lifetime,
|
|
18
|
+
idle_timeout: config.session.idle_timeout, legacy_bridge_until: config.session.legacy_bridge_until, access_policy: access_policy)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def security_events
|
|
22
|
+
raise AddAuth::Error, "enable notifications first" unless config.notifications.enabled
|
|
23
|
+
require "add_auth/rails/stores/security_events"
|
|
24
|
+
Core::SecurityEvents.new(store: Stores::SecurityEvents.new(user_model: ::User, event_model: ::AddAuthSecurityEvent),
|
|
25
|
+
digest: config.sign_in_token_digest, delivery_cipher: DeliveryCipher.new(key: key("security-notifications")))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def access_policy
|
|
29
|
+
Core::AccessPolicy.new(credentials: ->(id) {
|
|
30
|
+
::AddAuthCredential.find_by(external_id: id) if defined?(::AddAuthCredential) && ::AddAuthCredential.table_exists?
|
|
31
|
+
}, passkeys_enabled: config.passkeys.enabled, email_enabled: Core::Intake.email_available?(config),
|
|
32
|
+
trusted_recovery_address: config.trusted_recovery_address, password_enabled: config.passwords_enabled)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def email(purpose: :sign_in)
|
|
36
|
+
if purpose.to_s != "sign_in" && !config.step_up.enabled
|
|
37
|
+
raise AddAuth::Error, "enable step_up first"
|
|
38
|
+
end
|
|
39
|
+
raise AddAuth::Error, "enable session_upgrade and email_link first" unless Core::Intake.email_available?(config)
|
|
40
|
+
Core::Strategies::EmailLink.new(store: Stores::EmailTokens.new(user_model: ::User,
|
|
41
|
+
token_model: ::AddAuthSignInToken, session_model: ::Session), digest: config.sign_in_token_digest,
|
|
42
|
+
delivery_cipher: DeliveryCipher.new(key: key("delivery")), eligible: config.eligible,
|
|
43
|
+
normalize_identifier: method(:normalize), identifier_for: ->(user) { user.email_address },
|
|
44
|
+
token_lifetime: {"reauthentication" => 300, "recovery" => 1200}.fetch(purpose.to_s, config.email_link.token_lifetime),
|
|
45
|
+
same_browser: (purpose.to_s == "recovery") ? false : config.email_link.same_browser, purpose: purpose,
|
|
46
|
+
proof_allowed: ->(user) { (purpose.to_s == "recovery") ? access_policy.recoverable?(user) && config.passkeys.enabled : purpose.to_s != "sign_in" || access_policy.sign_in_allowed?(user, :email_link) },
|
|
47
|
+
sessions: (purpose.to_s == "sign_in") ? nil : sessions,
|
|
48
|
+
policy: (purpose.to_s == "sign_in") ? nil : step_up_policy)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def revoke_all(user:, session:, **proof)
|
|
52
|
+
policy = step_up_policy
|
|
53
|
+
current = sessions.with_elevation(user: user, session: session, purpose: :sign_out_everywhere, policy: policy)
|
|
54
|
+
if current.success?
|
|
55
|
+
count = sessions.revoke_all(user: user, session: session, grant: current.credential)
|
|
56
|
+
return count ? Result.success(user: user, strategy: :step_up) : Result.failure(reason: :elevation_required)
|
|
57
|
+
end
|
|
58
|
+
authorization = reauthenticate(user: user, session: session, **proof)
|
|
59
|
+
return authorization unless authorization.success?
|
|
60
|
+
count = sessions.revoke_all(user: user, session: session, grant: authorization.credential)
|
|
61
|
+
count ? Result.success(user: user, strategy: :step_up) : Result.failure(reason: :elevation_required)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def reauthenticate(user:, session:, password:, ip:, challenge_token:)
|
|
65
|
+
admitted = intake.call(identifier: user.email_address, ip: ip, action: :reauthenticate, challenge_token: challenge_token)
|
|
66
|
+
return Result.failure(reason: admitted) if admitted.is_a?(Symbol)
|
|
67
|
+
policy = config.step_up.enabled ? step_up_policy : Core::StepUp.new(purposes: {sign_out_everywhere: {methods: [:password]}})
|
|
68
|
+
sessions.reauthenticate(user: user, session: session, purpose: :sign_out_everywhere, policy: policy) do |account|
|
|
69
|
+
authenticate_password(identifier: account.email_address, password: password)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def step_up_purposes
|
|
74
|
+
defaults = if config.passkeys.enabled
|
|
75
|
+
{
|
|
76
|
+
manage_passkeys: {methods: [:password, :email_link, :passkey], return_to: "/passkeys", label: "manage your passkeys"},
|
|
77
|
+
manage_policy: {methods: [:passkey], require_passkey: true, return_to: "/passkeys", label: "change your recovery policy"},
|
|
78
|
+
recover_passkeys: {methods: [:email_link], return_to: "/passkeys", label: "replace a lost passkey", reauthentication: false}
|
|
79
|
+
}
|
|
80
|
+
else
|
|
81
|
+
{}
|
|
82
|
+
end
|
|
83
|
+
{sign_out_everywhere: {methods: [:password, :email_link, :passkey], return_to: "/sessions/revoke-all", label: "sign out everywhere"}}.merge(defaults).merge(config.step_up.purposes)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def passkeys
|
|
87
|
+
raise AddAuth::Error, "enable passkeys and their prerequisites first" unless config.passkeys.enabled && config.step_up.enabled && config.notifications.enabled
|
|
88
|
+
require "add_auth/rails/stores/passkeys"
|
|
89
|
+
Core::Strategies::Passkey.new(store: Stores::Passkeys.new(user_model: ::User, session_model: ::Session,
|
|
90
|
+
credential_model: ::AddAuthCredential, ceremony_model: ::AddAuthCeremony, token_model: ::AddAuthSignInToken), sessions: sessions,
|
|
91
|
+
policy: step_up_policy, access_policy: access_policy, digest: config.sign_in_token_digest,
|
|
92
|
+
eligible: config.eligible, rp_id: config.passkeys.rp_id, origins: config.passkeys.origins, name: config.passkeys.name,
|
|
93
|
+
limiter: method(:limit), anonymous_limit: config.passkeys.anonymous_limit,
|
|
94
|
+
notify: ->(**event) { security_events.issue(**event) }, allow_localhost: !::Rails.env.production?, support_url: config.support_url,
|
|
95
|
+
on_failure: ->(reason) { ::ActiveSupport::Notifications.instrument("passkey_failure.add_auth", reason: reason) })
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def step_up_policy
|
|
99
|
+
Core::StepUp.new(purposes: config.step_up.enabled ? step_up_purposes : {}, fresh_for: config.step_up.fresh_for,
|
|
100
|
+
strong_for: config.step_up.strong_for,
|
|
101
|
+
password_version: ->(user) { config.sign_in_token_digest.digest("password:#{user.password_digest}") },
|
|
102
|
+
credential_current: ->(user:, evidence:) {
|
|
103
|
+
evidence.method == :password ||
|
|
104
|
+
(evidence.method == :passkey && access_policy.credential_current?(user: user, id: evidence.credential_id) &&
|
|
105
|
+
::AddAuthCredential.find_by(external_id: evidence.credential_id)&.public_key == evidence.credential_version) ||
|
|
106
|
+
(evidence.method == :email_link && Core::Intake.email_available?(config) &&
|
|
107
|
+
config.sign_in_token_digest.matches?(evidence.credential_version, "identifier:#{normalize(user.email_address)}"))
|
|
108
|
+
}, methods_available: ->(user) { access_policy.methods_for(user) })
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def elevate_password(user:, session:, purpose:, password:, ip:, challenge_token:)
|
|
112
|
+
return Result.failure(reason: :elevation_required) unless step_up_policy.reauthentication_rule_for(purpose)
|
|
113
|
+
admitted = intake.call(identifier: user.email_address, ip: ip, action: :reauthenticate, challenge_token: challenge_token)
|
|
114
|
+
return Result.failure(reason: admitted) if admitted.is_a?(Symbol)
|
|
115
|
+
proof = sessions.reauthenticate(user: user, session: session, purpose: purpose, policy: step_up_policy) do |account|
|
|
116
|
+
authenticate_password(identifier: account.email_address, password: password)
|
|
117
|
+
end
|
|
118
|
+
return proof unless proof.success?
|
|
119
|
+
grant = sessions.rotate_for_step_up(user: user, session: session, grant: proof.credential)
|
|
120
|
+
grant ? Result.success(user: user, strategy: :step_up, session: grant.session, credential: grant) : Result.failure(reason: :elevation_required)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def enqueue_recovery(identifier) = enqueue_email_payload({identifier: identifier, purpose: "recovery"})
|
|
124
|
+
|
|
125
|
+
def enqueue_reauthentication(user:, session:, purpose:, browser_secret:)
|
|
126
|
+
payload = {identifier: user.email_address, purpose: "reauthentication", authentication_purpose: purpose.to_s,
|
|
127
|
+
session_id: session.id, session_digest: session.token_digest, browser_digest: browser_binding.digest(browser_secret)}
|
|
128
|
+
enqueue_email_payload(payload)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def authenticate_password(identifier:, password:)
|
|
132
|
+
return unless config.passwords_enabled && ::User.respond_to?(:authenticate_by)
|
|
133
|
+
::User.authenticate_by(email_address: identifier, password: password.is_a?(String) ? password : "")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def sign_in_path = "/sign-in"
|
|
137
|
+
|
|
138
|
+
def normalize(value) = ::User.normalize_value_for(:email_address, value)
|
|
139
|
+
def key(purpose) = ::Rails.application.key_generator.generate_key("add_auth.#{purpose}.v1", 32)
|
|
140
|
+
|
|
141
|
+
def intake_cipher
|
|
142
|
+
ActiveSupport::MessageEncryptor.new(key("intake"), cipher: "aes-256-gcm", serializer: :json)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def browser_binding = Core::BrowserBinding.new(digest: config.sign_in_token_digest)
|
|
146
|
+
|
|
147
|
+
def enqueue_email(identifier, browser_secret: nil)
|
|
148
|
+
payload = {identifier: identifier, browser_digest: email.browser_digest(browser_secret)}
|
|
149
|
+
enqueue_email_payload(payload)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def enqueue_email_payload(payload)
|
|
153
|
+
job = ::AddAuth::EmailRequestJob.perform_later(encrypt_intake(payload))
|
|
154
|
+
raise AddAuth::Error, "sign-in queue unavailable" unless job
|
|
155
|
+
job
|
|
156
|
+
rescue
|
|
157
|
+
raise AddAuth::Error, "sign-in queue unavailable", cause: nil
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def encrypt_intake(identifier)
|
|
161
|
+
intake_cipher.encrypt_and_sign(identifier, purpose: "sign_in_request", expires_in: 300)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def decrypt_intake(payload)
|
|
165
|
+
intake_cipher.decrypt_and_verify(payload, purpose: "sign_in_request")
|
|
166
|
+
rescue ActiveSupport::MessageEncryptor::InvalidMessage
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def intake
|
|
171
|
+
Core::Intake.new(digest: config.sign_in_token_digest, normalizer: method(:normalize),
|
|
172
|
+
limiter: method(:limit), challenge: config.challenge, challenge_on: config.challenge_on,
|
|
173
|
+
challenge_when_unavailable: config.challenge_when_unavailable,
|
|
174
|
+
on_challenge_unavailable: method(:record_challenge_bypass))
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def record_challenge_bypass(action:)
|
|
178
|
+
::ActiveSupport::Notifications.instrument("challenge_bypass.add_auth", action: action)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def limit(key:, limit:)
|
|
182
|
+
Core::RateLimit.new(counter: ->(key:, expires_in:) {
|
|
183
|
+
rate_limit_cache.increment(key, 1, expires_in: expires_in, initial: 0)
|
|
184
|
+
}).call(key: key, limit: limit)
|
|
185
|
+
rescue
|
|
186
|
+
raise AddAuth::Error, "rate limit store unavailable", cause: nil
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def rate_limit_cache = config.rate_limit_store || ::Rails.cache
|
|
190
|
+
|
|
191
|
+
def maintenance_cache_key = "add_auth:maintenance:v1:#{config.sign_in_token_digest.digest("last-success")}"
|
|
192
|
+
|
|
193
|
+
def record_maintenance
|
|
194
|
+
unless rate_limit_cache.write(maintenance_cache_key, Time.now.to_i, expires_in: 180)
|
|
195
|
+
raise AddAuth::Error, "maintenance heartbeat store unavailable"
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def maintenance_current?
|
|
200
|
+
completed = rate_limit_cache.read(maintenance_cache_key)
|
|
201
|
+
completed.is_a?(Integer) && (0..120).cover?(Time.now.to_i - completed)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def sign_in_url(token, purpose: "sign_in")
|
|
205
|
+
uri = URI.parse(config.base_url.to_s)
|
|
206
|
+
valid = uri.host && !uri.userinfo && !uri.query && !uri.fragment && ["", "/"].include?(uri.path) &&
|
|
207
|
+
(uri.scheme == "https" || (!::Rails.env.production? && uri.scheme == "http"))
|
|
208
|
+
unless valid
|
|
209
|
+
raise AddAuth::Error, "configure base_url as a fixed HTTPS origin (HTTP allowed only outside production)"
|
|
210
|
+
end
|
|
211
|
+
path = {"sign_in" => "/sign-in/link", "reauthentication" => "/reauthenticate/link", "recovery" => "/recover/link"}.fetch(purpose.to_s)
|
|
212
|
+
"#{uri.to_s.delete_suffix("/")}#{path}?token=#{URI.encode_www_form_component(token)}"
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Rails
|
|
5
|
+
module Stores
|
|
6
|
+
class AccountLock
|
|
7
|
+
def initialize(user_model)
|
|
8
|
+
@users = user_model
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(id:, additional_id: nil)
|
|
12
|
+
return yield nil unless id
|
|
13
|
+
if @users.connection.transaction_open?
|
|
14
|
+
raise AddAuth::Error, "authentication must own its transaction, not run inside an outer transaction"
|
|
15
|
+
end
|
|
16
|
+
completed = false
|
|
17
|
+
result = nil
|
|
18
|
+
@users.uncached do
|
|
19
|
+
@users.transaction(requires_new: true) do |transaction|
|
|
20
|
+
transaction.after_commit { completed = true }
|
|
21
|
+
# Opposing account switches acquire their locks in the same order.
|
|
22
|
+
accounts = [id, additional_id].compact.uniq.sort_by(&:to_s).map do |account_id|
|
|
23
|
+
if @users.connection.adapter_name == "SQLite"
|
|
24
|
+
pk = @users.connection.quote_column_name(@users.primary_key)
|
|
25
|
+
@users.where(@users.primary_key => account_id).update_all("#{pk} = #{pk}")
|
|
26
|
+
@users.find_by(@users.primary_key => account_id)
|
|
27
|
+
else
|
|
28
|
+
@users.lock.find_by(@users.primary_key => account_id)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
result = yield accounts.compact.find { |account| account.id == id }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
raise AddAuth::Error, "authentication transaction rolled back" unless completed
|
|
35
|
+
result
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|