add_auth 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +4 -4
- data/app/controllers/add_auth/accounts_controller.rb +5 -1
- data/app/controllers/add_auth/passkeys_controller.rb +6 -4
- data/app/jobs/add_auth/account_delivery_job.rb +2 -1
- data/app/mailers/add_auth/account_mailer.rb +2 -1
- data/app/views/add_auth/account_mailer/link.text.erb +4 -0
- data/app/views/add_auth/accounts/_proof.html.erb +8 -1
- data/lib/add_auth/core/account_lifecycle.rb +28 -4
- data/lib/add_auth/core/account_policy.rb +15 -1
- data/lib/add_auth/core/external_identities.rb +15 -0
- data/lib/add_auth/core/intake.rb +11 -6
- data/lib/add_auth/core/passwords/lifecycle.rb +15 -6
- data/lib/add_auth/core/rate_limit.rb +11 -0
- data/lib/add_auth/core/sessions.rb +9 -2
- data/lib/add_auth/core/strategies/passkey.rb +5 -2
- data/lib/add_auth/rails/runtime.rb +24 -4
- data/lib/add_auth/version.rb +1 -1
- data/lib/generators/add_auth/install/templates/initializer.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0f425748354f43e9f37579150ae2fe1cae7797f5da8c37af7e9f330b783a2d96
|
|
4
|
+
data.tar.gz: 8b7187ffe5b74c138d68cf4ffac7edf7c65e3d258941edec49c72f55a743c621
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 51c64dbd5366aac908be7df8fd4f82c9501ce081400fb77976ece4b92fa9066a7aca0fc799a7ba4e99ce3c19609190db702373e6e45fddc055795df8b23898c7
|
|
7
|
+
data.tar.gz: 9a37bfc7978081e75ea517ae9ba414fa4b2771ec7d9bb8ea6a56acf6da1ee3fe2b0882989e9177198a701ce409e2b05af225214389bb5361b0845bb5d6df5f01
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.1 — 2026-10-21
|
|
4
|
+
|
|
5
|
+
Security hardening; upgrading is recommended. Refresh ejected account and
|
|
6
|
+
passkey controllers/views through the normal review process.
|
|
7
|
+
|
|
8
|
+
- The first email confirmation of an account with a linked sign-in provider
|
|
9
|
+
names that provider in the email and on the page. Only an explicit "Confirm
|
|
10
|
+
and keep" preserves the link; any other confirmation removes it.
|
|
11
|
+
- Automatic password lockout gates only password sign-in. It no longer revokes
|
|
12
|
+
existing sessions or blocks passkey, email-link or provider sign-in, never
|
|
13
|
+
counts failures for accounts that cannot use a password, and is cleared by a
|
|
14
|
+
successful password reset. Manual locks are unchanged.
|
|
15
|
+
- Sign out everywhere also revokes outstanding email proofs, account proofs
|
|
16
|
+
(such as a pending address change), passkey/provider ceremonies and mobile
|
|
17
|
+
handoffs.
|
|
18
|
+
- Rate limits check the IP budget first and charge the identifier budget only
|
|
19
|
+
after a configured challenge passes. Passkey sign-in option requests use their
|
|
20
|
+
own IP budget, and `passkeys.anonymous_limit` now applies per IPv4 address or
|
|
21
|
+
IPv6 /64 rather than globally.
|
|
22
|
+
|
|
3
23
|
## 0.5.0 — 2026-09-18
|
|
4
24
|
|
|
5
25
|
- Honor Rails' `primary_key_type` generator setting for all AddAuth-owned tables.
|
data/README.md
CHANGED
|
@@ -29,15 +29,15 @@ for verification in a disposable host.
|
|
|
29
29
|
|
|
30
30
|
## Quickstart
|
|
31
31
|
|
|
32
|
-
These commands target 0.5.
|
|
33
|
-
[RubyGems publication](https://rubygems.org/gems/add_auth/versions/0.5.
|
|
32
|
+
These commands target 0.5.1 and are available after its
|
|
33
|
+
[RubyGems publication](https://rubygems.org/gems/add_auth/versions/0.5.1).
|
|
34
34
|
Check package availability before changing your app’s bundle.
|
|
35
35
|
|
|
36
36
|
Install the `add_auth` gem from RubyGems through your Rails app’s Gemfile,
|
|
37
37
|
then enable password and email-link sign-in. Use Ruby 3.3+ and Rails 8.0+
|
|
38
38
|
with Active Record, and run the commands below from your Rails app’s root.
|
|
39
39
|
|
|
40
|
-
**Release availability:** these commands require the published `0.5.
|
|
40
|
+
**Release availability:** these commands require the published `0.5.1` package.
|
|
41
41
|
If it is not yet listed on [RubyGems](https://rubygems.org/gems/add_auth/versions),
|
|
42
42
|
wait for publication; see [release status](https://addauthgem.com/release-status/).
|
|
43
43
|
Start in your app’s development environment; use the deployment settings
|
|
@@ -48,7 +48,7 @@ below before enabling sign-in for users.
|
|
|
48
48
|
|
|
49
49
|
```ruby
|
|
50
50
|
# Gemfile
|
|
51
|
-
gem "add_auth", "~> 0.5.
|
|
51
|
+
gem "add_auth", "~> 0.5.1"
|
|
52
52
|
```
|
|
53
53
|
|
|
54
54
|
Bundler downloads the package from RubyGems and records the resolved
|
|
@@ -52,6 +52,7 @@ module AddAuth
|
|
|
52
52
|
def proof
|
|
53
53
|
@purpose = purpose
|
|
54
54
|
result = Rails::Runtime.accounts.preview(token: params[:token], purpose: @purpose)
|
|
55
|
+
@providers = Rails::Runtime.provider_labels(result.credential) if result.success?
|
|
55
56
|
page(result.success? ? "proof" : "invalid_proof")
|
|
56
57
|
end
|
|
57
58
|
|
|
@@ -59,11 +60,14 @@ module AddAuth
|
|
|
59
60
|
@purpose = purpose
|
|
60
61
|
admitted = Rails::Runtime.intake.anonymous(ip: request.remote_ip, action: :account_consume)
|
|
61
62
|
return intake_failure(admitted) if admitted.is_a?(Symbol)
|
|
62
|
-
result = Rails::Runtime.accounts.consume(token: params[:token], purpose: @purpose, password: params[:password]
|
|
63
|
+
result = Rails::Runtime.accounts.consume(token: params[:token], purpose: @purpose, password: params[:password],
|
|
64
|
+
keep_providers: params[:providers] == "keep")
|
|
63
65
|
if result.success?
|
|
64
66
|
redirect_to "/sign-in", status: :see_other, notice: "Your account has been updated. Sign in to start a new session."
|
|
65
67
|
else
|
|
66
68
|
@error = "This request could not be completed. Check the password requirements or request a new link."
|
|
69
|
+
preview = Rails::Runtime.accounts.preview(token: params[:token], purpose: @purpose)
|
|
70
|
+
@providers = Rails::Runtime.provider_labels(preview.credential) if preview.success?
|
|
67
71
|
page("proof", status: :unprocessable_entity)
|
|
68
72
|
end
|
|
69
73
|
end
|
|
@@ -30,9 +30,11 @@ module AddAuth
|
|
|
30
30
|
finish(result, destination: "/passkeys", grant: result.grant)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
# Conditional UI requests options on page load. Keep that off the
|
|
34
|
+
# password sign_in budget while still applying the sign_in challenge.
|
|
33
35
|
def authentication_options
|
|
34
|
-
return unless admitted(:sign_in)
|
|
35
|
-
options(service.authentication_options(browser_secret: browser_secret))
|
|
36
|
+
return unless admitted(:sign_in, budget: :passkey_options)
|
|
37
|
+
options(service.authentication_options(browser_secret: browser_secret, ip: request.remote_ip))
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
def authenticate
|
|
@@ -92,8 +94,8 @@ module AddAuth
|
|
|
92
94
|
value.to_unsafe_h if value.is_a?(ActionController::Parameters)
|
|
93
95
|
end
|
|
94
96
|
|
|
95
|
-
def admitted(action)
|
|
96
|
-
result = Rails::Runtime.intake.anonymous(ip: request.remote_ip, action: action, challenge_token: challenge_token)
|
|
97
|
+
def admitted(action, budget: action)
|
|
98
|
+
result = Rails::Runtime.intake.anonymous(ip: request.remote_ip, action: action, challenge_token: challenge_token, budget: budget)
|
|
97
99
|
return true if result == true
|
|
98
100
|
render json: {error: "Verification could not start. Try again shortly."}, status: if result == :rate_limited
|
|
99
101
|
429
|
|
@@ -7,7 +7,8 @@ module AddAuth
|
|
|
7
7
|
return unless record
|
|
8
8
|
deliver(service: Rails::Runtime.accounts, record: record, mailer: AccountMailer) do |delivery|
|
|
9
9
|
AccountMailer.link(recipient: delivery.fetch(:recipient), purpose: delivery.fetch(:purpose),
|
|
10
|
-
url: Rails::Runtime.account_proof_url(delivery.fetch(:token), purpose: delivery.fetch(:purpose))
|
|
10
|
+
url: Rails::Runtime.account_proof_url(delivery.fetch(:token), purpose: delivery.fetch(:purpose)),
|
|
11
|
+
providers: Rails::Runtime.provider_labels(delivery.fetch(:providers, [])))
|
|
11
12
|
end
|
|
12
13
|
end
|
|
13
14
|
end
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module AddAuth
|
|
4
4
|
class AccountMailer < SignInMailer
|
|
5
|
-
def link(recipient:, url:, purpose:)
|
|
5
|
+
def link(recipient:, url:, purpose:, providers: [])
|
|
6
|
+
@providers = providers
|
|
6
7
|
@instruction = {"confirm" => "Confirm your email address", "reset_password" => "Choose a new password", "unlock" => "Unlock your account"}.fetch(purpose)
|
|
7
8
|
@url = url
|
|
8
9
|
mail(to: recipient, from: Rails::Runtime.config.mail_from, subject: @instruction,
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
Open this link, then use the button on the page to confirm your request:
|
|
4
4
|
<%= @url %>
|
|
5
|
+
<% if @providers.present? %>
|
|
6
|
+
|
|
7
|
+
This account was started by signing in with <%= @providers.to_sentence %>. Keep <%= @providers.to_sentence %> only if you used it to create this account. If you did not, choose "Confirm without <%= @providers.to_sentence %>".
|
|
8
|
+
<% end %>
|
|
5
9
|
|
|
6
10
|
This link expires in <%= AddAuth.configuration.lifecycle.proof_lifetime.to_i / 60 %> minutes and can be used once.
|
|
7
11
|
If you did not request this message, you can ignore it.
|
|
@@ -11,6 +11,13 @@
|
|
|
11
11
|
<%= form.password_field :password, required: true, autocomplete: "new-password", class: add_auth_class(:input) %>
|
|
12
12
|
</div>
|
|
13
13
|
<% end %>
|
|
14
|
-
|
|
14
|
+
<% if @purpose == "confirm" && @providers.present? %>
|
|
15
|
+
<% providers = @providers.to_sentence %>
|
|
16
|
+
<p>This account was started by signing in with <%= providers %>. Keep <%= providers %> only if you used it to create this account.</p>
|
|
17
|
+
<%= form.button "Confirm and keep #{providers}", name: "providers", value: "keep", class: add_auth_class(:button) %>
|
|
18
|
+
<%= form.button "Confirm without #{providers}", name: "providers", value: "remove", class: add_auth_class(:button) %>
|
|
19
|
+
<% else %>
|
|
20
|
+
<%= form.submit title, class: add_auth_class(:button) %>
|
|
21
|
+
<% end %>
|
|
15
22
|
<% end %>
|
|
16
23
|
<p><%= link_to "Back to sign in", "/sign-in" %></p>
|
|
@@ -35,7 +35,8 @@ module AddAuth
|
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
def initialize(store:, digest:, delivery_cipher:, policy:, password_policy:, trusted_address:, clock: Time, random: SecureRandom,
|
|
38
|
-
lifetime: 3600, notify: ->(**) {}, sessions: nil, step_up_policy: nil, profile_attributes: ->(_profile) { {} }, deletion_allowed: ->(_user) { true }, external_identities: nil
|
|
38
|
+
lifetime: 3600, notify: ->(**) {}, sessions: nil, step_up_policy: nil, profile_attributes: ->(_profile) { {} }, deletion_allowed: ->(_user) { true }, external_identities: nil,
|
|
39
|
+
external_bindings: nil)
|
|
39
40
|
raise ArgumentError, "proof lifetime must be between 60 and 86400 seconds" unless lifetime.is_a?(Numeric) && lifetime.between?(60, 86_400)
|
|
40
41
|
@store, @digest, @cipher, @policy, @password_policy, @trusted_address = store, digest, delivery_cipher, policy, password_policy, trusted_address
|
|
41
42
|
@clock, @random, @lifetime, @notify = clock, random, lifetime, notify
|
|
@@ -43,6 +44,7 @@ module AddAuth
|
|
|
43
44
|
@profile_attributes = profile_attributes
|
|
44
45
|
@deletion_allowed = deletion_allowed
|
|
45
46
|
@external_identities = external_identities
|
|
47
|
+
@external_bindings = external_bindings
|
|
46
48
|
end
|
|
47
49
|
|
|
48
50
|
def register(identifier:, password:, profile: {}, replacing: nil, user_agent: nil, ip_address: nil, remember: false)
|
|
@@ -86,14 +88,21 @@ module AddAuth
|
|
|
86
88
|
accepted
|
|
87
89
|
end
|
|
88
90
|
|
|
91
|
+
# A confirm preview's credential lists the provider configurations whose
|
|
92
|
+
# bindings the address owner must explicitly keep (see #consume).
|
|
89
93
|
def preview(token:, purpose:)
|
|
90
94
|
return failure unless token.is_a?(String) && TOKEN.match?(token) && PURPOSES.include?(purpose.to_s)
|
|
91
95
|
user, record = @store.inspect_token(digest: @digest.digest(token))
|
|
92
96
|
reason = rejection(user, record, purpose: purpose.to_s)
|
|
93
|
-
|
|
97
|
+
return Result.failure(reason: reason) if reason
|
|
98
|
+
Result.success(user: user, strategy: purpose.to_sym, credential: (unadopted_providers(user) if purpose.to_s == "confirm"))
|
|
94
99
|
end
|
|
95
100
|
|
|
96
|
-
|
|
101
|
+
# A first confirmation is the address owner's adoption decision for any
|
|
102
|
+
# provider binding made before the address was proved. Only an explicit
|
|
103
|
+
# keep_providers: true preserves those bindings; any other confirmation
|
|
104
|
+
# revokes them in the same transaction (canonical plan section 18, A1).
|
|
105
|
+
def consume(token:, purpose:, password: nil, keep_providers: false)
|
|
97
106
|
return failure unless token.is_a?(String) && TOKEN.match?(token) && PURPOSES.include?(purpose.to_s)
|
|
98
107
|
@store.with_token(digest: @digest.digest(token)) do |user, record|
|
|
99
108
|
reason = rejection(user, record, purpose: purpose.to_s)
|
|
@@ -110,12 +119,14 @@ module AddAuth
|
|
|
110
119
|
@notify.call(user: user, kind: :email_changed, at: now, recipient: old_address)
|
|
111
120
|
@notify.call(user: user, kind: :email_changed, at: now, recipient: pending)
|
|
112
121
|
else
|
|
122
|
+
@external_bindings.revoke_all_in_transaction(user_id: user.id, at: now) if keep_providers != true && unadopted_providers(user).any?
|
|
113
123
|
@store.update_account(user: user, confirmed_at: now)
|
|
114
124
|
provision_account(user)
|
|
115
125
|
end
|
|
116
126
|
when "reset_password"
|
|
117
127
|
next failure unless valid_new_password?(password)
|
|
118
128
|
@store.replace_password(user: user, password: password)
|
|
129
|
+
clear_password_lock(user)
|
|
119
130
|
@store.revoke_authority(user: user, at: now)
|
|
120
131
|
@notify.call(user: user, kind: :password_changed, at: now)
|
|
121
132
|
when "unlock"
|
|
@@ -214,6 +225,18 @@ module AddAuth
|
|
|
214
225
|
failure
|
|
215
226
|
end
|
|
216
227
|
|
|
228
|
+
# A proved reset ends brute-force lockout, never a manual lock.
|
|
229
|
+
def clear_password_lock(user)
|
|
230
|
+
attempts = user.respond_to?(:failed_attempts) ? user.failed_attempts.to_i : 0
|
|
231
|
+
return if user.add_auth_manual_lock || (!user.locked_at && attempts.zero?)
|
|
232
|
+
@store.update_account(user: user, locked_at: nil, add_auth_locked_until: nil, failed_attempts: 0)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def unadopted_providers(user)
|
|
236
|
+
return [].freeze unless @external_bindings && user && !user.confirmed_at
|
|
237
|
+
@external_bindings.provider_ids(user_id: user.id)
|
|
238
|
+
end
|
|
239
|
+
|
|
217
240
|
def provision_account(user)
|
|
218
241
|
tracked = user.respond_to?(:add_auth_provisioned_at)
|
|
219
242
|
return if tracked && user.add_auth_provisioned_at
|
|
@@ -286,7 +309,8 @@ module AddAuth
|
|
|
286
309
|
payload = JSON.parse(raw)
|
|
287
310
|
return unless payload["purpose"] == record.purpose && payload["recipient"] == recipient_for(user, record.purpose) &&
|
|
288
311
|
payload["token"].is_a?(String) && @digest.matches?(record.digest, payload["token"])
|
|
289
|
-
{recipient: payload.fetch("recipient"), token: payload.fetch("token"), purpose: record.purpose
|
|
312
|
+
{recipient: payload.fetch("recipient"), token: payload.fetch("token"), purpose: record.purpose,
|
|
313
|
+
providers: (record.purpose == "confirm") ? unadopted_providers(user) : []}
|
|
290
314
|
rescue JSON::ParserError, KeyError
|
|
291
315
|
nil
|
|
292
316
|
end
|
|
@@ -41,13 +41,27 @@ module AddAuth
|
|
|
41
41
|
return :disabled unless available?(user)
|
|
42
42
|
return unless @enabled
|
|
43
43
|
return :unconfirmed if !value(user, :confirmed_at) && (confirmation_required? || !password_account?(user))
|
|
44
|
-
:locked if
|
|
44
|
+
:locked if account_locked?(user)
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
# Password results may also disclose an automatic lock to the holder of
|
|
48
|
+
# the correct password; other methods never consult this.
|
|
49
|
+
def password_denial(user) = denial(user) || ((@enabled && locked?(user)) ? :locked : nil)
|
|
50
|
+
|
|
51
|
+
# Any active lock, including the automatic password lock.
|
|
47
52
|
def locked?(user)
|
|
48
53
|
value(user, :locked_at) && (value(user, :add_auth_manual_lock) || !value(user, :add_auth_locked_until) || value(user, :add_auth_locked_until) > @clock.now)
|
|
49
54
|
end
|
|
50
55
|
|
|
56
|
+
# Manual and imported (untimed) locks deny the whole account. An automatic
|
|
57
|
+
# brute-force lock gates only the password method (plan section 18, A2).
|
|
58
|
+
def account_locked?(user) = locked?(user) && !self.class.automatic_lock?(user, now: @clock.now)
|
|
59
|
+
|
|
60
|
+
def self.automatic_lock?(user, now:)
|
|
61
|
+
read = ->(name) { user.public_send(name) if user.respond_to?(name) }
|
|
62
|
+
!!(read.call(:locked_at) && !read.call(:add_auth_manual_lock) && read.call(:add_auth_locked_until) && read.call(:add_auth_locked_until) > now)
|
|
63
|
+
end
|
|
64
|
+
|
|
51
65
|
private
|
|
52
66
|
|
|
53
67
|
def password_account?(user)
|
|
@@ -185,6 +185,21 @@ module AddAuth
|
|
|
185
185
|
self.class.invalidate_credentials_in_transaction(store: @store, user_id: user_id, at: at)
|
|
186
186
|
end
|
|
187
187
|
|
|
188
|
+
# Bindings persist while provider sign-in is disabled, so G3 reaches them
|
|
189
|
+
# through this narrow port rather than the enabled service. G3 owns the
|
|
190
|
+
# adoption decision; this only reports and revokes live bindings.
|
|
191
|
+
class Bindings
|
|
192
|
+
def initialize(store:)
|
|
193
|
+
@store = store
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def provider_ids(user_id:) = @store.bindings(user_id: user_id).map(&:provider_id).uniq.sort.freeze
|
|
197
|
+
|
|
198
|
+
def revoke_all_in_transaction(user_id:, at:)
|
|
199
|
+
@store.bindings(user_id: user_id).each { |identity| @store.revoke(identity: identity, at: at) }.size
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
188
203
|
def credential_current?(user:, id:, version:)
|
|
189
204
|
return false unless @enabled
|
|
190
205
|
identity = @store.binding_for_user(user_id: user.id, id: id)
|
data/lib/add_auth/core/intake.rb
CHANGED
|
@@ -14,19 +14,24 @@ module AddAuth
|
|
|
14
14
|
@on_challenge_unavailable = on_challenge_unavailable
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
# An exhausted IP budget stops before the identifier budget, and the
|
|
18
|
+
# identifier is charged only after any configured challenge passes, so
|
|
19
|
+
# other clients cannot spend a target's budget (plan section 18, A4).
|
|
17
20
|
def call(identifier:, ip:, action:, challenge_token: nil)
|
|
18
21
|
value = (identifier.is_a?(String) && identifier.valid_encoding? && identifier.bytesize.between?(1, 254)) ? @normalizer.call(identifier) : ""
|
|
19
22
|
value = "" unless value.is_a?(String) && value.valid_encoding? && value.bytesize.between?(1, 254)
|
|
20
|
-
|
|
21
|
-
identifier_ok = @limiter.call(key: @digest.digest("identifier:#{value}:#{action}"), limit: 5)
|
|
22
|
-
return :rate_limited unless ip_ok && identifier_ok
|
|
23
|
+
return :rate_limited unless @limiter.call(key: @digest.digest("ip:#{ip}:#{action}"), limit: 30)
|
|
23
24
|
return :invalid_credentials if value.to_s.empty?
|
|
24
25
|
rejection = verify_challenge(ip: ip, action: action, challenge_token: challenge_token)
|
|
25
|
-
rejection
|
|
26
|
+
return rejection if rejection
|
|
27
|
+
return :rate_limited unless @limiter.call(key: @digest.digest("identifier:#{value}:#{action}"), limit: 5)
|
|
28
|
+
value
|
|
26
29
|
end
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
# budget separates an IP counter from the challenge action, so a
|
|
32
|
+
# background request cannot spend the budget of an interactive form.
|
|
33
|
+
def anonymous(ip:, action:, challenge_token: nil, budget: action)
|
|
34
|
+
return :rate_limited unless @limiter.call(key: @digest.digest("ip:#{ip}:#{budget}"), limit: 30)
|
|
30
35
|
verify_challenge(ip: ip, action: action, challenge_token: challenge_token) || true
|
|
31
36
|
end
|
|
32
37
|
|
|
@@ -1,19 +1,30 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "add_auth/core/account_policy"
|
|
4
|
+
|
|
3
5
|
module AddAuth
|
|
4
6
|
module Core
|
|
5
7
|
module Passwords
|
|
6
8
|
# Called only inside the owning account transaction. The adapter supplies
|
|
7
9
|
# Rails hashing and persistence; these decisions are shared by entry paths.
|
|
8
10
|
class Lifecycle
|
|
9
|
-
|
|
11
|
+
# password_allowed reports whether this account may use password sign-in
|
|
12
|
+
# at all (feature enabled, stored password, not strict). Accounts that
|
|
13
|
+
# cannot use a password never accumulate failures or locks.
|
|
14
|
+
def initialize(store:, policy:, enabled:, maximum_attempts: 20, unlock_in: 3600, clock: Time, issue_unlock: ->(_user) {},
|
|
15
|
+
password_allowed: ->(_user) { true })
|
|
10
16
|
unless maximum_attempts.is_a?(Integer) && maximum_attempts.between?(1, 100) &&
|
|
11
17
|
unlock_in.is_a?(Numeric) && unlock_in.finite? && unlock_in.between?(60, 86_400)
|
|
12
18
|
raise ArgumentError, "configure bounded password attempts and unlock duration"
|
|
13
19
|
end
|
|
14
20
|
@store, @policy, @enabled, @maximum, @unlock_in, @clock, @issue_unlock = store, policy, enabled, maximum_attempts, unlock_in, clock, issue_unlock
|
|
21
|
+
@password_allowed = password_allowed
|
|
15
22
|
end
|
|
16
23
|
|
|
24
|
+
# The automatic lock gates the password method only; it never denies
|
|
25
|
+
# other methods or revokes existing authority.
|
|
26
|
+
def locked?(user) = @enabled && !!user && AccountPolicy.automatic_lock?(user, now: @clock.now)
|
|
27
|
+
|
|
17
28
|
def prepare(user:)
|
|
18
29
|
return unless @enabled && user && !user.add_auth_manual_lock && user.locked_at &&
|
|
19
30
|
user.add_auth_locked_until && user.add_auth_locked_until <= @clock.now
|
|
@@ -21,7 +32,8 @@ module AddAuth
|
|
|
21
32
|
end
|
|
22
33
|
|
|
23
34
|
def verified(user:, password:, valid:, rehash: true)
|
|
24
|
-
return false unless user && @policy.call(user) == true
|
|
35
|
+
return false unless user && @policy.call(user) == true && @password_allowed.call(user) == true
|
|
36
|
+
return false if locked?(user)
|
|
25
37
|
if valid
|
|
26
38
|
@store.update_account(user: user, failed_attempts: 0) if @enabled && user.failed_attempts != 0
|
|
27
39
|
if rehash && user.respond_to?(:add_auth_password_scheme) && user.add_auth_password_scheme == "devise_bcrypt" && assignable?(password)
|
|
@@ -49,10 +61,7 @@ module AddAuth
|
|
|
49
61
|
changes[:add_auth_locked_until] = @clock.now + @unlock_in
|
|
50
62
|
end
|
|
51
63
|
@store.update_account(user: user, **changes)
|
|
52
|
-
if attempts >= @maximum
|
|
53
|
-
@store.revoke_authority(user: user, at: @clock.now)
|
|
54
|
-
@issue_unlock.call(user)
|
|
55
|
-
end
|
|
64
|
+
@issue_unlock.call(user) if attempts >= @maximum
|
|
56
65
|
end
|
|
57
66
|
end
|
|
58
67
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
|
|
3
5
|
module AddAuth
|
|
4
6
|
module Core
|
|
5
7
|
class RateLimit
|
|
@@ -25,6 +27,15 @@ module AddAuth
|
|
|
25
27
|
end
|
|
26
28
|
counts.all? { |count| count <= limit }
|
|
27
29
|
end
|
|
30
|
+
|
|
31
|
+
# One budget per IPv4 address or IPv6 /64, which a single client
|
|
32
|
+
# commonly controls in full.
|
|
33
|
+
def self.network(ip)
|
|
34
|
+
address = IPAddr.new(ip.to_s).native
|
|
35
|
+
address.ipv6? ? "#{address.mask(64)}/64" : address.to_s
|
|
36
|
+
rescue IPAddr::Error
|
|
37
|
+
"unknown"
|
|
38
|
+
end
|
|
28
39
|
end
|
|
29
40
|
end
|
|
30
41
|
end
|
|
@@ -31,7 +31,8 @@ module AddAuth
|
|
|
31
31
|
Page = Struct.new(:entries, :next_cursor)
|
|
32
32
|
|
|
33
33
|
def initialize(store:, digest:, eligible:, lifetime: 43_200, idle_timeout: 1800,
|
|
34
|
-
legacy_bridge_until: nil, clock: Time, access_policy: nil, password_lifecycle: nil, remembered_profile: nil, mobile_profile: nil, on_sign_in: nil, verified_denial: nil
|
|
34
|
+
legacy_bridge_until: nil, clock: Time, access_policy: nil, password_lifecycle: nil, remembered_profile: nil, mobile_profile: nil, on_sign_in: nil, verified_denial: nil,
|
|
35
|
+
revoke_authority: nil)
|
|
35
36
|
unless [lifetime, idle_timeout].all? { |n| n.is_a?(Numeric) && n.finite? && n.positive? } && idle_timeout <= lifetime
|
|
36
37
|
raise ArgumentError, "session timeouts must be positive and idle_timeout <= lifetime"
|
|
37
38
|
end
|
|
@@ -45,6 +46,7 @@ module AddAuth
|
|
|
45
46
|
@password_lifecycle = password_lifecycle
|
|
46
47
|
@on_sign_in = on_sign_in
|
|
47
48
|
@verified_denial = verified_denial
|
|
49
|
+
@revoke_authority = revoke_authority
|
|
48
50
|
raise ArgumentError, "invalid mobile profile" unless mobile_profile.nil? || mobile_profile.is_a?(MobileProfile)
|
|
49
51
|
@mobile = mobile_profile
|
|
50
52
|
@lifetime, @idle, @deadline = lifetime, idle_timeout, legacy_bridge_until
|
|
@@ -98,6 +100,7 @@ module AddAuth
|
|
|
98
100
|
return if transport == :mobile && replacing
|
|
99
101
|
raise ArgumentError, "unsupported proof" unless %w[password email_link passkey external_identity].include?(method.to_s)
|
|
100
102
|
return if @access_policy && !@access_policy.sign_in_allowed?(user, method)
|
|
103
|
+
return if method.to_s == "password" && @password_lifecycle&.locked?(user)
|
|
101
104
|
if method.to_s == "passkey"
|
|
102
105
|
return unless proof.is_a?(StepUp::Evidence) && proof.strong? && proof.user_id == user.id &&
|
|
103
106
|
proof.verified_at <= @clock.now && proof.verified_at > @clock.now - 300
|
|
@@ -255,9 +258,13 @@ module AddAuth
|
|
|
255
258
|
end || Result.failure(reason: :elevation_required)
|
|
256
259
|
end
|
|
257
260
|
|
|
261
|
+
# Sign-out-everywhere retires every outstanding proof, not only sessions:
|
|
262
|
+
# the shared authority port also revokes tokens, ceremonies and handoffs.
|
|
258
263
|
def revoke_all(user:, session:, grant:)
|
|
259
264
|
with_authority(user: user, session: session, grant: grant, purpose: :sign_out_everywhere) do |account, _row, now|
|
|
260
|
-
@store.revoke_all_in_transaction(user_id: account.id, at: now)
|
|
265
|
+
count = @store.revoke_all_in_transaction(user_id: account.id, at: now)
|
|
266
|
+
@revoke_authority&.call(user_id: account.id, at: now)
|
|
267
|
+
count
|
|
261
268
|
end || false
|
|
262
269
|
end
|
|
263
270
|
|
|
@@ -44,7 +44,9 @@ module AddAuth
|
|
|
44
44
|
end
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
# ip selects the anonymous ceremony budget; one network cannot exhaust
|
|
48
|
+
# passkey sign-in for every other client.
|
|
49
|
+
def authentication_options(browser_secret:, user: nil, session: nil, purpose: nil, ip: nil)
|
|
48
50
|
return failure unless @binding.valid?(browser_secret)
|
|
49
51
|
if user || session || purpose
|
|
50
52
|
@store.with_user(id: user&.id) do |account|
|
|
@@ -54,7 +56,8 @@ module AddAuth
|
|
|
54
56
|
create_options(options, kind: "assertion", user: account, session: session, purpose: purpose, browser_secret: browser_secret)
|
|
55
57
|
end
|
|
56
58
|
else
|
|
57
|
-
|
|
59
|
+
budget = "passkey:anonymous-ceremonies:#{RateLimit.network(ip)}"
|
|
60
|
+
return failure(:rate_limited) unless @limiter.call(key: @digest.digest(budget), limit: @anonymous_limit)
|
|
58
61
|
options = @rp.options_for_authentication(user_verification: "required")
|
|
59
62
|
create_options(options, kind: "assertion", browser_secret: browser_secret)
|
|
60
63
|
end
|
|
@@ -90,7 +90,26 @@ module AddAuth
|
|
|
90
90
|
policy: account_policy, password_policy: config.lifecycle.password_policy, trusted_address: method(:trusted_recovery_address),
|
|
91
91
|
lifetime: config.lifecycle.proof_lifetime, notify: ->(**event) { security_events.issue(**event) if config.notifications.enabled },
|
|
92
92
|
sessions: sessions, step_up_policy: step_up_policy, profile_attributes: config.lifecycle.profile_attributes, deletion_allowed: config.lifecycle.deletion_allowed,
|
|
93
|
-
external_identities: -> { external_identities })
|
|
93
|
+
external_identities: -> { external_identities }, external_bindings: external_bindings)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Present whenever the binding table exists, even with providers disabled:
|
|
97
|
+
# a first confirmation must still decide what happens to old bindings.
|
|
98
|
+
def external_bindings
|
|
99
|
+
return unless defined?(::AddAuthExternalIdentity) && ::AddAuthExternalIdentity.table_exists?
|
|
100
|
+
Core::ExternalIdentities::Bindings.new(store: external_identity_store)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Display-only labels for provider configuration IDs held by Core. A
|
|
104
|
+
# native configuration borrows the label of a browser provider with the
|
|
105
|
+
# same issuer.
|
|
106
|
+
def provider_labels(ids)
|
|
107
|
+
options = config.external_identities
|
|
108
|
+
Array(ids).map { |id|
|
|
109
|
+
native = options.native_provider(id)
|
|
110
|
+
provider = options.provider(id) || (native && options.providers.find { |item| item.configuration.issuer == native.issuer })
|
|
111
|
+
provider&.label || "your sign-in provider"
|
|
112
|
+
}.uniq
|
|
94
113
|
end
|
|
95
114
|
|
|
96
115
|
def enqueue_account_request(identifier:, purpose:)
|
|
@@ -112,9 +131,9 @@ module AddAuth
|
|
|
112
131
|
idle_timeout: config.session.idle_timeout, legacy_bridge_until: config.session.legacy_bridge_until, access_policy: access_policy,
|
|
113
132
|
password_lifecycle: password_lifecycle,
|
|
114
133
|
mobile_profile: mobile_profile,
|
|
115
|
-
verified_denial: ->(user) { (config.eligible.call(user) == true) ? account_policy.
|
|
134
|
+
verified_denial: ->(user) { (config.eligible.call(user) == true) ? account_policy.password_denial(user) : :disabled },
|
|
116
135
|
remembered_profile: config.lifecycle.enabled ? {lifetime: config.lifecycle.remember_lifetime, idle_timeout: config.lifecycle.remember_idle_timeout} : nil,
|
|
117
|
-
on_sign_in: method(:record_sign_in))
|
|
136
|
+
on_sign_in: method(:record_sign_in), revoke_authority: authority.method(:revoke))
|
|
118
137
|
end
|
|
119
138
|
|
|
120
139
|
def mobile_profile
|
|
@@ -163,7 +182,8 @@ module AddAuth
|
|
|
163
182
|
Core::Passwords::Lifecycle.new(store: Stores::AccountCredentials.new(user_model: ::User, authority: authority),
|
|
164
183
|
policy: method(:eligible), enabled: config.lifecycle.enabled,
|
|
165
184
|
maximum_attempts: config.lifecycle.maximum_attempts, unlock_in: config.lifecycle.unlock_in,
|
|
166
|
-
issue_unlock: ->(user) { accounts.issue_unlock_in_transaction(user: user) }
|
|
185
|
+
issue_unlock: ->(user) { accounts.issue_unlock_in_transaction(user: user) },
|
|
186
|
+
password_allowed: ->(user) { access_policy.sign_in_allowed?(user, :password) })
|
|
167
187
|
end
|
|
168
188
|
|
|
169
189
|
def security_events
|
data/lib/add_auth/version.rb
CHANGED
|
@@ -79,7 +79,7 @@ AddAuth.configure do |config|
|
|
|
79
79
|
config.passkeys.origins = [] # Required: exact trusted origins, never request Host.
|
|
80
80
|
# config.passkeys.origins = ["https://app.example.com"]
|
|
81
81
|
config.passkeys.name = "Your account"
|
|
82
|
-
config.passkeys.anonymous_limit = 1000 #
|
|
82
|
+
config.passkeys.anonymous_limit = 1000 # Ceremony budget per IPv4 address or IPv6 /64 per five minutes.
|
|
83
83
|
config.trusted_recovery_address = ->(_user) {} # No trusted recovery by default.
|
|
84
84
|
# config.trusted_recovery_address = ->(user) { user.email_address if user.confirmed_at }
|
|
85
85
|
# Lifecycle additionally rejects unconfirmed addresses as trusted recovery.
|