add_auth 0.2.2 → 0.3.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 +22 -0
- data/README.md +38 -14
- data/ROADMAP.md +87 -38
- data/SECURITY.md +6 -0
- data/app/controllers/add_auth/accounts_controller.rb +138 -0
- data/app/controllers/add_auth/assets_controller.rb +2 -1
- data/app/controllers/add_auth/external_identities_controller.rb +56 -0
- data/app/controllers/add_auth/mobile_sessions_controller.rb +120 -0
- data/app/controllers/add_auth/provider_sign_ins_controller.rb +248 -0
- data/app/helpers/add_auth/sign_ins_helper.rb +2 -1
- data/app/jobs/add_auth/account_delivery_job.rb +14 -0
- data/app/jobs/add_auth/account_request_job.rb +17 -0
- data/app/mailers/add_auth/account_mailer.rb +12 -0
- data/app/views/add_auth/account_mailer/link.text.erb +7 -0
- data/app/views/add_auth/accounts/_check_email.html.erb +5 -0
- data/app/views/add_auth/accounts/_delete.html.erb +6 -0
- data/app/views/add_auth/accounts/_email.html.erb +12 -0
- data/app/views/add_auth/accounts/_invalid_proof.html.erb +5 -0
- data/app/views/add_auth/accounts/_password.html.erb +12 -0
- data/app/views/add_auth/accounts/_proof.html.erb +16 -0
- data/app/views/add_auth/accounts/_register.html.erb +17 -0
- data/app/views/add_auth/accounts/_request.html.erb +14 -0
- data/app/views/add_auth/accounts/_unavailable.html.erb +3 -0
- data/app/views/add_auth/external_identities/_index.html.erb +22 -0
- data/app/views/add_auth/external_identities/_unavailable.html.erb +3 -0
- data/app/views/add_auth/passkeys/_controls.html.erb +3 -1
- data/app/views/add_auth/provider_sign_ins/_enrollment.html.erb +12 -0
- data/app/views/add_auth/provider_sign_ins/_failure.html.erb +3 -0
- data/app/views/add_auth/provider_sign_ins/_prepare.html.erb +7 -0
- data/app/views/add_auth/reauthentications/_form.html.erb +9 -0
- data/app/views/add_auth/sessions/_list.html.erb +1 -1
- data/app/views/add_auth/sessions/index.html.erb +7 -0
- data/app/views/add_auth/sign_ins/_confirmation.html.erb +1 -0
- data/app/views/add_auth/sign_ins/_form.html.erb +17 -0
- data/app/views/add_auth/sign_ins/_remember.html.erb +6 -0
- data/app/views/layouts/add_auth/authentication.html.erb +4 -3
- data/exe/add_auth-preflight +26 -0
- data/lib/add_auth/configuration.rb +78 -4
- data/lib/add_auth/core/access_policy.rb +11 -1
- data/lib/add_auth/core/account_lifecycle.rb +251 -0
- data/lib/add_auth/core/account_policy.rb +50 -0
- data/lib/add_auth/core/external_identities/evidence.rb +98 -0
- data/lib/add_auth/core/external_identities.rb +278 -0
- data/lib/add_auth/core/intake.rb +6 -1
- data/lib/add_auth/core/maintenance.rb +5 -3
- data/lib/add_auth/core/migration/account_adoption.rb +79 -0
- data/lib/add_auth/core/migration/readiness.rb +41 -0
- data/lib/add_auth/core/mobile_authentication.rb +35 -0
- data/lib/add_auth/core/mobile_handoffs.rb +83 -0
- data/lib/add_auth/core/mobile_profile.rb +46 -0
- data/lib/add_auth/core/mobile_response.rb +12 -0
- data/lib/add_auth/core/native_authentication.rb +77 -0
- data/lib/add_auth/core/passwords/bcrypt_support.rb +28 -0
- data/lib/add_auth/core/passwords/credential.rb +49 -0
- data/lib/add_auth/core/passwords/legacy_bcrypt.rb +43 -0
- data/lib/add_auth/core/passwords/lifecycle.rb +60 -0
- data/lib/add_auth/core/remaining_factors.rb +24 -0
- data/lib/add_auth/core/sessions.rb +102 -17
- data/lib/add_auth/core/step_up.rb +13 -1
- data/lib/add_auth/migration/static_inventory.rb +116 -0
- data/lib/add_auth/rails/account_password_entry.rb +24 -0
- data/lib/add_auth/rails/authentication.rb +1 -1
- data/lib/add_auth/rails/authentication_pages.rb +5 -0
- data/lib/add_auth/rails/doctor.rb +70 -3
- data/lib/add_auth/rails/ejection.rb +1 -1
- data/lib/add_auth/rails/engine.rb +6 -1
- data/lib/add_auth/rails/migration/account_store.rb +41 -0
- data/lib/add_auth/rails/migration/effective_inventory.rb +162 -0
- data/lib/add_auth/rails/migration/key_type.rb +23 -0
- data/lib/add_auth/rails/migration/source_bridge.rb +53 -0
- data/lib/add_auth/rails/password_adoption.rb +37 -0
- data/lib/add_auth/rails/provider_enrollment.rb +61 -0
- data/lib/add_auth/rails/provider_libraries/apple_form_post_correlation.rb +127 -0
- data/lib/add_auth/rails/provider_libraries/apple_jwks.rb +68 -0
- data/lib/add_auth/rails/provider_libraries/apple_native.rb +55 -0
- data/lib/add_auth/rails/provider_libraries/callback_route.rb +16 -0
- data/lib/add_auth/rails/provider_libraries/omniauth.rb +136 -0
- data/lib/add_auth/rails/provider_libraries/omniauth_correlation.rb +107 -0
- data/lib/add_auth/rails/provider_libraries/openid_connect.rb +62 -0
- data/lib/add_auth/rails/provider_libraries/request_protection.rb +43 -0
- data/lib/add_auth/rails/runtime.rb +170 -8
- data/lib/add_auth/rails/stores/account_credentials.rb +44 -0
- data/lib/add_auth/rails/stores/account_lock.rb +16 -7
- data/lib/add_auth/rails/stores/account_tokens.rb +96 -0
- data/lib/add_auth/rails/stores/authority.rb +28 -0
- data/lib/add_auth/rails/stores/commit_dispatch.rb +32 -0
- data/lib/add_auth/rails/stores/external_identities.rb +91 -0
- data/lib/add_auth/rails/stores/maintenance.rb +1 -1
- data/lib/add_auth/rails/stores/mobile_handoffs.rb +53 -0
- data/lib/add_auth/rails/stores/security_events.rb +3 -6
- data/lib/add_auth/rails/user_lifecycle.rb +8 -10
- data/lib/add_auth/result.rb +1 -0
- data/lib/add_auth/version.rb +1 -1
- data/lib/add_auth.rb +4 -0
- data/lib/generators/add_auth/accounts/accounts_generator.rb +52 -0
- data/lib/generators/add_auth/accounts/templates/add_add_auth_accounts.rb.tt +49 -0
- data/lib/generators/add_auth/accounts/templates/add_auth_account_token.rb +6 -0
- data/lib/generators/add_auth/accounts/templates/add_auth_address_claim.rb +6 -0
- data/lib/generators/add_auth/devise_accounts/devise_accounts_generator.rb +23 -0
- data/lib/generators/add_auth/devise_accounts/templates/prepare_add_auth_devise_accounts.rb.tt +17 -0
- data/lib/generators/add_auth/email_link/templates/add_auth_challenge.js +1 -0
- data/lib/generators/add_auth/email_tokens/templates/create_add_auth_sign_in_tokens.rb.tt +3 -1
- data/lib/generators/add_auth/external_identities/external_identities_generator.rb +52 -0
- data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_enrollment.rb.tt +5 -0
- data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_identity_validity.rb.tt +5 -0
- data/lib/generators/add_auth/external_identities/templates/add_auth_external_identity.rb +4 -0
- data/lib/generators/add_auth/external_identities/templates/add_auth_external_transaction.rb +6 -0
- data/lib/generators/add_auth/external_identities/templates/create_add_auth_external_identities.rb.tt +50 -0
- data/lib/generators/add_auth/external_identities/templates/providers.rb +51 -0
- data/lib/generators/add_auth/install/templates/initializer.rb +4 -0
- data/lib/generators/add_auth/javascript/templates/passkey.js +8 -5
- data/lib/generators/add_auth/mobile_sessions/mobile_sessions_generator.rb +48 -0
- data/lib/generators/add_auth/mobile_sessions/templates/add_add_auth_mobile_sessions.rb.tt +9 -0
- data/lib/generators/add_auth/mobile_sessions/templates/add_auth_mobile_handoff.rb +5 -0
- data/lib/generators/add_auth/mobile_sessions/templates/create_add_auth_mobile_handoffs.rb.tt +27 -0
- data/lib/generators/add_auth/mobile_sessions/templates/mobile.rb +8 -0
- data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +3 -1
- data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +7 -3
- data/lib/generators/add_auth/step_up/templates/add_add_auth_reauthentication.rb.tt +3 -1
- data/lib/tasks/add_auth.rake +18 -1
- metadata +85 -2
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ripper"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "add_auth/core/migration/readiness"
|
|
6
|
+
|
|
7
|
+
module AddAuth
|
|
8
|
+
module Migration
|
|
9
|
+
# Reads a bounded set of source files as data. Never require/load/eval a host.
|
|
10
|
+
class StaticInventory
|
|
11
|
+
MAX_FILES = 2000
|
|
12
|
+
MAX_BYTES = 1_048_576
|
|
13
|
+
TOTAL_BYTES = 16 * 1_048_576
|
|
14
|
+
PATTERNS = %w[app/models/**/*.rb app/controllers/**/*.rb app/services/**/*.rb app/jobs/**/*.rb app/workers/**/*.rb app/mailers/**/*.rb app/helpers/**/*.rb lib/**/*.rb lib/tasks/**/*.rake config/initializers/**/*.rb config/routes.rb db/schema.rb spec/factories/**/*.rb spec/support/**/*.rb].freeze
|
|
15
|
+
SURFACES = {
|
|
16
|
+
devise: %w[Devise devise devise_for devise_scope], warden: %w[warden Warden],
|
|
17
|
+
guards: %w[authenticate_user! current_user user_signed_in? authenticated],
|
|
18
|
+
passwords: %w[encrypted_password password_digest valid_password? password=],
|
|
19
|
+
tokens: %w[authentication_token MobileAuthToken reset_password_token confirmation_token unlock_token],
|
|
20
|
+
providers: %w[OmniAuth omniauth omniauth_callbacks],
|
|
21
|
+
eligibility: %w[active_for_authentication? inactive_message locked_at confirmed_at disabled_at]
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
def initialize(root:)
|
|
25
|
+
@root = File.realpath(root)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call
|
|
29
|
+
@complete = true
|
|
30
|
+
@remaining_bytes = TOTAL_BYTES
|
|
31
|
+
paths = PATTERNS.flat_map { |pattern| Dir.glob(File.join(@root, pattern)) }.uniq.sort
|
|
32
|
+
@complete = false if paths.length > MAX_FILES
|
|
33
|
+
sources = paths.first(MAX_FILES).filter_map do |path|
|
|
34
|
+
source = read(path)
|
|
35
|
+
next unless source
|
|
36
|
+
lexical = Ripper.lex(source)
|
|
37
|
+
tokens = lexical.reject { |(_, kind, _, _)| %i[on_comment on_tstring_content].include?(kind) }
|
|
38
|
+
literal_keys = lexical.filter_map { |(_, kind, value, _)| value if kind == :on_tstring_content && SURFACES.values.flatten.include?(value) }
|
|
39
|
+
tree = Ripper.sexp(source)
|
|
40
|
+
@complete = false unless tree
|
|
41
|
+
[relative(path), {tokens: tokens.map { |token| token[2] }, literal_keys: literal_keys, tree: tree}]
|
|
42
|
+
end.to_h
|
|
43
|
+
calls = sources.values.flat_map { |source| devise_calls(source[:tree]) }
|
|
44
|
+
names = calls.flatten
|
|
45
|
+
unknown = names.count { |name| !Core::Migration::Readiness::MODULES.include?(name) }
|
|
46
|
+
@complete = false if names.include?(nil)
|
|
47
|
+
lock = read(File.join(@root, "Gemfile.lock")) || ""
|
|
48
|
+
versions = %w[devise rails ruby bcrypt].to_h do |name|
|
|
49
|
+
version = if name == "ruby"
|
|
50
|
+
lock[/^ ruby (\d+\.\d+\.\d+)/, 1]
|
|
51
|
+
else
|
|
52
|
+
lock[/^ #{Regexp.escape(name)} \((\d+\.\d+(?:\.\d+)*(?:[.a-z0-9-]*))\)/, 1]
|
|
53
|
+
end
|
|
54
|
+
[name.to_sym, version]
|
|
55
|
+
end
|
|
56
|
+
extensions = Core::Migration::Readiness::EXTENSIONS.select { |name| lock.match?(/^ #{Regexp.escape(name)} \(/) }
|
|
57
|
+
targets = Core::Migration::Readiness::TARGETS.select { |path| sources.key?(path) }
|
|
58
|
+
user = sources.dig("app/models/user.rb", :tokens) || []
|
|
59
|
+
facts = {mode: "static", complete: @complete, versions: versions,
|
|
60
|
+
modules: (names.compact & Core::Migration::Readiness::MODULES).sort,
|
|
61
|
+
unknown_modules: unknown, extensions: extensions, scope_count: calls.length,
|
|
62
|
+
rails_contract: targets.length == Core::Migration::Readiness::TARGETS.length && user.include?("has_secure_password") && user.include?("normalizes"),
|
|
63
|
+
target_conflicts: targets, files_inspected: sources.length,
|
|
64
|
+
surfaces: SURFACES.transform_values { |terms| sources.filter_map { |path, source| path if ((source[:tokens] + source[:literal_keys]) & terms).any? } }}
|
|
65
|
+
Core::Migration::Readiness.new.call(facts)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def read(path)
|
|
71
|
+
# Reject symlinks at any depth, even those pointing to another host file.
|
|
72
|
+
current = Pathname.new(path)
|
|
73
|
+
while current.to_s != @root
|
|
74
|
+
return incomplete if current.symlink?
|
|
75
|
+
parent = current.parent
|
|
76
|
+
return incomplete if parent == current
|
|
77
|
+
current = parent
|
|
78
|
+
end
|
|
79
|
+
return incomplete unless File.file?(path)
|
|
80
|
+
return incomplete if @remaining_bytes <= 0
|
|
81
|
+
File.open(path, "rb") do |file|
|
|
82
|
+
limit = [MAX_BYTES, @remaining_bytes].min
|
|
83
|
+
source = file.read(limit + 1)
|
|
84
|
+
@remaining_bytes -= source.bytesize
|
|
85
|
+
return incomplete if source.bytesize > limit
|
|
86
|
+
source.force_encoding(Encoding::UTF_8)
|
|
87
|
+
return incomplete unless source.valid_encoding?
|
|
88
|
+
source
|
|
89
|
+
end
|
|
90
|
+
rescue SystemCallError
|
|
91
|
+
incomplete
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def incomplete
|
|
95
|
+
@complete = false
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def relative(path) = Pathname.new(path).relative_path_from(Pathname.new(@root)).to_s
|
|
100
|
+
|
|
101
|
+
def devise_calls(node)
|
|
102
|
+
return [] unless node.is_a?(Array)
|
|
103
|
+
arguments = if node[0] == :command && node.dig(1, 1) == "devise"
|
|
104
|
+
node[2]
|
|
105
|
+
elsif node[0] == :method_add_arg && node.dig(1, 0) == :fcall && node.dig(1, 1, 1) == "devise"
|
|
106
|
+
node.dig(2, 1)
|
|
107
|
+
end
|
|
108
|
+
if arguments
|
|
109
|
+
args = (arguments[0] == :args_add_block) ? arguments[1] : []
|
|
110
|
+
return [args.reject { |arg| arg[0] == :bare_assoc_hash }.map { |arg| (arg[0] == :symbol_literal) ? arg.dig(1, 1, 1) : nil }]
|
|
111
|
+
end
|
|
112
|
+
node.flat_map { |child| child.is_a?(Array) ? devise_calls(child) : [] }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Rails
|
|
5
|
+
module AccountPasswordEntry
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
protect_from_forgery with: :exception
|
|
10
|
+
prepend_before_action :add_auth_account_password_entry, only: %i[new create edit update]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def add_auth_account_password_entry
|
|
16
|
+
return unless AddAuth.configuration.lifecycle.enabled
|
|
17
|
+
verify_authenticity_token unless request.get? || request.head?
|
|
18
|
+
response.set_header("Cache-Control", "no-store")
|
|
19
|
+
response.set_header("Referrer-Policy", "no-referrer")
|
|
20
|
+
redirect_to "/account/requests/reset_password", status: request.get? ? :found : :see_other
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -27,7 +27,7 @@ module AddAuth
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def add_auth_session_hints
|
|
30
|
-
{user_agent: request.user_agent.to_s.truncate_bytes(512), ip_address: request.remote_ip}
|
|
30
|
+
{user_agent: request.user_agent.to_s.truncate_bytes(512), ip_address: request.remote_ip, remember: params[:remember] == "1"}
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def add_auth_replacement_session
|
|
@@ -44,6 +44,11 @@ module AddAuth
|
|
|
44
44
|
|
|
45
45
|
def page(partial, status: :ok)
|
|
46
46
|
@page_partial = authentication_partial(partial)
|
|
47
|
+
# Turbo carries the form's stream Accept header across a 303. Prefer
|
|
48
|
+
# its HTML option on the destination GET so navigation commits the URL.
|
|
49
|
+
if (request.get? || request.head?) && request.accepts.include?(Mime[:html])
|
|
50
|
+
return render "add_auth/sign_ins/show", layout: "add_auth/authentication", formats: [:html], content_type: "text/html", status: status
|
|
51
|
+
end
|
|
47
52
|
respond_to do |format|
|
|
48
53
|
format.html { render "add_auth/sign_ins/show", layout: "add_auth/authentication", status: status }
|
|
49
54
|
format.turbo_stream { render turbo_stream: turbo_stream.update("add_auth-content", partial: @page_partial), status: status }
|
|
@@ -33,6 +33,7 @@ module AddAuth
|
|
|
33
33
|
end
|
|
34
34
|
check("Include AddAuth::Rails::UserLifecycle in User") { ::User < UserLifecycle }
|
|
35
35
|
check("Set passwords_enabled to true or false") { [true, false].include?(config.passwords_enabled) }
|
|
36
|
+
check("Set turbo_enabled to true or false") { [true, false].include?(config.turbo_enabled) }
|
|
36
37
|
if config.passwords_enabled || defined?(::SessionsController)
|
|
37
38
|
check("Install the shared password entry in SessionsController") { ::SessionsController < PasswordEntry }
|
|
38
39
|
end
|
|
@@ -63,6 +64,25 @@ module AddAuth
|
|
|
63
64
|
end
|
|
64
65
|
end
|
|
65
66
|
end
|
|
67
|
+
if config.mobile.enabled
|
|
68
|
+
columns(::Session, %w[transport client_id mobile_idle_timeout], "mobile session")
|
|
69
|
+
check("Configure explicit bounded mobile timeouts and registered client identifiers") { Runtime.mobile_profile }
|
|
70
|
+
check("Install mobile session routes") { ::Rails.application.routes.recognize_path("/mobile/session", method: :post) }
|
|
71
|
+
check("Configure mobile callbacks and Apple providers as explicit maps") do
|
|
72
|
+
config.mobile.callbacks.is_a?(Hash) && config.mobile.apple_providers.is_a?(Hash)
|
|
73
|
+
end
|
|
74
|
+
if config.mobile.callbacks.is_a?(Hash) && config.mobile.callbacks.any?
|
|
75
|
+
check("Enable reviewed provider integration before mobile handoffs") { config.external_identities.enabled }
|
|
76
|
+
columns(defined?(::AddAuthMobileHandoff) && ::AddAuthMobileHandoff,
|
|
77
|
+
%w[user_id external_digest digest client_id callback state challenge_digest credential_id credential_version policy_version authenticated_at issued_at expires_at consumed_at], "mobile handoff")
|
|
78
|
+
unique_index(::AddAuthMobileHandoff, "digest") if defined?(::AddAuthMobileHandoff)
|
|
79
|
+
unique_index(::AddAuthMobileHandoff, "external_digest") if defined?(::AddAuthMobileHandoff)
|
|
80
|
+
check("Use one database pool for mobile handoffs, accounts and sessions") { Runtime.mobile_handoffs }
|
|
81
|
+
end
|
|
82
|
+
if config.mobile.apple_providers.is_a?(Hash) && config.mobile.apple_providers.any?
|
|
83
|
+
check("Configure the optional native Apple library and exact client mapping") { Runtime.native_apple }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
66
86
|
if config.email_link.enabled
|
|
67
87
|
check("Enable session_upgrade before email_link") { config.session.enabled }
|
|
68
88
|
check("Configure base_url as a fixed HTTPS origin (HTTP only outside production)") { Runtime.sign_in_url("validation") }
|
|
@@ -75,7 +95,54 @@ module AddAuth
|
|
|
75
95
|
columns(defined?(::AddAuthSignInToken) && ::AddAuthSignInToken, ["browser_digest"], "email browser binding")
|
|
76
96
|
end
|
|
77
97
|
end
|
|
78
|
-
if config.
|
|
98
|
+
if config.external_identities.enabled
|
|
99
|
+
check("Enable session and step-up prerequisites for provider sign-in") { config.session.enabled && config.step_up.enabled }
|
|
100
|
+
check("Declare current password verifier support metadata") { config.current_password_support.respond_to?(:available?) }
|
|
101
|
+
if config.external_identities.providers.any?
|
|
102
|
+
require "add_auth/rails/provider_libraries/request_protection"
|
|
103
|
+
check("Configure a supported OmniAuth request validator") { !!ProviderLibraries::RequestProtection.validator }
|
|
104
|
+
end
|
|
105
|
+
check("Register at least one reviewed provider profile") { config.external_identities.configurations.any? }
|
|
106
|
+
columns(defined?(::AddAuthExternalIdentity) && ::AddAuthExternalIdentity,
|
|
107
|
+
%w[user_id namespace provider_id issuer audience subject provenance credential_version linked_at revoked_at invalidated_at], "external identity")
|
|
108
|
+
columns(defined?(::AddAuthExternalTransaction) && ::AddAuthExternalTransaction,
|
|
109
|
+
%w[digest browser_digest provider_id issuer audience purpose user_id session_id session_digest policy_version issued_at expires_at consumed_at enrollment_payload], "external transaction")
|
|
110
|
+
columns(::Session, %w[authentication_external_id authentication_external_version], "external session evidence")
|
|
111
|
+
unique_index(::AddAuthExternalIdentity, "namespace") if defined?(::AddAuthExternalIdentity)
|
|
112
|
+
unique_index(::AddAuthExternalTransaction, "digest") if defined?(::AddAuthExternalTransaction)
|
|
113
|
+
check("Use one database pool for provider, account and session persistence") { Runtime.external_identities }
|
|
114
|
+
config.external_identities.providers.each do |provider|
|
|
115
|
+
check("Install the configured provider callback and preparation routes") do
|
|
116
|
+
::Rails.application.routes.recognize_path("/sign-in/providers/#{provider.middleware_name}", method: :post) &&
|
|
117
|
+
::Rails.application.routes.recognize_path("/auth/#{provider.middleware_name}/callback", method: provider.apple_form_post? ? :post : :get)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
check("Install provider account management routes") { ::Rails.application.routes.recognize_path("/account/external-identities", method: :get) }
|
|
121
|
+
end
|
|
122
|
+
if config.lifecycle.enabled
|
|
123
|
+
if defined?(::PasswordsController)
|
|
124
|
+
check("Retire stock password reset entry points through the shared account flow") { ::PasswordsController < AccountPasswordEntry }
|
|
125
|
+
end
|
|
126
|
+
columns(::Session, %w[remembered idle_timeout], "remembered browser sessions")
|
|
127
|
+
check("Enable session, step-up and notification prerequisites for account lifecycle") { config.session.enabled && config.step_up.enabled && config.notifications.enabled }
|
|
128
|
+
columns(::User, %w[confirmed_at unconfirmed_email locked_at failed_attempts disabled_at deleted_at add_auth_manual_lock add_auth_locked_until add_auth_authority], "account lifecycle")
|
|
129
|
+
columns(defined?(::AddAuthAccountToken) && ::AddAuthAccountToken,
|
|
130
|
+
(TOKEN_COLUMNS - %w[identifier_digest]) + %w[address_digest account_version], "account proofs")
|
|
131
|
+
columns(defined?(::AddAuthAddressClaim) && ::AddAuthAddressClaim, %w[user_id digest state], "address claims")
|
|
132
|
+
if defined?(::AddAuthAccountToken)
|
|
133
|
+
unique_index(::AddAuthAccountToken, "digest")
|
|
134
|
+
unique_index(::AddAuthAccountToken, "request_id")
|
|
135
|
+
end
|
|
136
|
+
unique_index(::AddAuthAddressClaim, "digest") if defined?(::AddAuthAddressClaim)
|
|
137
|
+
check("Configure account policy, profile mapping and local transaction provisioning callbacks") do
|
|
138
|
+
%i[eligible password_policy profile_attributes provision deletion_allowed delete_account].all? { |name| config.lifecycle.public_send(name).respond_to?(:call) }
|
|
139
|
+
end
|
|
140
|
+
check("Configure account proof delivery, lock policy and shared account storage") { Runtime.accounts && Runtime.password_lifecycle && Runtime.account_proof_url("validation", purpose: "confirm") }
|
|
141
|
+
%w[/account/sign-up /account/requests/confirm /account/email /account/password].each do |path|
|
|
142
|
+
check("Install route #{path}") { ::Rails.application.routes.recognize_path(path, method: :get) }
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
if config.email_link.enabled || config.notifications.enabled || config.lifecycle.enabled
|
|
79
146
|
check("Configure mail_from") { config.mail_from.is_a?(String) && !config.mail_from.strip.empty? }
|
|
80
147
|
check("Enable Action Mailer delivery") { ActionMailer::Base.perform_deliveries && ActionMailer::Base.raise_delivery_errors }
|
|
81
148
|
if ::Rails.env.production?
|
|
@@ -83,7 +150,7 @@ module AddAuth
|
|
|
83
150
|
check("Configure production mail delivery") { !%i[test file].include?(ActionMailer::Base.delivery_method) }
|
|
84
151
|
end
|
|
85
152
|
end
|
|
86
|
-
maintenance_needed = config.passkeys.enabled || config.email_link.enabled || config.notifications.enabled || config.maintenance.session_retention
|
|
153
|
+
maintenance_needed = config.passkeys.enabled || config.email_link.enabled || config.notifications.enabled || config.lifecycle.enabled || config.external_identities.enabled || config.mobile.enabled || config.maintenance.session_retention
|
|
87
154
|
if ::Rails.env.production? && maintenance_needed
|
|
88
155
|
check("Schedule add_auth:deliver_pending every minute; no successful cleanup in the last two minutes") { Runtime.maintenance_current? }
|
|
89
156
|
end
|
|
@@ -122,7 +189,7 @@ module AddAuth
|
|
|
122
189
|
end
|
|
123
190
|
end
|
|
124
191
|
if config.challenge_on.any?
|
|
125
|
-
check("Unknown challenge actions") { (config.challenge_on - %i[sign_in email_link reauthenticate passkey_enrollment]).empty? }
|
|
192
|
+
check("Unknown challenge actions (email proof pages cannot load third-party challenges)") { (config.challenge_on - %i[sign_in email_link reauthenticate passkey_enrollment register account_request provider]).empty? }
|
|
126
193
|
check("Configure a challenge provider before setting challenge_on") { !config.challenge.is_a?(Core::Challenge::Null) }
|
|
127
194
|
if ::Rails.env.production?
|
|
128
195
|
check("Configure challenge hostname restrictions") { config.challenge.allowed_hostnames&.any? }
|
|
@@ -9,7 +9,7 @@ module AddAuth
|
|
|
9
9
|
class Ejection
|
|
10
10
|
MANIFEST = "config/add_auth-ejections.json"
|
|
11
11
|
VIEW_GROUPS = {"email_link" => %w[sign_ins], "sessions" => %w[sessions],
|
|
12
|
-
"step_up" => %w[reauthentications], "passkeys" => %w[passkeys recoveries]}.freeze
|
|
12
|
+
"step_up" => %w[reauthentications], "passkeys" => %w[passkeys recoveries], "accounts" => %w[accounts], "providers" => %w[provider_sign_ins external_identities]}.freeze
|
|
13
13
|
|
|
14
14
|
def initialize(host_root:, engine_root: File.expand_path("../../..", __dir__))
|
|
15
15
|
@host, @engine = host_root.to_s, engine_root.to_s
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "turbo-rails"
|
|
4
4
|
require "add_auth/rails/authentication_pages"
|
|
5
|
+
require "add_auth/rails/provider_libraries/callback_route"
|
|
5
6
|
|
|
6
7
|
module AddAuth
|
|
7
8
|
module Rails
|
|
@@ -10,7 +11,7 @@ module AddAuth
|
|
|
10
11
|
engine_name "add_auth"
|
|
11
12
|
|
|
12
13
|
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
|
+
app.config.filter_parameters += [:token, :password, :enrollment_payload, :code, :state, :nonce, :id_token, :access_token, :refresh_token, :client_secret, :authorization_code, :raw_info, :id_info, :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
15
|
end
|
|
15
16
|
|
|
16
17
|
config.to_prepare do
|
|
@@ -21,6 +22,10 @@ module AddAuth
|
|
|
21
22
|
if AddAuth.configuration.passwords_enabled || defined?(::SessionsController)
|
|
22
23
|
::SessionsController.include AddAuth::Rails::PasswordEntry
|
|
23
24
|
end
|
|
25
|
+
if defined?(::PasswordsController)
|
|
26
|
+
require "add_auth/rails/account_password_entry"
|
|
27
|
+
::PasswordsController.include AddAuth::Rails::AccountPasswordEntry
|
|
28
|
+
end
|
|
24
29
|
end
|
|
25
30
|
end
|
|
26
31
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "add_auth/rails/stores/account_lock"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Rails
|
|
7
|
+
module Migration
|
|
8
|
+
class AccountStore
|
|
9
|
+
FIELDS = {id: :id, email: :email, encrypted_password: :encrypted_password,
|
|
10
|
+
authority: :add_auth_authority, migration_stamp: :add_auth_migration_stamp,
|
|
11
|
+
email_address: :email_address, password_digest: :password_digest, scheme: :add_auth_password_scheme}.freeze
|
|
12
|
+
|
|
13
|
+
def initialize(user_model:)
|
|
14
|
+
@users = user_model
|
|
15
|
+
@lock = Stores::AccountLock.new(user_model)
|
|
16
|
+
unless @users.primary_key == "id" && (FIELDS.values.map(&:to_s) - @users.column_names).empty?
|
|
17
|
+
raise ArgumentError, "prepare the reviewed User account schema before conversion"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def source_page(after:, limit:)
|
|
22
|
+
scope = @users.unscoped.order(:id)
|
|
23
|
+
scope = scope.where(@users.arel_table[:id].gt(after)) if after
|
|
24
|
+
scope.limit(limit).pluck(*FIELDS.values).map { |row| FIELDS.keys.zip(row).to_h }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def with_account(id)
|
|
28
|
+
@lock.call(id: id) { |user| yield(user && FIELDS.to_h { |name, field| [name, user.read_attribute(field)] }) }
|
|
29
|
+
rescue ActiveRecord::RecordNotUnique
|
|
30
|
+
:conflicts
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def prepare(id:, **attributes)
|
|
34
|
+
raise AddAuth::Error, "conversion requires an account transaction" unless @users.connection.transaction_open?
|
|
35
|
+
# Source authority is retained. No callbacks, mail or host provisioning.
|
|
36
|
+
@users.unscoped.where(id: id).update_all(attributes)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "add_auth/core/migration/readiness"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Rails
|
|
7
|
+
module Migration
|
|
8
|
+
class EffectiveInventory
|
|
9
|
+
LIMIT = 10_000
|
|
10
|
+
ACCOUNT_FIELDS = %w[email email_address encrypted_password password_digest confirmed_at unconfirmed_email locked_at failed_attempts disabled_at deleted_at remember_created_at reset_password_token confirmation_token unlock_token authentication_token provider uid].freeze
|
|
11
|
+
OPTIONS = %i[authentication_keys case_insensitive_keys strip_whitespace_keys stretches password_length reconfirmable allow_unconfirmed_access_for confirm_within reset_password_within maximum_attempts unlock_strategy unlock_in remember_for sign_in_after_reset_password].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(limit: LIMIT)
|
|
14
|
+
raise ArgumentError, "limit must be between 1 and 100000" unless limit.is_a?(Integer) && limit.between?(1, 100_000)
|
|
15
|
+
@limit = limit
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
unless %w[test development].include?(::Rails.env.to_s) && defined?(::Devise)
|
|
20
|
+
raise ArgumentError, "effective preflight requires a local test/development Devise host"
|
|
21
|
+
end
|
|
22
|
+
@complete = true
|
|
23
|
+
mappings = ::Devise.mappings.values
|
|
24
|
+
models = mappings.map(&:to).uniq
|
|
25
|
+
connections = models.map(&:connection_pool).uniq
|
|
26
|
+
raise ArgumentError, "inspect one Active Record connection pool at a time" unless connections.length == 1
|
|
27
|
+
accounts = read_only(connections.first) { models.map { |model| account(model) } }
|
|
28
|
+
all_modules = accounts.flat_map { |entry| entry[:modules] }.uniq
|
|
29
|
+
facts = {mode: "effective", complete: @complete, row_limit: @limit,
|
|
30
|
+
versions: %w[devise rails bcrypt].to_h { |name| [name.to_sym, Gem.loaded_specs[name]&.version&.to_s] }.merge(ruby: RUBY_VERSION),
|
|
31
|
+
scope_count: mappings.length, modules: all_modules & Core::Migration::Readiness::MODULES,
|
|
32
|
+
unknown_modules: accounts.sum { |entry| entry[:unknown_modules] },
|
|
33
|
+
extensions: Core::Migration::Readiness::EXTENSIONS.select { |name| Gem.loaded_specs.key?(name) },
|
|
34
|
+
rails_contract: accounts.length == 1 && accounts.first[:rails_contract], accounts: accounts}
|
|
35
|
+
Core::Migration::Readiness.new.call(facts)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def read_only(pool)
|
|
41
|
+
pool.with_connection do |connection|
|
|
42
|
+
raise ArgumentError, "effective preflight requires its own transaction" if connection.transaction_open?
|
|
43
|
+
case connection.adapter_name
|
|
44
|
+
when "PostgreSQL"
|
|
45
|
+
connection.transaction(requires_new: true) do
|
|
46
|
+
connection.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
|
|
47
|
+
connection.execute("SET LOCAL statement_timeout = '10s'")
|
|
48
|
+
yield
|
|
49
|
+
end
|
|
50
|
+
when "SQLite"
|
|
51
|
+
previous = connection.select_value("PRAGMA query_only").to_i
|
|
52
|
+
begin
|
|
53
|
+
connection.execute("PRAGMA query_only = ON")
|
|
54
|
+
# Rails 8.1 starts ordinary SQLite transactions with BEGIN
|
|
55
|
+
# IMMEDIATE, which requests write authority even for SELECTs.
|
|
56
|
+
connection.execute("BEGIN DEFERRED TRANSACTION")
|
|
57
|
+
begin
|
|
58
|
+
yield
|
|
59
|
+
ensure
|
|
60
|
+
connection.execute("ROLLBACK")
|
|
61
|
+
end
|
|
62
|
+
ensure
|
|
63
|
+
connection.execute("PRAGMA query_only = #{previous}")
|
|
64
|
+
end
|
|
65
|
+
else
|
|
66
|
+
raise ArgumentError, "effective preflight supports SQLite and PostgreSQL only"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def account(model)
|
|
72
|
+
columns = model.columns_hash
|
|
73
|
+
fields = ACCOUNT_FIELDS & columns.keys
|
|
74
|
+
# No model instances, callbacks, password verification or normalization hooks.
|
|
75
|
+
rows = model.unscoped.limit(@limit + 1).pluck(*fields).map { |values| fields.zip(Array(values)).to_h }
|
|
76
|
+
@complete = false if rows.length > @limit
|
|
77
|
+
rows = rows.first(@limit)
|
|
78
|
+
modules = model.devise_modules.map(&:to_s)
|
|
79
|
+
lookup_keys = Array(model.authentication_keys)
|
|
80
|
+
identifier = (lookup_keys.length == 1 && fields.include?(lookup_keys.first.to_s)) ? lookup_keys.first.to_s : "email"
|
|
81
|
+
@complete = false unless lookup_keys == [:email] || lookup_keys == [:email_address]
|
|
82
|
+
password = fields.include?("encrypted_password") ? "encrypted_password" : "password_digest"
|
|
83
|
+
keys = rows.map { |row| row[identifier].to_s.strip.downcase }
|
|
84
|
+
nonblank = keys.reject(&:empty?)
|
|
85
|
+
options = OPTIONS.filter_map { |name| [name, safe_option(model.public_send(name))] if model.respond_to?(name) }.to_h
|
|
86
|
+
verifier = model.instance_method(:valid_password?).source_location&.first
|
|
87
|
+
devise_root = Gem.loaded_specs.fetch("devise").full_gem_path + "/"
|
|
88
|
+
indexes = model.connection.indexes(model.table_name)
|
|
89
|
+
{model: safe_name(model.name), table: safe_name(model.table_name),
|
|
90
|
+
primary_key_type: columns[model.primary_key]&.type&.to_s,
|
|
91
|
+
database: model.connection.adapter_name, identifier: identifier,
|
|
92
|
+
identifier_type: columns[identifier]&.type&.to_s,
|
|
93
|
+
identifier_collation: columns[identifier]&.respond_to?(:collation) ? safe_name(columns[identifier].collation) : nil,
|
|
94
|
+
unique_identifier_index: indexes.any? { |index| index.unique && index.columns == [identifier] && !index.where },
|
|
95
|
+
modules: modules & Core::Migration::Readiness::MODULES,
|
|
96
|
+
unknown_modules: (modules - Core::Migration::Readiness::MODULES).length,
|
|
97
|
+
options: options, pepper_configured: model.respond_to?(:pepper) && !model.pepper.to_s.empty?,
|
|
98
|
+
custom_verifier: !verifier&.start_with?(devise_root) || !model.instance_method(:password=).source_location&.first&.start_with?(devise_root),
|
|
99
|
+
rails_contract: (model.name == "User" && fields.include?("email_address") && fields.include?("password_digest") && model.respond_to?(:authenticate_by) && defined?(::Session) && defined?(::Authentication)) ? true : false,
|
|
100
|
+
related_tables: related_tables(model),
|
|
101
|
+
counts: {inspected: rows.length, missing_identifier: keys.count(&:empty?), identifier_collisions: nonblank.length - nonblank.uniq.length,
|
|
102
|
+
unknown_password: rows.count { |row| !row[password].to_s.match?(/\A\$2[aby]\$\d{2}\$[.\/A-Za-z0-9]{53}\z/) },
|
|
103
|
+
unconfirmed: fields.include?("confirmed_at") ? rows.count { |row| row["confirmed_at"].nil? } : 0,
|
|
104
|
+
reconfirming: rows.count { |row| !row["unconfirmed_email"].to_s.empty? },
|
|
105
|
+
locked: rows.count { |row| row["locked_at"] }, disabled: rows.count { |row| row["disabled_at"] },
|
|
106
|
+
deleted: rows.count { |row| row["deleted_at"] },
|
|
107
|
+
outstanding_authority: rows.count { |row| %w[remember_created_at reset_password_token confirmation_token unlock_token authentication_token].any? { |key| !row[key].to_s.empty? } }}}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def related_tables(model)
|
|
111
|
+
connection = model.connection
|
|
112
|
+
tables = connection.tables.sort
|
|
113
|
+
@complete = false if tables.length > 200
|
|
114
|
+
tables.first(200).filter_map do |table|
|
|
115
|
+
next if table == model.table_name
|
|
116
|
+
columns = connection.columns(table).to_h { |column| [column.name, column] }
|
|
117
|
+
foreign_keys = connection.foreign_keys(table).select { |key| key.to_table == model.table_name }
|
|
118
|
+
reference = foreign_keys.first&.column || ("user_id" if model.name == "User" && columns.key?("user_id"))
|
|
119
|
+
next unless reference && columns.key?(reference)
|
|
120
|
+
quoted_table = connection.quote_table_name(table)
|
|
121
|
+
quote = ->(name) { connection.quote_column_name(name) }
|
|
122
|
+
identity_fields = %w[provider issuer subject uid] & columns.keys
|
|
123
|
+
selected = [reference, *identity_fields]
|
|
124
|
+
records = connection.select_rows("SELECT #{selected.map { |field| quote.call(field) }.join(", ")} FROM #{quoted_table} LIMIT #{@limit + 1}")
|
|
125
|
+
@complete = false if records.length > @limit
|
|
126
|
+
records = records.first(@limit)
|
|
127
|
+
identities = identity_fields.empty? ? [] : records.map { |row| row.drop(1) }
|
|
128
|
+
account_table = connection.quote_table_name(model.table_name)
|
|
129
|
+
key = quote.call(model.primary_key)
|
|
130
|
+
reference_column = quote.call(reference)
|
|
131
|
+
orphan_count = connection.select_value(<<~SQL).to_i
|
|
132
|
+
SELECT COUNT(*) FROM
|
|
133
|
+
(SELECT #{reference_column} FROM #{quoted_table} LIMIT #{@limit + 1}) source
|
|
134
|
+
LEFT JOIN #{account_table} account ON account.#{key} = source.#{reference_column}
|
|
135
|
+
WHERE source.#{reference_column} IS NOT NULL AND account.#{key} IS NULL
|
|
136
|
+
SQL
|
|
137
|
+
{table: safe_name(table), reference: safe_name(reference), key_type: columns[reference].type.to_s,
|
|
138
|
+
foreign_key_enforced: foreign_keys.any?, inspected: records.length,
|
|
139
|
+
orphaned_references: orphan_count, duplicate_identities: identities.length - identities.uniq.length}
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def safe_name(value)
|
|
144
|
+
value.to_s.match?(/\A[A-Za-z_]\w*(?:::\w+)*\z/) ? value.to_s : nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def safe_option(value)
|
|
148
|
+
case value
|
|
149
|
+
when nil, true, false, Numeric then value
|
|
150
|
+
when Symbol then safe_name(value)
|
|
151
|
+
when Range then [safe_option(value.begin), safe_option(value.end), value.exclude_end?]
|
|
152
|
+
when Array then value.map { |item| safe_option(item) }
|
|
153
|
+
when Hash then value.to_h { |key, item| [safe_name(key), safe_option(item)] }
|
|
154
|
+
else
|
|
155
|
+
# ActiveSupport::Duration exposes only a numeric duration.
|
|
156
|
+
value.is_a?(ActiveSupport::Duration) ? value.to_i : "custom"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Rails
|
|
5
|
+
module Migration
|
|
6
|
+
module KeyType
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Resolve when the migration runs, after the host's account table exists.
|
|
10
|
+
# Session IDs remain integer until opaque cursor support is established.
|
|
11
|
+
def for(connection, table, session: false)
|
|
12
|
+
key = connection.primary_key(table)
|
|
13
|
+
column = connection.columns(table).find { |entry| entry.name == key }
|
|
14
|
+
raise ArgumentError, "AddAuth requires a single integer or UUID primary key on #{table}" unless column
|
|
15
|
+
return :bigint if column.type == :integer && column.limit == 8
|
|
16
|
+
return :integer if column.type == :integer
|
|
17
|
+
return :uuid if column.type == :uuid && !session
|
|
18
|
+
raise ArgumentError, session ? "AddAuth requires integer Session IDs; UUID accounts can use integer Sessions" : "AddAuth requires an integer or UUID account primary key"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "add_auth/core/migration/account_adoption"
|
|
4
|
+
require "add_auth/rails/stores/account_lock"
|
|
5
|
+
|
|
6
|
+
module AddAuth
|
|
7
|
+
module Rails
|
|
8
|
+
module Migration
|
|
9
|
+
# Include after Devise in an expanded, reviewed source model. This does
|
|
10
|
+
# not select routes or retire other host credential readers at cutover.
|
|
11
|
+
module SourceBridge
|
|
12
|
+
extend ActiveSupport::Concern
|
|
13
|
+
|
|
14
|
+
included do
|
|
15
|
+
before_save :add_auth_project_source_credentials
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def valid_password?(password)
|
|
19
|
+
Core::Migration::AccountAdoption.source_authority?(self[:add_auth_authority]) && super
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def active_for_authentication?
|
|
23
|
+
Core::Migration::AccountAdoption.source_authority?(self[:add_auth_authority]) && super
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Devise Lockable increments this counter through update_all before its
|
|
27
|
+
# later save callback. Fence that bypass on the same account row lock.
|
|
28
|
+
def increment_failed_attempts
|
|
29
|
+
self.class.transaction do
|
|
30
|
+
current = Stores::AccountLock.new(self.class).current_in_transaction(id: id)
|
|
31
|
+
raise AddAuth::Error, "source account no longer exists" unless current
|
|
32
|
+
Core::Migration::AccountAdoption.authorize_source_write!(authority: current[:add_auth_authority], changed_fields: [:failed_attempts])
|
|
33
|
+
super
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def add_auth_project_source_credentials
|
|
40
|
+
current = persisted? ? Stores::AccountLock.new(self.class).current_in_transaction(id: id) : self
|
|
41
|
+
raise AddAuth::Error, "source account no longer exists" unless current
|
|
42
|
+
source = %i[email encrypted_password].to_h do |name|
|
|
43
|
+
[name, will_save_change_to_attribute?(name) ? self[name] : current[name]]
|
|
44
|
+
end
|
|
45
|
+
attributes = Core::Migration::AccountAdoption.projection_for_write(**source,
|
|
46
|
+
authority: current[:add_auth_authority], authority_changed: persisted? && will_save_change_to_add_auth_authority?,
|
|
47
|
+
changed_fields: changed_attribute_names_to_save)
|
|
48
|
+
attributes.each { |name, value| self[name] = value }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "add_auth/core/passwords/legacy_bcrypt"
|
|
4
|
+
require "add_auth/core/passwords/credential"
|
|
5
|
+
|
|
6
|
+
module AddAuth
|
|
7
|
+
module Rails
|
|
8
|
+
# Include after has_secure_password. Rails owns new assignment and lookup;
|
|
9
|
+
# Core chooses exactly one verifier for the persisted credential profile.
|
|
10
|
+
module PasswordAdoption
|
|
11
|
+
extend ActiveSupport::Concern
|
|
12
|
+
|
|
13
|
+
included do
|
|
14
|
+
self.filter_attributes += [:add_auth_password_scheme]
|
|
15
|
+
before_save :add_auth_retire_legacy_password
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def authenticate_password(password)
|
|
19
|
+
result = Core::Passwords::Credential.new(legacy_verifier: AddAuth.configuration.legacy_password_verifier)
|
|
20
|
+
.authenticate(user: self, password: password, current: ->(value) { super(value) })
|
|
21
|
+
result.success? ? result.user : false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def authenticate(password) = authenticate_password(password)
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def add_auth_retire_legacy_password
|
|
29
|
+
# A host's ordinary password assignment replaces the legacy credential.
|
|
30
|
+
# Direct migration copies do not set the virtual plaintext attribute.
|
|
31
|
+
self.add_auth_password_scheme = Core::Passwords::Credential.scheme_after_assignment(
|
|
32
|
+
scheme: add_auth_password_scheme, password: password, digest_changed: will_save_change_to_password_digest?
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|