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 +4 -4
- data/CHANGELOG.md +22 -0
- data/app/controllers/standard_id/web/verify_email/confirm_controller.rb +18 -22
- data/app/controllers/standard_id/web/verify_phone/confirm_controller.rb +18 -22
- data/app/forms/standard_id/web/reset_password_confirm_form.rb +42 -1
- data/app/models/standard_id/session.rb +3 -1
- data/lib/standard_id/version.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: '047082209f6ec1b345a5500438fb424ef343c2e72f922b063ed876124b0ad957'
|
|
4
|
+
data.tar.gz: 00f773c75baff568d12c3caa98f56b49c4630da093808cf7a8afb82e5a66807a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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).
|
|
7
|
-
# guessing across many challenges
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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).
|
|
7
|
-
# guessing across many challenges
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
data/lib/standard_id/version.rb
CHANGED