standard_id 0.54.0 → 0.54.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de8ced4a8e48a3b14437ece4082079510d1f7466737be90f3dffe3ac3959d56c
4
- data.tar.gz: af90eba1873dd457c207ce3151d75215cf5de2e8a5cb37d42ab32a1227fb5017
3
+ metadata.gz: '047082209f6ec1b345a5500438fb424ef343c2e72f922b063ed876124b0ad957'
4
+ data.tar.gz: 00f773c75baff568d12c3caa98f56b49c4630da093808cf7a8afb82e5a66807a
5
5
  SHA512:
6
- metadata.gz: d5bb7b2e920c26ae28e3b8ddd09ea4c475bb8b0562b89f057adf9573737fc225c43470faf94e202545d3840a4ab2b41e6abc8b17836fd8272a6b17711e47d5f6
7
- data.tar.gz: b64139dbd6641e978e75d4dec5f89a14891b603755edf6e67e2a566c5ace604d678aaeb4f75a6e924f1da2d04fdd5af3de8b0f005c05c344486aa2796d45b096
6
+ metadata.gz: 9a2c6bb133b7eca3d39545b666b0d82b6ef03f59374110618fee49643dceaf8c839871761e188a1aa4f1f3e5c0b3dfebc28a3a61d68f4cc12fa051337f33de8c
7
+ data.tar.gz: 6aebcee090d61b6094f25b724e3a89bb73e7424227f2bef24e4b17606e175a081673b6e6c296a31374ab22661f5d7b0f998d9e4585aa0ba0330d2d684537f3ce
data/CHANGELOG.md CHANGED
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.54.1] - 2026-10-22
11
+
12
+ ### Security
13
+
14
+ - **Web email/phone verification now counts attempts.** `Web::VerifyEmail::ConfirmController`
15
+ and `Web::VerifyPhone::ConfirmController` looked the code up with
16
+ `CodeChallenge.active.find_by(code:)`, so the only brake on guessing was the per-IP
17
+ rate limit. `PATCH` now goes through `StandardId::Otp.verify`, which counts failed
18
+ attempts and burns the challenge at `passwordless.max_attempts_per_challenge`.
19
+ `GET` no longer checks the code at all (it answered valid/invalid without counting an
20
+ attempt); a wrong code is reported on submit. Because `Otp.verify` honours
21
+ `passwordless.bypass_code`, that code now also verifies an email or phone through these
22
+ pages where it is allowed (never in production, which raises).
23
+ - **Web password reset revokes refresh tokens.** `Web::ResetPasswordConfirmForm` ran
24
+ `account.sessions.destroy_all`, and `Session has_many :refresh_tokens, dependent:
25
+ :nullify` left every refresh token active and detached. It now revokes all sessions
26
+ (`Session.revoke_all_for!`, reason `"password_reset"`, one `SESSION_REVOKED` each)
27
+ and every remaining refresh token for the account, and publishes the documented
28
+ `credential.password.reset_completed` event, which the gem never emitted before (a
29
+ raising subscriber is logged, not surfaced, since the reset has already happened).
30
+ Sessions are revoked, no longer deleted.
31
+
10
32
  ## [0.54.0] - 2026-10-22
11
33
 
12
34
  ### Added
@@ -3,46 +3,42 @@ module StandardId
3
3
  module VerifyEmail
4
4
  class ConfirmController < BaseController
5
5
  # Per-IP limit on code confirmation (20 per 15 minutes, shared with OTP
6
- # verify). The per-challenge attempt cap alone doesn't stop distributed
7
- # guessing across many challenges; both show and update probe a code.
6
+ # verify). Otp.verify's per-challenge attempt cap alone doesn't stop
7
+ # distributed guessing across many challenges.
8
8
  rate_limit to: StandardId.config.rate_limits.otp_verify_per_ip,
9
9
  within: 15.minutes,
10
10
  name: "verify-email-confirm-ip",
11
11
  only: [:show, :update],
12
12
  store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
13
13
 
14
- before_action :prepare_code_challenge
15
-
16
14
  def show
17
- return redirect_to(standard_id_web.login_path, alert: "Invalid or expired verification code") if @challenge.nil?
15
+ return redirect_to(standard_id_web.login_path, alert: "Invalid or expired verification code") if target.blank? || code.blank?
16
+ # Does not check the code: a GET that answered valid/invalid would be
17
+ # a guessing oracle that never counts an attempt. PATCH verifies.
18
18
  render plain: "verify email confirm", status: :ok
19
19
  end
20
20
 
21
21
  def update
22
- return redirect_to(standard_id_web.login_path, alert: "Invalid or expired verification code") if @challenge.nil?
22
+ # Otp.verify locks the challenge, compares in constant time, counts
23
+ # the failed attempt and burns the challenge at the per-challenge cap.
24
+ return redirect_to(standard_id_web.login_path, alert: "Invalid or expired verification code") if code.blank?
25
+
26
+ result = StandardId::Otp.verify(realm: "verification", target: target, code: code, channel: :email, request: request)
27
+ return redirect_to(standard_id_web.login_path, alert: result.error) unless result.success?
23
28
 
24
- identifier = StandardId::EmailIdentifier.find_by(value: @challenge.target)
25
- if identifier.present?
26
- identifier.verify!
27
- end
28
- @challenge.use!
29
+ StandardId::EmailIdentifier.find_by(value: target)&.verify!
29
30
 
30
31
  redirect_to standard_id_web.login_path, notice: "Your email has been verified. Please sign in.", status: :see_other
31
32
  end
32
33
 
33
34
  private
34
35
 
35
- def prepare_code_challenge
36
- email = params[:email].to_s.strip.downcase
37
- code = params[:code].to_s
38
- return @challenge = nil if email.blank? || code.blank?
39
-
40
- @challenge = StandardId::CodeChallenge.active.find_by(
41
- realm: "verification",
42
- channel: "email",
43
- target: email,
44
- code: code
45
- )
36
+ def target
37
+ @target ||= params[:email].to_s.strip.downcase
38
+ end
39
+
40
+ def code
41
+ params[:code].to_s
46
42
  end
47
43
  end
48
44
  end
@@ -3,46 +3,42 @@ module StandardId
3
3
  module VerifyPhone
4
4
  class ConfirmController < BaseController
5
5
  # Per-IP limit on code confirmation (20 per 15 minutes, shared with OTP
6
- # verify). The per-challenge attempt cap alone doesn't stop distributed
7
- # guessing across many challenges; both show and update probe a code.
6
+ # verify). Otp.verify's per-challenge attempt cap alone doesn't stop
7
+ # distributed guessing across many challenges.
8
8
  rate_limit to: StandardId.config.rate_limits.otp_verify_per_ip,
9
9
  within: 15.minutes,
10
10
  name: "verify-phone-confirm-ip",
11
11
  only: [:show, :update],
12
12
  store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
13
13
 
14
- before_action :prepare_code_challenge
15
-
16
14
  def show
17
- return redirect_to(standard_id_web.login_path, alert: "Invalid or expired verification code") if @challenge.nil?
15
+ return redirect_to(standard_id_web.login_path, alert: "Invalid or expired verification code") if target.blank? || code.blank?
16
+ # Does not check the code: a GET that answered valid/invalid would be
17
+ # a guessing oracle that never counts an attempt. PATCH verifies.
18
18
  render plain: "verify phone confirm", status: :ok
19
19
  end
20
20
 
21
21
  def update
22
- return redirect_to(standard_id_web.login_path, alert: "Invalid or expired verification code") if @challenge.nil?
22
+ # Otp.verify locks the challenge, compares in constant time, counts
23
+ # the failed attempt and burns the challenge at the per-challenge cap.
24
+ return redirect_to(standard_id_web.login_path, alert: "Invalid or expired verification code") if code.blank?
25
+
26
+ result = StandardId::Otp.verify(realm: "verification", target: target, code: code, channel: :sms, request: request)
27
+ return redirect_to(standard_id_web.login_path, alert: result.error) unless result.success?
23
28
 
24
- identifier = StandardId::PhoneNumberIdentifier.find_by(value: @challenge.target)
25
- if identifier.present?
26
- identifier.verify!
27
- end
28
- @challenge.use!
29
+ StandardId::PhoneNumberIdentifier.find_by(value: target)&.verify!
29
30
 
30
31
  redirect_to standard_id_web.login_path, notice: "Your phone number has been verified. Please sign in.", status: :see_other
31
32
  end
32
33
 
33
34
  private
34
35
 
35
- def prepare_code_challenge
36
- phone = params[:phone_number].to_s.strip
37
- code = params[:code].to_s
38
- return @challenge = nil if phone.blank? || code.blank?
39
-
40
- @challenge = StandardId::CodeChallenge.active.find_by(
41
- realm: "verification",
42
- channel: "sms",
43
- target: phone,
44
- code: code
45
- )
36
+ def target
37
+ @target ||= params[:phone_number].to_s.strip
38
+ end
39
+
40
+ def code
41
+ params[:code].to_s
46
42
  end
47
43
  end
48
44
  end
@@ -22,16 +22,57 @@ module StandardId
22
22
  def submit
23
23
  return false unless valid?
24
24
 
25
+ account = @password_credential.account
26
+
27
+ # The password change and the revocation commit together, so a failure
28
+ # cannot leave the new password in place with old tokens still live.
29
+ # The SESSION_REVOKED events revoke_all_for! publishes therefore run
30
+ # before the commit; each is rescued, so a subscriber cannot roll the
31
+ # reset back, but one that reads the rows must do so after commit
32
+ # (e.g. from a job, which Rails enqueues after commit).
25
33
  ActiveRecord::Base.transaction do
26
34
  @password_credential.update!(password: password, password_confirmation: password_confirmation)
27
- @password_credential.account.sessions.destroy_all
35
+ revoke_access!(account)
28
36
  end
29
37
 
38
+ publish_reset_completed(account)
39
+
30
40
  true
31
41
  rescue ActiveRecord::RecordInvalid => e
32
42
  errors.add(:base, e.record.errors.full_messages.join(", "))
33
43
  false
34
44
  end
45
+
46
+ private
47
+
48
+ # The reset has already happened, so a failing subscriber must not turn
49
+ # it into an error page; log and carry on, as Session does for
50
+ # SESSION_REVOKED.
51
+ def publish_reset_completed(account)
52
+ StandardId::Events.publish(
53
+ StandardId::Events::CREDENTIAL_PASSWORD_RESET_COMPLETED,
54
+ credential: @password_credential,
55
+ account: account
56
+ )
57
+ rescue StandardError => e
58
+ logger = StandardId.logger
59
+ return unless logger.respond_to?(:error)
60
+
61
+ logger.error(
62
+ "[StandardId::Web::ResetPasswordConfirmForm] Failed to publish " \
63
+ "CREDENTIAL_PASSWORD_RESET_COMPLETED for account #{account.id}: #{e.class}: #{e.message}"
64
+ )
65
+ end
66
+
67
+ # Revoke every session (and the refresh tokens under them), then any
68
+ # refresh token with no session (e.g. one minted by a grant flow). Revoking
69
+ # rather than destroying keeps the audit trail and fires SESSION_REVOKED;
70
+ # sessions.destroy_all used to nullify refresh_tokens.session_id
71
+ # (dependent: :nullify), leaving those tokens active after the reset.
72
+ def revoke_access!(account)
73
+ StandardId::Session.revoke_all_for!(account, reason: "password_reset")
74
+ StandardId::RefreshToken.where(account_id: account.id, revoked_at: nil).update_all(revoked_at: Time.current)
75
+ end
35
76
  end
36
77
  end
37
78
  end
@@ -176,7 +176,9 @@ module StandardId
176
176
  )
177
177
  end
178
178
 
179
- # DB state is already committed by the time this runs; event publishing is
179
+ # DB state is already committed by the time this runs (unless the caller
180
+ # wraps revoke_all_for! in its own transaction, as Web::ResetPasswordConfirmForm
181
+ # does); event publishing is
180
182
  # best-effort audit emission. A failing subscriber must not short-circuit the
181
183
  # loop and leave later sessions without their SESSION_REVOKED event, which
182
184
  # would permanently desync audit-trail consumers from the DB.
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.54.0"
2
+ VERSION = "0.54.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_id
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.54.0
4
+ version: 0.54.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim