add_auth 0.2.1 → 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 +36 -0
- data/README.md +57 -18
- data/ROADMAP.md +260 -242
- 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 +73 -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/rate_limit_cache.rb +17 -0
- data/lib/add_auth/rails/runtime.rb +173 -9
- 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 +7 -1
- 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 +86 -2
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module AddAuth
|
|
7
|
+
module Core
|
|
8
|
+
class AccountLifecycle
|
|
9
|
+
include Delivery
|
|
10
|
+
|
|
11
|
+
PURPOSES = %w[confirm reset_password unlock].freeze
|
|
12
|
+
TOKEN = /\Aac1:[A-Za-z0-9_-]{43}\z/
|
|
13
|
+
SECURITY_ATTRIBUTES = %w[id email email_address password password_confirmation password_digest encrypted_password
|
|
14
|
+
confirmed_at unconfirmed_email locked_at disabled_at deleted_at failed_attempts confirmation_token
|
|
15
|
+
reset_password_token unlock_token authentication_token].freeze
|
|
16
|
+
class Conflict < StandardError; end
|
|
17
|
+
class InvalidPassword < StandardError; end
|
|
18
|
+
class DeletionRejected < StandardError; end
|
|
19
|
+
|
|
20
|
+
class NewAccount
|
|
21
|
+
attr_reader :user, :user_id
|
|
22
|
+
def initialize(user:)
|
|
23
|
+
@user, @user_id = user, user.id
|
|
24
|
+
freeze
|
|
25
|
+
end
|
|
26
|
+
private_class_method :new
|
|
27
|
+
def inspect = "#<AddAuth::Core::AccountLifecycle::NewAccount [FILTERED]>"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.validate_host_write!(changes:, enabled:)
|
|
31
|
+
if enabled && (changes.keys.map(&:to_s) & %w[email_address unconfirmed_email]).any?
|
|
32
|
+
raise AddAuth::Error, "change account email through the account lifecycle command"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(store:, digest:, delivery_cipher:, policy:, password_policy:, trusted_address:, clock: Time, random: SecureRandom,
|
|
37
|
+
lifetime: 3600, notify: ->(**) {}, sessions: nil, step_up_policy: nil, profile_attributes: ->(_profile) { {} }, deletion_allowed: ->(_user) { true }, external_identities: nil)
|
|
38
|
+
raise ArgumentError, "proof lifetime must be between 60 and 86400 seconds" unless lifetime.is_a?(Numeric) && lifetime.between?(60, 86_400)
|
|
39
|
+
@store, @digest, @cipher, @policy, @password_policy, @trusted_address = store, digest, delivery_cipher, policy, password_policy, trusted_address
|
|
40
|
+
@clock, @random, @lifetime, @notify = clock, random, lifetime, notify
|
|
41
|
+
@sessions, @step_up_policy = sessions, step_up_policy
|
|
42
|
+
@profile_attributes = profile_attributes
|
|
43
|
+
@deletion_allowed = deletion_allowed
|
|
44
|
+
@external_identities = external_identities
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def register(identifier:, password:, profile: {})
|
|
48
|
+
return failure unless valid_new_password?(password)
|
|
49
|
+
register_account(identifier: identifier, password: password, profile: profile)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def register_external(identifier:, evidence:, profile: {})
|
|
53
|
+
return failure unless @external_identities
|
|
54
|
+
external = @external_identities.call
|
|
55
|
+
register_account(identifier: identifier, password: nil, profile: profile) do |user|
|
|
56
|
+
external.bind_new_account_in_transaction(registration: NewAccount.send(:new, user: user), evidence: evidence)
|
|
57
|
+
end
|
|
58
|
+
rescue ExternalIdentities::EnrollmentRejected
|
|
59
|
+
failure
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def issue(identifier:, purpose:, request_id: @random.uuid)
|
|
63
|
+
return failure unless PURPOSES.include?(purpose.to_s)
|
|
64
|
+
email = normalize(identifier)
|
|
65
|
+
return accepted unless email
|
|
66
|
+
@store.with_user(identifier: email) do |user|
|
|
67
|
+
next if @store.issued?(request_id: request_id)
|
|
68
|
+
next unless @policy.allowed?(user, purpose: purpose)
|
|
69
|
+
recipient = recipient_for(user, purpose.to_s)
|
|
70
|
+
next unless recipient
|
|
71
|
+
issue_in_transaction(user: user, purpose: purpose.to_s, recipient: recipient, request_id: request_id)
|
|
72
|
+
end
|
|
73
|
+
accepted
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def preview(token:, purpose:)
|
|
77
|
+
return failure unless token.is_a?(String) && TOKEN.match?(token) && PURPOSES.include?(purpose.to_s)
|
|
78
|
+
user, record = @store.inspect_token(digest: @digest.digest(token))
|
|
79
|
+
reason = rejection(user, record, purpose: purpose.to_s)
|
|
80
|
+
reason ? Result.failure(reason: reason) : Result.success(user: user, strategy: purpose.to_sym)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def consume(token:, purpose:, password: nil)
|
|
84
|
+
return failure unless token.is_a?(String) && TOKEN.match?(token) && PURPOSES.include?(purpose.to_s)
|
|
85
|
+
@store.with_token(digest: @digest.digest(token)) do |user, record|
|
|
86
|
+
reason = rejection(user, record, purpose: purpose.to_s)
|
|
87
|
+
next Result.failure(reason: reason) if reason
|
|
88
|
+
now = @clock.now
|
|
89
|
+
case purpose.to_s
|
|
90
|
+
when "confirm"
|
|
91
|
+
pending = user.unconfirmed_email
|
|
92
|
+
if pending && !pending.empty?
|
|
93
|
+
old_address = user.email_address
|
|
94
|
+
@store.promote_address(user: user, digest: address_digest(pending))
|
|
95
|
+
@store.update_account(user: user, email_address: pending, unconfirmed_email: nil, confirmed_at: now)
|
|
96
|
+
@store.revoke_authority(user: user, at: now)
|
|
97
|
+
@notify.call(user: user, kind: :email_changed, at: now, recipient: old_address)
|
|
98
|
+
@notify.call(user: user, kind: :email_changed, at: now, recipient: pending)
|
|
99
|
+
else
|
|
100
|
+
@store.update_account(user: user, confirmed_at: now)
|
|
101
|
+
@store.provision(user: user)
|
|
102
|
+
end
|
|
103
|
+
when "reset_password"
|
|
104
|
+
next failure unless valid_new_password?(password)
|
|
105
|
+
@store.replace_password(user: user, password: password)
|
|
106
|
+
@store.revoke_authority(user: user, at: now)
|
|
107
|
+
@notify.call(user: user, kind: :password_changed, at: now)
|
|
108
|
+
when "unlock"
|
|
109
|
+
@store.update_account(user: user, locked_at: nil, add_auth_locked_until: nil, failed_attempts: 0)
|
|
110
|
+
end
|
|
111
|
+
@store.consume(record: record, at: now)
|
|
112
|
+
Result.success(user: user, strategy: purpose.to_sym)
|
|
113
|
+
end
|
|
114
|
+
rescue Conflict, InvalidPassword
|
|
115
|
+
failure
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def change_email(user:, session:, identifier:)
|
|
119
|
+
email = normalize(identifier)
|
|
120
|
+
return failure unless email
|
|
121
|
+
with_account_change(user: user, session: session, purpose: :change_email) do |account|
|
|
122
|
+
next accepted if email == normalize(account.email_address)
|
|
123
|
+
@store.claim_address(user: account, digest: address_digest(email), address: email, state: "pending")
|
|
124
|
+
@store.update_account(user: account, unconfirmed_email: email)
|
|
125
|
+
issue_in_transaction(user: account, purpose: "confirm", recipient: email)
|
|
126
|
+
accepted
|
|
127
|
+
end
|
|
128
|
+
rescue Conflict
|
|
129
|
+
failure
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def change_password(user:, session:, password:)
|
|
133
|
+
return failure unless valid_new_password?(password)
|
|
134
|
+
with_account_change(user: user, session: session, purpose: :change_password) do |account|
|
|
135
|
+
@store.replace_password(user: account, password: password)
|
|
136
|
+
@store.revoke_authority(user: account, at: @clock.now)
|
|
137
|
+
@notify.call(user: account, kind: :password_changed, at: @clock.now)
|
|
138
|
+
accepted
|
|
139
|
+
end
|
|
140
|
+
rescue InvalidPassword
|
|
141
|
+
failure
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def valid_new_password?(password)
|
|
145
|
+
password.is_a?(String) && password.valid_encoding? && !password.include?("\0") && password.bytesize.between?(1, 1024) && @password_policy.call(password) == true
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def delete_account(user:, session:)
|
|
149
|
+
with_account_change(user: user, session: session, purpose: :delete_account) do |account|
|
|
150
|
+
next failure unless @deletion_allowed.call(account) == true
|
|
151
|
+
@store.revoke_authority(user: account, at: @clock.now)
|
|
152
|
+
@store.delete_account(user: account)
|
|
153
|
+
accepted
|
|
154
|
+
end
|
|
155
|
+
rescue DeletionRejected
|
|
156
|
+
failure
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The password lifecycle already owns the account transaction and lock.
|
|
160
|
+
def issue_unlock_in_transaction(user:)
|
|
161
|
+
return unless @policy.allowed?(user, purpose: :unlock)
|
|
162
|
+
recipient = recipient_for(user, "unlock")
|
|
163
|
+
issue_in_transaction(user: user, purpose: "unlock", recipient: recipient) if recipient
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
def register_account(identifier:, password:, profile:)
|
|
169
|
+
email = normalize(identifier)
|
|
170
|
+
return failure unless email && profile.is_a?(Hash) && profile.length <= 20
|
|
171
|
+
attributes = @profile_attributes.call(profile)
|
|
172
|
+
profile_valid = attributes.is_a?(Hash) && attributes.keys.all? { |name| (name.is_a?(String) || name.is_a?(Symbol)) && !SECURITY_ATTRIBUTES.include?(name.to_s) && !name.to_s.start_with?("add_auth_") }
|
|
173
|
+
raise AddAuth::Error, "registration profile must contain host profile fields only" unless profile_valid
|
|
174
|
+
outcome = @store.create_account(email: email, password: password, profile: attributes) do |user|
|
|
175
|
+
raise Conflict unless @policy.allowed?(user, purpose: :confirm)
|
|
176
|
+
yield user if block_given?
|
|
177
|
+
@store.claim_address(user: user, digest: address_digest(email), address: email, state: "current")
|
|
178
|
+
issue_in_transaction(user: user, purpose: "confirm", recipient: email)
|
|
179
|
+
end
|
|
180
|
+
(outcome == :invalid) ? failure : accepted
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def with_account_change(user:, session:, purpose:)
|
|
184
|
+
outcome = Result.failure(reason: :elevation_required)
|
|
185
|
+
return outcome unless @sessions && @step_up_policy
|
|
186
|
+
authorization = @sessions.with_elevation(user: user, session: session, purpose: purpose, policy: @step_up_policy) do |account|
|
|
187
|
+
outcome = @policy.allowed?(account, purpose: :manage) ? yield(account) : failure
|
|
188
|
+
end
|
|
189
|
+
authorization.success? ? outcome : authorization
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def accepted = Result.success(user: nil, strategy: :account_request)
|
|
193
|
+
def failure = Result.failure(reason: :invalid_credentials)
|
|
194
|
+
|
|
195
|
+
def normalize(value)
|
|
196
|
+
return unless value.is_a?(String) && value.valid_encoding? && value.bytesize.between?(1, 254)
|
|
197
|
+
value = value.strip.downcase
|
|
198
|
+
value if value.match?(/\A[^\s@]+@[^\s@]+\z/) && value.bytesize <= 254
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def address_digest(email) = @digest.digest("account-address:#{normalize(email)}")
|
|
202
|
+
|
|
203
|
+
def recipient_for(user, purpose)
|
|
204
|
+
if purpose == "confirm"
|
|
205
|
+
normalize(user.unconfirmed_email.to_s.empty? ? user.email_address : user.unconfirmed_email)
|
|
206
|
+
else
|
|
207
|
+
address = normalize(@trusted_address.call(user))
|
|
208
|
+
address if address == normalize(user.email_address)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def version(user, purpose)
|
|
213
|
+
@digest.digest(JSON.generate([purpose, user.id, user.email_address, user.unconfirmed_email,
|
|
214
|
+
user.password_digest, user.confirmed_at&.to_f, user.locked_at&.to_f, user.disabled_at&.to_f,
|
|
215
|
+
user.deleted_at&.to_f, user.add_auth_manual_lock]))
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def issue_in_transaction(user:, purpose:, recipient:, request_id: @random.uuid)
|
|
219
|
+
token = "ac1:#{@random.urlsafe_base64(32)}"
|
|
220
|
+
digest = @digest.digest(token)
|
|
221
|
+
expires_at = @clock.now + @lifetime
|
|
222
|
+
payload = @cipher.encrypt(token: JSON.generate(token: token, recipient: recipient, purpose: purpose), digest: digest, expires_at: expires_at)
|
|
223
|
+
@store.replace_pending(user: user, purpose: purpose, digest: digest, request_id: request_id,
|
|
224
|
+
address_digest: address_digest(recipient), account_version: version(user, purpose),
|
|
225
|
+
expires_at: expires_at, delivery_payload: payload, created_at: @clock.now)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def rejection(user, record, purpose: record&.purpose)
|
|
229
|
+
return :invalid_credentials unless user && record && record.purpose == purpose && PURPOSES.include?(purpose)
|
|
230
|
+
return :consumed_token if record.consumed_at
|
|
231
|
+
return :revoked_token if record.revoked_at
|
|
232
|
+
return :expired_token if record.expires_at <= @clock.now
|
|
233
|
+
return :invalid_credentials unless @policy.allowed?(user, purpose: purpose)
|
|
234
|
+
recipient = recipient_for(user, purpose)
|
|
235
|
+
return :invalid_credentials unless recipient && record.address_digest == address_digest(recipient) && record.account_version == version(user, purpose)
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def delivery_details(user, record)
|
|
240
|
+
raw = @cipher.decrypt(payload: record.delivery_payload, digest: record.digest)
|
|
241
|
+
return unless raw
|
|
242
|
+
payload = JSON.parse(raw)
|
|
243
|
+
return unless payload["purpose"] == record.purpose && payload["recipient"] == recipient_for(user, record.purpose) &&
|
|
244
|
+
payload["token"].is_a?(String) && @digest.matches?(record.digest, payload["token"])
|
|
245
|
+
{recipient: payload.fetch("recipient"), token: payload.fetch("token"), purpose: record.purpose}
|
|
246
|
+
rescue JSON::ParserError, KeyError
|
|
247
|
+
nil
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Core
|
|
5
|
+
class AccountPolicy
|
|
6
|
+
def initialize(enabled:, eligible: ->(_user) { true }, clock: Time)
|
|
7
|
+
@enabled, @eligible, @clock = enabled, eligible, clock
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def allowed?(user, purpose: :sign_in)
|
|
11
|
+
return denial(user).nil? if %i[sign_in manage].include?(purpose.to_sym)
|
|
12
|
+
return false unless available?(user)
|
|
13
|
+
return true unless @enabled
|
|
14
|
+
case purpose.to_sym
|
|
15
|
+
when :confirm
|
|
16
|
+
!value(user, :confirmed_at) || !value(user, :unconfirmed_email).to_s.empty?
|
|
17
|
+
when :unlock
|
|
18
|
+
locked?(user) && !value(user, :add_auth_manual_lock)
|
|
19
|
+
when :reset_password
|
|
20
|
+
!!value(user, :confirmed_at) && !value(user, :add_auth_strict)
|
|
21
|
+
else false
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Only a verified-password result may disclose this to an anonymous client.
|
|
26
|
+
def denial(user)
|
|
27
|
+
return :disabled unless available?(user)
|
|
28
|
+
return unless @enabled
|
|
29
|
+
return :unconfirmed unless value(user, :confirmed_at)
|
|
30
|
+
:locked if locked?(user)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def locked?(user)
|
|
34
|
+
value(user, :locked_at) && (value(user, :add_auth_manual_lock) || !value(user, :add_auth_locked_until) || value(user, :add_auth_locked_until) > @clock.now)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def available?(user)
|
|
40
|
+
user && @eligible.call(user) == true &&
|
|
41
|
+
(!user.respond_to?(:add_auth_authority) || user.add_auth_authority == "add_auth") &&
|
|
42
|
+
(!@enabled || !(value(user, :disabled_at) || value(user, :deleted_at)))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def value(user, name)
|
|
46
|
+
user.public_send(name) if user.respond_to?(name)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module AddAuth
|
|
7
|
+
module Core
|
|
8
|
+
class ExternalIdentities
|
|
9
|
+
# Only configured server code may supply this verifier. It must validate a
|
|
10
|
+
# live middleware/library result, including issuer, audience, state/nonce
|
|
11
|
+
# and (for reauthentication) auth_time/max_age. A request auth hash is not
|
|
12
|
+
# such a result. Core deliberately contains no protocol or token decoder.
|
|
13
|
+
class Configuration
|
|
14
|
+
attr_reader :id, :issuer, :audience
|
|
15
|
+
|
|
16
|
+
def initialize(id:, issuer:, audience:, verifier:, clock: Time)
|
|
17
|
+
@id, @issuer, @audience = [id, issuer, audience].map { |value| self.class.text(value) }
|
|
18
|
+
raise ArgumentError, "a trusted server verifier is required" unless verifier.respond_to?(:call)
|
|
19
|
+
@verifier, @clock = verifier, clock
|
|
20
|
+
freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.text(value)
|
|
24
|
+
raise ArgumentError, "invalid external identity field" unless value.is_a?(String) && value.valid_encoding? &&
|
|
25
|
+
value.bytesize.between?(1, 2048) && !value.match?(/[\x00-\x1f\x7f]/)
|
|
26
|
+
value.dup.freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def verify(server_result:, transaction:)
|
|
30
|
+
return unless transaction.is_a?(Pending) && transaction.configuration.equal?(self)
|
|
31
|
+
claims = @verifier.call(server_result: server_result, transaction: transaction)
|
|
32
|
+
return unless claims.is_a?(Hash) && claims[:issuer] == issuer && claims[:audience] == audience
|
|
33
|
+
VerifiedIdentity.send(:new, configuration: self, transaction: transaction,
|
|
34
|
+
subject: self.class.text(claims[:subject]), provenance: self.class.text(claims[:provenance]),
|
|
35
|
+
authenticated_at: claims[:authenticated_at], verified_at: @clock.now)
|
|
36
|
+
rescue ArgumentError
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def namespace(subject) = ::Digest::SHA256.hexdigest(JSON.generate([id, issuer, audience, subject]))
|
|
41
|
+
def inspect = "#<AddAuth::Core::ExternalIdentities::Configuration [FILTERED]>"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class Pending
|
|
45
|
+
attr_reader :configuration, :id, :purpose, :user_id, :session_id, :session_digest,
|
|
46
|
+
:policy_version, :issued_at, :expires_at
|
|
47
|
+
|
|
48
|
+
def initialize(configuration:, id:, purpose:, user_id:, session_id:, session_digest:, policy_version:, issued_at:, expires_at:)
|
|
49
|
+
@configuration = configuration
|
|
50
|
+
@id, @purpose = id.dup.freeze, purpose.to_sym
|
|
51
|
+
@user_id, @session_id = user_id&.dup&.freeze, session_id&.dup&.freeze
|
|
52
|
+
@session_digest = session_digest&.dup&.freeze
|
|
53
|
+
@policy_version = policy_version
|
|
54
|
+
@issued_at, @expires_at = issued_at.dup.freeze, expires_at.dup.freeze
|
|
55
|
+
freeze
|
|
56
|
+
end
|
|
57
|
+
private_class_method :new
|
|
58
|
+
|
|
59
|
+
def inspect = "#<AddAuth::Core::ExternalIdentities::Pending [FILTERED]>"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
class VerifiedIdentity
|
|
63
|
+
attr_reader :configuration, :transaction, :subject, :provenance, :authenticated_at, :verified_at
|
|
64
|
+
|
|
65
|
+
def initialize(configuration:, transaction:, subject:, provenance:, authenticated_at:, verified_at:)
|
|
66
|
+
raise ArgumentError, "invalid authentication occurrence time" unless authenticated_at.nil? || authenticated_at.is_a?(Time)
|
|
67
|
+
@configuration, @transaction, @subject, @provenance = configuration, transaction, subject, provenance
|
|
68
|
+
@authenticated_at = authenticated_at&.dup&.freeze
|
|
69
|
+
@verified_at = verified_at.dup.freeze
|
|
70
|
+
freeze
|
|
71
|
+
end
|
|
72
|
+
private_class_method :new
|
|
73
|
+
|
|
74
|
+
def provider_id = configuration.id
|
|
75
|
+
def issuer = configuration.issuer
|
|
76
|
+
def audience = configuration.audience
|
|
77
|
+
def transaction_id = transaction.id
|
|
78
|
+
def purpose = transaction.purpose
|
|
79
|
+
def namespace = configuration.namespace(subject)
|
|
80
|
+
def inspect = "#<AddAuth::Core::ExternalIdentities::VerifiedIdentity [FILTERED]>"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Session evidence is issued only after locked binding resolution. It is
|
|
84
|
+
# never a StepUp::Evidence merely because the callback was just received.
|
|
85
|
+
class SessionProof
|
|
86
|
+
attr_reader :user_id, :credential_id, :credential_version, :verified_at
|
|
87
|
+
def initialize(user_id:, credential_id:, credential_version:, verified_at:)
|
|
88
|
+
@user_id, @credential_id = user_id, credential_id.to_s.dup.freeze
|
|
89
|
+
@credential_version, @verified_at = credential_version.dup.freeze, verified_at.dup.freeze
|
|
90
|
+
freeze
|
|
91
|
+
end
|
|
92
|
+
private_class_method :new
|
|
93
|
+
def user_verification = false
|
|
94
|
+
def inspect = "#<AddAuth::Core::ExternalIdentities::SessionProof [FILTERED]>"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|