add_auth 0.2.2 → 0.4.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 +36 -0
- data/README.md +64 -17
- data/ROADMAP.md +98 -43
- data/SECURITY.md +8 -0
- data/app/controllers/add_auth/accounts_controller.rb +146 -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/controllers/add_auth/reauthentications_controller.rb +5 -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 +17 -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 +295 -0
- data/lib/add_auth/core/account_policy.rb +68 -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 +6 -1
- data/lib/add_auth/rails/doctor.rb +80 -6
- 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 +179 -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 +104 -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 +56 -0
- data/lib/generators/add_auth/accounts/templates/add_add_auth_accounts.rb.tt +49 -0
- data/lib/generators/add_auth/accounts/templates/add_add_auth_provisioning.rb.tt +9 -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/email_link_generator.rb +4 -1
- 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/feature_configuration.rb +21 -0
- data/lib/generators/add_auth/install/templates/initializer.rb +154 -46
- 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/notifications_generator.rb +3 -4
- data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +3 -1
- data/lib/generators/add_auth/passkeys/passkeys_generator.rb +3 -21
- data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +7 -3
- data/lib/generators/add_auth/session_upgrade/session_upgrade_generator.rb +3 -1
- data/lib/generators/add_auth/step_up/step_up_generator.rb +5 -5
- 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 +101 -2
|
@@ -20,13 +20,7 @@ module AddAuth
|
|
|
20
20
|
transaction.after_commit { completed = true }
|
|
21
21
|
# Opposing account switches acquire their locks in the same order.
|
|
22
22
|
accounts = [id, additional_id].compact.uniq.sort_by(&:to_s).map do |account_id|
|
|
23
|
-
|
|
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
|
|
23
|
+
current_in_transaction(id: account_id)
|
|
30
24
|
end
|
|
31
25
|
result = yield accounts.compact.find { |account| account.id == id }
|
|
32
26
|
end
|
|
@@ -34,6 +28,21 @@ module AddAuth
|
|
|
34
28
|
raise AddAuth::Error, "authentication transaction rolled back" unless completed
|
|
35
29
|
result
|
|
36
30
|
end
|
|
31
|
+
|
|
32
|
+
# For model save callbacks, whose Active Record transaction already owns
|
|
33
|
+
# the write. All actual row-lock acquisition lives in this adapter.
|
|
34
|
+
def current_in_transaction(id:)
|
|
35
|
+
raise AddAuth::Error, "account row locking requires an owning transaction" unless @users.connection.transaction_open?
|
|
36
|
+
@users.uncached do
|
|
37
|
+
if @users.connection.adapter_name == "SQLite"
|
|
38
|
+
pk = @users.connection.quote_column_name(@users.primary_key)
|
|
39
|
+
@users.unscoped.where(@users.primary_key => id).update_all("#{pk} = #{pk}")
|
|
40
|
+
@users.unscoped.find_by(@users.primary_key => id)
|
|
41
|
+
else
|
|
42
|
+
@users.unscoped.lock.find_by(@users.primary_key => id)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
37
46
|
end
|
|
38
47
|
end
|
|
39
48
|
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "add_auth/rails/stores/email_tokens"
|
|
4
|
+
require "add_auth/rails/stores/commit_dispatch"
|
|
5
|
+
require "add_auth/rails/stores/account_credentials"
|
|
6
|
+
|
|
7
|
+
module AddAuth
|
|
8
|
+
module Rails
|
|
9
|
+
module Stores
|
|
10
|
+
class AccountTokens < EmailTokens
|
|
11
|
+
def initialize(user_model:, token_model:, session_model:, address_model:, authority:, provision:, delete_account: ->(user) { user.destroy! })
|
|
12
|
+
super(user_model: user_model, token_model: token_model, session_model: session_model)
|
|
13
|
+
raise ArgumentError, "address claims must share the account connection pool" unless address_model.connection_pool.equal?(user_model.connection_pool)
|
|
14
|
+
@addresses, @authority, @provision = address_model, authority, provision
|
|
15
|
+
@delete_account = delete_account
|
|
16
|
+
@account_credentials = AccountCredentials.new(user_model: user_model, authority: authority)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def create_account(email:, password:, profile: {}, replacing: nil)
|
|
20
|
+
raise AddAuth::Error, "registration must own its transaction" if @users.connection.transaction_open?
|
|
21
|
+
completed = false
|
|
22
|
+
saved = false
|
|
23
|
+
@users.transaction do |transaction|
|
|
24
|
+
transaction.after_commit { completed = true }
|
|
25
|
+
AccountLock.new(@users).current_in_transaction(id: replacing.user_id) if replacing
|
|
26
|
+
user = @users.new(profile)
|
|
27
|
+
user.email_address = email
|
|
28
|
+
user.password = password
|
|
29
|
+
user.add_auth_authority = "add_auth"
|
|
30
|
+
user.save!
|
|
31
|
+
saved = true
|
|
32
|
+
yield user
|
|
33
|
+
end
|
|
34
|
+
raise AddAuth::Error, "registration transaction rolled back" unless completed
|
|
35
|
+
:created
|
|
36
|
+
rescue ActiveRecord::RecordNotUnique, Core::AccountLifecycle::Conflict
|
|
37
|
+
:duplicate
|
|
38
|
+
rescue ActiveRecord::RecordInvalid => error
|
|
39
|
+
raise if saved
|
|
40
|
+
(error.record.errors.attribute_names - [:email_address]).empty? ? :duplicate : :invalid
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def replace_pending(**attributes)
|
|
44
|
+
raise AddAuth::Error, "account proof must join its owning transaction" unless @users.current_transaction.open?
|
|
45
|
+
record = super
|
|
46
|
+
CommitDispatch.enqueue(transaction: @users.current_transaction, job: ::AddAuth::AccountDeliveryJob,
|
|
47
|
+
id: record.id, failure_event: "account_enqueue_failed.add_auth", payload: {issuance_id: record.id})
|
|
48
|
+
record
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def claim_address(user:, digest:, address:, state:)
|
|
52
|
+
raise Core::AccountLifecycle::Conflict if @users.where(email_address: address).where.not(id: user.id).exists?
|
|
53
|
+
@addresses.where(user_id: user.id, state: state).where.not(digest: digest).delete_all
|
|
54
|
+
existing = @addresses.find_by(digest: digest)
|
|
55
|
+
if existing
|
|
56
|
+
raise Core::AccountLifecycle::Conflict unless existing.user_id == user.id && existing.state == state
|
|
57
|
+
else
|
|
58
|
+
@addresses.create!(user_id: user.id, digest: digest, state: state)
|
|
59
|
+
end
|
|
60
|
+
rescue ActiveRecord::RecordNotUnique
|
|
61
|
+
raise Core::AccountLifecycle::Conflict
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def promote_address(user:, digest:)
|
|
65
|
+
pending = @addresses.find_by!(user_id: user.id, digest: digest, state: "pending")
|
|
66
|
+
@addresses.where(user_id: user.id, state: "current").delete_all
|
|
67
|
+
pending.update!(state: "current")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def inspect_token(digest:)
|
|
71
|
+
@tokens.uncached do
|
|
72
|
+
record = @tokens.find_by(digest: digest)
|
|
73
|
+
[record && @users.find_by(id: record.user_id), record]
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def update_account(user:, **attributes)
|
|
78
|
+
# Core owns notices and invalidation for this transition. Avoid firing
|
|
79
|
+
# a second independent model callback for the same committed change.
|
|
80
|
+
@account_credentials.update_account(user: user, **attributes)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def replace_password(user:, password:)
|
|
84
|
+
@account_credentials.replace_password(user: user, password: password)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def revoke_authority(user:, at:) = @authority.revoke(user_id: user.id, at: at)
|
|
88
|
+
|
|
89
|
+
def provision(user:)
|
|
90
|
+
@provision.call(user)
|
|
91
|
+
user.reload
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def delete_account(user:)
|
|
95
|
+
raise AddAuth::Error, "account deletion requires its owning transaction" unless @users.connection.transaction_open?
|
|
96
|
+
@delete_account.call(user)
|
|
97
|
+
raise Core::AccountLifecycle::DeletionRejected if @users.unscoped.exists?(id: user.id)
|
|
98
|
+
rescue ActiveRecord::RecordNotDestroyed, ActiveRecord::InvalidForeignKey
|
|
99
|
+
raise Core::AccountLifecycle::DeletionRejected, cause: nil
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Rails
|
|
5
|
+
module Stores
|
|
6
|
+
# One persistence implementation for retiring outstanding account authority.
|
|
7
|
+
# Core chooses the event, account and time; callers own the account lock.
|
|
8
|
+
class Authority
|
|
9
|
+
def initialize(user_model:, session_model:, token_models: [], ceremony_models: [], credential_models: [], external_invalidator: nil)
|
|
10
|
+
@external_invalidator = external_invalidator
|
|
11
|
+
@users, @sessions, @tokens, @ceremonies, @credentials = user_model, session_model, token_models, ceremony_models, credential_models
|
|
12
|
+
unless [session_model, *token_models, *ceremony_models, *credential_models].all? { |model| model.connection_pool.equal?(user_model.connection_pool) }
|
|
13
|
+
raise ArgumentError, "authentication models must share one connection pool"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def revoke(user_id:, at:)
|
|
18
|
+
raise AddAuth::Error, "authority revocation requires an account transaction" unless @users.connection.transaction_open?
|
|
19
|
+
@external_invalidator&.call(user_id: user_id, at: at)
|
|
20
|
+
@sessions.where(user_id: user_id, revoked_at: nil).update_all(revoked_at: at)
|
|
21
|
+
@tokens.each { |model| model.where(user_id: user_id, consumed_at: nil, revoked_at: nil).update_all(revoked_at: at, delivery_payload: nil) }
|
|
22
|
+
@ceremonies.each { |model| model.where(user_id: user_id, consumed_at: nil).update_all(consumed_at: at) }
|
|
23
|
+
@credentials.each { |model| model.where(user_id: user_id, revoked_at: nil).update_all(revoked_at: at) }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Rails
|
|
5
|
+
module Stores
|
|
6
|
+
module CommitDispatch
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def enqueue(transaction:, job:, id:, failure_event:, payload:)
|
|
10
|
+
raise AddAuth::Error, "outbox dispatch must join its owning transaction" unless transaction.open?
|
|
11
|
+
transaction.after_commit do
|
|
12
|
+
raise AddAuth::Error unless job.perform_later(id)
|
|
13
|
+
rescue
|
|
14
|
+
notify(event: failure_event, payload: payload)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def event(transaction:, event:, payload:)
|
|
19
|
+
raise AddAuth::Error, "commit events must join their owning transaction" unless transaction.open?
|
|
20
|
+
transaction.after_commit { notify(event: event, payload: payload) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def notify(event:, payload:)
|
|
24
|
+
ActiveSupport::Notifications.instrument(event, payload)
|
|
25
|
+
rescue
|
|
26
|
+
# Optional observers cannot report a committed operation as failed.
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "add_auth/rails/stores/account_lock"
|
|
5
|
+
|
|
6
|
+
module AddAuth
|
|
7
|
+
module Rails
|
|
8
|
+
module Stores
|
|
9
|
+
class ExternalIdentities
|
|
10
|
+
def initialize(user_model:, identity_model:, transaction_model:)
|
|
11
|
+
@users, @identities, @transactions = user_model, identity_model, transaction_model
|
|
12
|
+
unless [identity_model, transaction_model].all? { |model| model.connection_pool.equal?(@users.connection_pool) }
|
|
13
|
+
raise ArgumentError, "authentication models must share one connection pool"
|
|
14
|
+
end
|
|
15
|
+
@lock = AccountLock.new(@users)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def with_user(id:, &block) = @lock.call(id: id, &block)
|
|
19
|
+
|
|
20
|
+
def with_identity(namespace:, replacing: nil)
|
|
21
|
+
@identities.uncached do
|
|
22
|
+
locator = binding(namespace: namespace)
|
|
23
|
+
@lock.call(id: locator&.user_id, additional_id: replacing&.user_id) do |user|
|
|
24
|
+
yield user, user && binding(namespace: namespace)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The G3 capability establishes fresh-account provenance. The adapter
|
|
30
|
+
# additionally enforces the exact user model/pool and owning transaction
|
|
31
|
+
# before a pending transaction can be consumed.
|
|
32
|
+
def registration_account(user:)
|
|
33
|
+
return unless @users.connection.transaction_open? && user.is_a?(@users) && user.persisted? && !user.destroyed?
|
|
34
|
+
@users.uncached { @users.find_by(id: user.id) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def binding(namespace:) = @identities.find_by(namespace: namespace)
|
|
38
|
+
def binding_for_user(user_id:, id:) = @identities.find_by(user_id: user_id, id: id)
|
|
39
|
+
def bindings(user_id:) = @identities.where(user_id: user_id, revoked_at: nil).to_a
|
|
40
|
+
|
|
41
|
+
def bind(user:, evidence:, at:)
|
|
42
|
+
require_transaction!
|
|
43
|
+
# A savepoint contains unique-index failures on PostgreSQL. Account
|
|
44
|
+
# locks serialize same-owner callbacks; the unique namespace index
|
|
45
|
+
# arbitrates different owners without choosing one by email.
|
|
46
|
+
@identities.transaction(requires_new: true) do
|
|
47
|
+
identity = binding(namespace: evidence.namespace)
|
|
48
|
+
if identity
|
|
49
|
+
return unless identity.user_id == user.id
|
|
50
|
+
return identity unless identity.revoked_at
|
|
51
|
+
identity.update!(revoked_at: nil, linked_at: at, credential_version: SecureRandom.hex(16), provenance: evidence.provenance)
|
|
52
|
+
identity
|
|
53
|
+
else
|
|
54
|
+
@identities.create!(user_id: user.id, namespace: evidence.namespace, provider_id: evidence.provider_id,
|
|
55
|
+
issuer: evidence.issuer, audience: evidence.audience, subject: evidence.subject,
|
|
56
|
+
provenance: evidence.provenance, linked_at: at, credential_version: SecureRandom.hex(16))
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
rescue ActiveRecord::RecordNotUnique
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def revoke(identity:, at:)
|
|
64
|
+
require_transaction!
|
|
65
|
+
identity.update!(revoked_at: at, credential_version: SecureRandom.hex(16))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def invalidate_credentials(user_id:, at:, credential_version:)
|
|
69
|
+
require_transaction!
|
|
70
|
+
# Never move the cutoff backwards if a delayed invalidation arrives.
|
|
71
|
+
@identities.where(user_id: user_id, revoked_at: nil).where("invalidated_at IS NULL OR invalidated_at <= ?", at)
|
|
72
|
+
.update_all(invalidated_at: at, credential_version: credential_version, updated_at: at)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def create_transaction(**attributes) = @transactions.create!(**attributes)
|
|
76
|
+
def transaction_by_digest(digest:) = @transactions.uncached { @transactions.find_by(digest: digest) }
|
|
77
|
+
|
|
78
|
+
def consume_transaction(digest:, at:)
|
|
79
|
+
@transactions.where(digest: digest, consumed_at: nil).where("issued_at <= ? AND expires_at > ?", at, at)
|
|
80
|
+
.update_all(consumed_at: at) == 1
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def require_transaction!
|
|
86
|
+
raise AddAuth::Error, "identity mutation requires an account transaction" unless @users.connection.transaction_open?
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -12,7 +12,7 @@ module AddAuth
|
|
|
12
12
|
scope = @model.where("expires_at <= ?", before)
|
|
13
13
|
if @kind == :session
|
|
14
14
|
scope = scope.or(@model.where("revoked_at <= ?", before))
|
|
15
|
-
elsif @kind
|
|
15
|
+
elsif !Core::Maintenance::EXPIRING.key?(@kind)
|
|
16
16
|
scope = without_live_lease(scope, now)
|
|
17
17
|
end
|
|
18
18
|
bounded(scope, limit).delete_all
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "add_auth/rails/stores/account_lock"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Rails
|
|
7
|
+
module Stores
|
|
8
|
+
class MobileHandoffs
|
|
9
|
+
def initialize(user_model:, handoff_model:, session_model:)
|
|
10
|
+
@users, @handoffs = user_model, handoff_model
|
|
11
|
+
unless [handoff_model, session_model].all? { |model| model.connection_pool.equal?(@users.connection_pool) }
|
|
12
|
+
raise ArgumentError, "mobile authentication models must share one connection pool"
|
|
13
|
+
end
|
|
14
|
+
@lock = AccountLock.new(@users)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def create_pending(**attributes)
|
|
18
|
+
@handoffs.transaction(requires_new: true) { @handoffs.create!(**attributes) }
|
|
19
|
+
rescue ActiveRecord::RecordNotUnique
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def by_external_digest(digest:) = @handoffs.uncached { @handoffs.find_by(external_digest: digest) }
|
|
24
|
+
|
|
25
|
+
def issue(row:, **attributes)
|
|
26
|
+
require_transaction!
|
|
27
|
+
@handoffs.where(id: row.id, digest: nil, consumed_at: nil, user_id: nil).update_all(**attributes) == 1
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def with_code(digest:)
|
|
31
|
+
@handoffs.uncached do
|
|
32
|
+
locator = @handoffs.find_by(digest: digest)
|
|
33
|
+
@lock.call(id: locator&.user_id) do |user|
|
|
34
|
+
yield user, user && @handoffs.find_by(digest: digest, user_id: user.id)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def consume(row:, at:)
|
|
40
|
+
require_transaction!
|
|
41
|
+
@handoffs.where(id: row.id, user_id: row.user_id, consumed_at: nil).where("expires_at > ?", at)
|
|
42
|
+
.update_all(consumed_at: at) == 1
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def require_transaction!
|
|
48
|
+
raise AddAuth::Error, "mobile handoff requires an account transaction" unless @users.connection.transaction_open?
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "add_auth/rails/stores/delivery_state"
|
|
4
|
+
require "add_auth/rails/stores/commit_dispatch"
|
|
4
5
|
|
|
5
6
|
module AddAuth
|
|
6
7
|
module Rails
|
|
@@ -16,12 +17,8 @@ module AddAuth
|
|
|
16
17
|
def append(user:, **attributes)
|
|
17
18
|
raise AddAuth::Error, "security notifications must join the account transaction" unless @users.current_transaction.open?
|
|
18
19
|
event = @events.create!(**attributes, user_id: user.id)
|
|
19
|
-
@users.current_transaction
|
|
20
|
-
|
|
21
|
-
raise AddAuth::Error unless job
|
|
22
|
-
rescue
|
|
23
|
-
ActiveSupport::Notifications.instrument("notification_enqueue_failed.add_auth", event_id: event.id)
|
|
24
|
-
end
|
|
20
|
+
CommitDispatch.enqueue(transaction: @users.current_transaction, job: ::AddAuth::SecurityNotificationJob,
|
|
21
|
+
id: event.id, failure_event: "notification_enqueue_failed.add_auth", payload: {event_id: event.id})
|
|
25
22
|
event
|
|
26
23
|
end
|
|
27
24
|
|
|
@@ -6,29 +6,27 @@ module AddAuth
|
|
|
6
6
|
extend ActiveSupport::Concern
|
|
7
7
|
|
|
8
8
|
included do
|
|
9
|
+
before_update :add_auth_validate_account_write
|
|
9
10
|
after_update :add_auth_invalidate_proofs
|
|
10
11
|
end
|
|
11
12
|
|
|
12
13
|
private
|
|
13
14
|
|
|
15
|
+
def add_auth_validate_account_write
|
|
16
|
+
Core::AccountLifecycle.validate_host_write!(changes: changes_to_save, enabled: AddAuth.configuration.lifecycle.enabled)
|
|
17
|
+
end
|
|
18
|
+
|
|
14
19
|
def add_auth_invalidate_proofs
|
|
15
20
|
return unless Core::Intake.revoke_after_change?(saved_changes)
|
|
16
21
|
now = Time.current
|
|
17
|
-
|
|
18
|
-
|
|
22
|
+
kind = Core::Intake.notification_after_change(saved_changes)
|
|
23
|
+
if AddAuth.configuration.notifications.enabled && kind
|
|
19
24
|
Runtime.security_events.issue(user: self, kind: kind, at: now)
|
|
20
25
|
if saved_change_to_email_address? && email_address_before_last_save != email_address
|
|
21
26
|
Runtime.security_events.issue(user: self, kind: :email_changed, at: now, recipient: email_address_before_last_save)
|
|
22
27
|
end
|
|
23
28
|
end
|
|
24
|
-
|
|
25
|
-
::AddAuthCeremony.where(user_id: id, consumed_at: nil).update_all(consumed_at: now)
|
|
26
|
-
end
|
|
27
|
-
sessions.where(revoked_at: nil).update_all(revoked_at: now)
|
|
28
|
-
if defined?(::AddAuthSignInToken) && ::AddAuthSignInToken.table_exists?
|
|
29
|
-
::AddAuthSignInToken.where(user_id: id, consumed_at: nil, revoked_at: nil)
|
|
30
|
-
.update_all(revoked_at: now, delivery_payload: nil)
|
|
31
|
-
end
|
|
29
|
+
Runtime.authority.revoke(user_id: id, at: now)
|
|
32
30
|
end
|
|
33
31
|
end
|
|
34
32
|
end
|
data/lib/add_auth/result.rb
CHANGED
|
@@ -16,6 +16,7 @@ module AddAuth
|
|
|
16
16
|
consumed_token revoked_token challenge_rejected
|
|
17
17
|
challenge_unavailable rate_limited origin_mismatch
|
|
18
18
|
counter_regression elevation_required
|
|
19
|
+
identity_unbound identity_conflict last_credential
|
|
19
20
|
].freeze
|
|
20
21
|
|
|
21
22
|
attr_reader :user, :strategy, :credential, :reason, :session, :grant
|
data/lib/add_auth/version.rb
CHANGED
data/lib/add_auth.rb
CHANGED
|
@@ -13,11 +13,15 @@ require "add_auth/configuration"
|
|
|
13
13
|
# ejectable, disposable UI on top of it.
|
|
14
14
|
require "add_auth/core/browser_binding"
|
|
15
15
|
require "add_auth/core/access_policy"
|
|
16
|
+
require "add_auth/core/account_policy"
|
|
16
17
|
require "add_auth/core/sessions"
|
|
18
|
+
require "add_auth/core/mobile_response"
|
|
17
19
|
require "add_auth/core/step_up"
|
|
20
|
+
require "add_auth/core/external_identities"
|
|
18
21
|
require "add_auth/core/intake"
|
|
19
22
|
require "add_auth/core/rate_limit"
|
|
20
23
|
require "add_auth/core/delivery"
|
|
24
|
+
require "add_auth/core/account_lifecycle"
|
|
21
25
|
require "add_auth/core/maintenance"
|
|
22
26
|
require "add_auth/core/security_events"
|
|
23
27
|
require "add_auth/core/strategies/email_link"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module AddAuth
|
|
7
|
+
module Generators
|
|
8
|
+
class AccountsGenerator < ::Rails::Generators::Base
|
|
9
|
+
include ::Rails::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
class_option :email_link, type: :boolean, default: true, desc: "Enable email sign-in as a step-up dependency"
|
|
13
|
+
desc "Prepare optional account lifecycle schema and pages; review existing account confirmation and policy before enabling"
|
|
14
|
+
|
|
15
|
+
def self.next_migration_number(dirname) = ::ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
16
|
+
|
|
17
|
+
def prerequisites
|
|
18
|
+
invoke "add_auth:notifications"
|
|
19
|
+
invoke "add_auth:step_up", [], email_link: options[:email_link]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def persistence
|
|
23
|
+
unless Dir[File.join(destination_root, "db/migrate/*_add_add_auth_accounts.rb")].any?
|
|
24
|
+
migration_template "add_add_auth_accounts.rb.tt", "db/migrate/add_add_auth_accounts.rb"
|
|
25
|
+
end
|
|
26
|
+
unless Dir[File.join(destination_root, "db/migrate/*_add_add_auth_provisioning.rb")].any?
|
|
27
|
+
migration_template "add_add_auth_provisioning.rb.tt", "db/migrate/add_add_auth_provisioning.rb"
|
|
28
|
+
end
|
|
29
|
+
%w[add_auth_account_token add_auth_address_claim].each do |name|
|
|
30
|
+
template "#{name}.rb", "app/models/#{name}.rb" unless File.exist?(File.join(destination_root, "app/models/#{name}.rb"))
|
|
31
|
+
end
|
|
32
|
+
say "Account lifecycle is prepared but disabled. Map existing account policy and confirmation state, then set config.lifecycle.enabled = true."
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def routes
|
|
36
|
+
return if File.read(File.join(destination_root, "config/routes.rb")).include?("# AddAuth account lifecycle")
|
|
37
|
+
route <<~ROUTES
|
|
38
|
+
# AddAuth account lifecycle
|
|
39
|
+
get "account/sign-up", to: "add_auth/accounts#new"
|
|
40
|
+
post "account/sign-up", to: "add_auth/accounts#create"
|
|
41
|
+
get "account/check-email", to: "add_auth/accounts#check_email"
|
|
42
|
+
get "account/requests/:purpose", to: "add_auth/accounts#request_form", constraints: {purpose: /confirm|reset_password|unlock/}
|
|
43
|
+
post "account/requests/:purpose", to: "add_auth/accounts#request_proof", constraints: {purpose: /confirm|reset_password|unlock/}
|
|
44
|
+
get "account/proofs/:purpose", to: "add_auth/accounts#proof", constraints: {purpose: /confirm|reset_password|unlock/}
|
|
45
|
+
post "account/proofs/:purpose", to: "add_auth/accounts#consume", constraints: {purpose: /confirm|reset_password|unlock/}
|
|
46
|
+
get "account/email", to: "add_auth/accounts#edit_email"
|
|
47
|
+
post "account/email", to: "add_auth/accounts#change_email"
|
|
48
|
+
get "account/password", to: "add_auth/accounts#edit_password"
|
|
49
|
+
post "account/password", to: "add_auth/accounts#change_password"
|
|
50
|
+
get "account/delete", to: "add_auth/accounts#confirm_deletion"
|
|
51
|
+
delete "account", to: "add_auth/accounts#destroy"
|
|
52
|
+
ROUTES
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
require "add_auth/rails/migration/key_type"
|
|
2
|
+
|
|
3
|
+
class AddAddAuthAccounts < ActiveRecord::Migration[8.0]
|
|
4
|
+
def up
|
|
5
|
+
user_key_type = AddAuth::Rails::Migration::KeyType.for(connection, :users)
|
|
6
|
+
false_default = (connection.adapter_name == "SQLite") ? -> { "0" } : false
|
|
7
|
+
%i[confirmed_at locked_at disabled_at deleted_at add_auth_locked_until].each do |name|
|
|
8
|
+
add_column :users, name, :datetime unless column_exists?(:users, name)
|
|
9
|
+
end
|
|
10
|
+
add_column :users, :unconfirmed_email, :string unless column_exists?(:users, :unconfirmed_email)
|
|
11
|
+
add_column :users, :failed_attempts, :integer, default: 0, null: false unless column_exists?(:users, :failed_attempts)
|
|
12
|
+
add_column :users, :add_auth_manual_lock, :boolean, default: false_default, null: false unless column_exists?(:users, :add_auth_manual_lock)
|
|
13
|
+
add_column :users, :add_auth_password_scheme, :string unless column_exists?(:users, :add_auth_password_scheme)
|
|
14
|
+
add_column :users, :add_auth_authority, :string, default: "add_auth", null: false unless column_exists?(:users, :add_auth_authority)
|
|
15
|
+
add_column :sessions, :remembered, :boolean, default: false_default, null: false unless column_exists?(:sessions, :remembered)
|
|
16
|
+
add_column :sessions, :idle_timeout, :integer unless column_exists?(:sessions, :idle_timeout)
|
|
17
|
+
create_table :add_auth_address_claims do |t|
|
|
18
|
+
t.references :user, type: user_key_type, null: false, foreign_key: {on_delete: :cascade}
|
|
19
|
+
t.string :digest, null: false
|
|
20
|
+
t.string :state, null: false
|
|
21
|
+
t.timestamps
|
|
22
|
+
end
|
|
23
|
+
add_index :add_auth_address_claims, :digest, unique: true
|
|
24
|
+
add_index :add_auth_address_claims, [:user_id, :state], unique: true
|
|
25
|
+
create_table :add_auth_account_tokens do |t|
|
|
26
|
+
t.references :user, type: user_key_type, null: false, foreign_key: {on_delete: :cascade}
|
|
27
|
+
t.string :digest, null: false
|
|
28
|
+
t.string :purpose, null: false
|
|
29
|
+
t.string :address_digest, null: false
|
|
30
|
+
t.string :account_version, null: false
|
|
31
|
+
t.string :request_id, null: false
|
|
32
|
+
t.datetime :expires_at, null: false
|
|
33
|
+
t.datetime :consumed_at
|
|
34
|
+
t.datetime :revoked_at
|
|
35
|
+
t.text :delivery_payload
|
|
36
|
+
t.string :delivery_lease_key
|
|
37
|
+
t.datetime :delivery_lease_until
|
|
38
|
+
t.datetime :delivered_at
|
|
39
|
+
t.timestamps
|
|
40
|
+
end
|
|
41
|
+
add_index :add_auth_account_tokens, :digest, unique: true
|
|
42
|
+
add_index :add_auth_account_tokens, :request_id, unique: true
|
|
43
|
+
add_index :add_auth_account_tokens, :expires_at
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def down
|
|
47
|
+
raise ActiveRecord::IrreversibleMigration, "Retain account policy, address claims and spent proofs during rollback; retire them only after reviewed reconciliation"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
class AddAddAuthProvisioning < ActiveRecord::Migration[8.0]
|
|
2
|
+
def up
|
|
3
|
+
add_column :users, :add_auth_provisioned_at, :datetime unless column_exists?(:users, :add_auth_provisioned_at)
|
|
4
|
+
end
|
|
5
|
+
|
|
6
|
+
def down
|
|
7
|
+
raise ActiveRecord::IrreversibleMigration, "Retain provisioning receipts to prevent repeating account setup"
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module AddAuth
|
|
7
|
+
module Generators
|
|
8
|
+
class DeviseAccountsGenerator < ::Rails::Generators::Base
|
|
9
|
+
include ::Rails::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
desc "Prepare additive account columns for a reviewed Devise migration; does not convert accounts or switch authentication"
|
|
13
|
+
|
|
14
|
+
def self.next_migration_number(dirname) = ::ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
15
|
+
|
|
16
|
+
def migration
|
|
17
|
+
return if Dir[File.join(destination_root, "db/migrate/*_prepare_add_auth_devise_accounts.rb")].any?
|
|
18
|
+
migration_template "prepare_add_auth_devise_accounts.rb.tt", "db/migrate/prepare_add_auth_devise_accounts.rb"
|
|
19
|
+
say "Review the generated migration and run preflight before conversion. Authentication and host models are unchanged."
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require "add_auth/rails/migration/key_type"
|
|
2
|
+
|
|
3
|
+
class PrepareAddAuthDeviseAccounts < ActiveRecord::Migration[8.0]
|
|
4
|
+
def up
|
|
5
|
+
AddAuth::Rails::Migration::KeyType.for(connection, :users)
|
|
6
|
+
add_column :users, :email_address, :string unless column_exists?(:users, :email_address)
|
|
7
|
+
add_column :users, :password_digest, :string unless column_exists?(:users, :password_digest)
|
|
8
|
+
add_column :users, :add_auth_password_scheme, :string unless column_exists?(:users, :add_auth_password_scheme)
|
|
9
|
+
add_column :users, :add_auth_migration_stamp, :string unless column_exists?(:users, :add_auth_migration_stamp)
|
|
10
|
+
add_column :users, :add_auth_authority, :string, default: "devise", null: false unless column_exists?(:users, :add_auth_authority)
|
|
11
|
+
add_index :users, :email_address, unique: true unless index_exists?(:users, :email_address, unique: true)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def down
|
|
15
|
+
raise ActiveRecord::IrreversibleMigration, "Retain canonical credentials for the reviewed bridge rollback; retire columns only after reconciliation"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "rails/generators"
|
|
4
|
+
require "generators/add_auth/feature_configuration"
|
|
4
5
|
require "rails/generators/active_record"
|
|
5
6
|
|
|
6
7
|
module AddAuth
|
|
7
8
|
module Generators
|
|
8
9
|
class EmailLinkGenerator < ::Rails::Generators::Base
|
|
9
10
|
include ::Rails::Generators::Migration
|
|
11
|
+
include FeatureConfiguration
|
|
10
12
|
|
|
11
13
|
source_root File.expand_path("templates", __dir__)
|
|
14
|
+
class_option :enable, type: :boolean, default: true, desc: "Enable email sign-in after preparing its storage"
|
|
12
15
|
def self.next_migration_number(dirname) = ::ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
13
16
|
|
|
14
17
|
def dependencies
|
|
@@ -48,7 +51,7 @@ module AddAuth
|
|
|
48
51
|
unless File.read(File.join(destination_root, "config/routes.rb")).include?("add_auth/challenge.js")
|
|
49
52
|
route 'get "add_auth/challenge.js", to: "add_auth/assets#challenge"'
|
|
50
53
|
end
|
|
51
|
-
|
|
54
|
+
enable_feature(:email_link) if options[:enable]
|
|
52
55
|
say "Review and migrate, configure base_url/mail_from, a durable queue, shared rate-limit cache and a recurring add_auth:deliver_pending sweep."
|
|
53
56
|
end
|
|
54
57
|
end
|