standard_id 0.53.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 +42 -0
- data/README.md +33 -0
- data/app/controllers/concerns/standard_id/passwordless_flow.rb +65 -1
- data/app/controllers/standard_id/web/login_controller.rb +4 -13
- data/app/controllers/standard_id/web/login_verify_controller.rb +6 -15
- 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/mailers/standard_id/application_mailer.rb +10 -3
- data/app/models/standard_id/session.rb +3 -1
- data/lib/generators/standard_id/install/templates/standard_id_reference.rb +5 -0
- data/lib/standard_id/config/schema.rb +5 -0
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id.rb +15 -0
- 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,48 @@ 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
|
+
|
|
32
|
+
## [0.54.0] - 2026-10-22
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- **`PasswordlessFlow#start_passwordless_login(username:, connection:, return_to:)`.**
|
|
37
|
+
Sends the code and stores the signed hand-off the WebEngine's `login_verify` page
|
|
38
|
+
requires, so a host controller (a sign-up or invite form) can start a passwordless
|
|
39
|
+
login and redirect to `standard_id_web.login_verify_path` without copying the
|
|
40
|
+
gem's `message_verifier(:otp)` / `session[:standard_id_otp_payload]` internals.
|
|
41
|
+
Nothing is stored when the code cannot be sent. The gem's own login controllers
|
|
42
|
+
now use the same code. (rarebit-one/rarebit-sre#479)
|
|
43
|
+
- **`c.mailer_parent_class`** (default `"ActionMailer::Base"`). The class
|
|
44
|
+
StandardId's mailers inherit from; set it to `"ApplicationMailer"` so built-in OTP
|
|
45
|
+
and password-reset mail uses the host's layout, helpers, `default_url_options`
|
|
46
|
+
and locale handling. With a host parent the gem no longer sets its placeholder
|
|
47
|
+
`default from:` or `layout "mailer"`, so the parent's apply. A value that is
|
|
48
|
+
not an `ActionMailer::Base` subclass raises `StandardId::ConfigurationError`
|
|
49
|
+
naming the setting.
|
|
50
|
+
(rarebit-one/rarebit-sre#479)
|
|
51
|
+
|
|
10
52
|
## [0.53.0] - 2026-10-22
|
|
11
53
|
|
|
12
54
|
### Added
|
data/README.md
CHANGED
|
@@ -817,6 +817,14 @@ locale files, or the templates in `app/views/standard_id/passwordless_mailer/`.
|
|
|
817
817
|
An assigned `c.passwordless.mailer_subject` still sets the sign-in subject. The subscriber runs synchronously inside the request, so
|
|
818
818
|
`I18n.locale`, `Current.*` and the like are still available.
|
|
819
819
|
|
|
820
|
+
The built-in mailers inherit from `ActionMailer::Base`. Set
|
|
821
|
+
`c.mailer_parent_class = "ApplicationMailer"` to have them inherit your mailer
|
|
822
|
+
base instead, with its layout, helpers, `default_url_options` and locale
|
|
823
|
+
handling. It is read when the gem's mailers load, so set it in an initializer.
|
|
824
|
+
The sender still comes from `c.passwordless.mailer_from` and
|
|
825
|
+
`c.reset_password.mailer_from`, which every gem message passes explicitly, so a
|
|
826
|
+
parent's `default from:` does not apply to them.
|
|
827
|
+
|
|
820
828
|
Event payload includes:
|
|
821
829
|
- `channel` - `"email"` or `"sms"`
|
|
822
830
|
- `identifier` - The email address or phone number
|
|
@@ -828,6 +836,31 @@ Event payload includes:
|
|
|
828
836
|
|
|
829
837
|
> **Note**: `passwordless_email_sender` / `passwordless_sms_sender` were removed in 0.43; see the [Migration Guide](docs/MIGRATION_GUIDE.md).
|
|
830
838
|
|
|
839
|
+
### Starting a passwordless login from your own controller
|
|
840
|
+
|
|
841
|
+
To send the code from your own controller (a sign-up form, an invite page) and
|
|
842
|
+
let the WebEngine's verify page finish the sign-in, include
|
|
843
|
+
`StandardId::PasswordlessFlow` and call `start_passwordless_login`. It sends the
|
|
844
|
+
code and stores the signed hand-off the verify page requires, then you redirect
|
|
845
|
+
there through the engine's mounted helper, which includes the mount path:
|
|
846
|
+
|
|
847
|
+
```ruby
|
|
848
|
+
class Onboarding::SignupsController < ApplicationController
|
|
849
|
+
include StandardId::PasswordlessFlow
|
|
850
|
+
|
|
851
|
+
def create
|
|
852
|
+
start_passwordless_login(username: params[:email], return_to: welcome_path)
|
|
853
|
+
redirect_to standard_id_web.login_verify_path, status: :see_other
|
|
854
|
+
rescue StandardId::InvalidRequestError => e
|
|
855
|
+
redirect_to new_signup_path, alert: e.message
|
|
856
|
+
end
|
|
857
|
+
end
|
|
858
|
+
```
|
|
859
|
+
|
|
860
|
+
`connection:` defaults to `c.passwordless.connection`. `return_to:` becomes the
|
|
861
|
+
post-sign-in destination, still subject to the verify page's safe-redirect check.
|
|
862
|
+
Nothing is stored when the code cannot be sent.
|
|
863
|
+
|
|
831
864
|
### Using OTP for non-authentication flows
|
|
832
865
|
|
|
833
866
|
The same hardened OTP machinery (enumeration defense, atomic attempt tracking, pessimistic locking, bypass_code hook) is exposed as a public primitive via `StandardId::Otp`. Use it when you need one-time codes for purposes *other* than authentication — contact verification widgets, step-up challenges, custom confirmation flows, etc.
|
|
@@ -6,7 +6,8 @@ module StandardId
|
|
|
6
6
|
#
|
|
7
7
|
# Requires the including controller to have access to `request` (standard in
|
|
8
8
|
# all Rails controllers). No other dependencies are needed -- both
|
|
9
|
-
# `generate_passwordless_otp` and `verify_passwordless_otp` only use `request
|
|
9
|
+
# `generate_passwordless_otp` and `verify_passwordless_otp` only use `request`;
|
|
10
|
+
# `start_passwordless_login` also writes to `session`.
|
|
10
11
|
#
|
|
11
12
|
# @example Usage in a host app controller
|
|
12
13
|
# class Auth::LoginController < ApplicationController
|
|
@@ -29,13 +30,76 @@ module StandardId
|
|
|
29
30
|
# end
|
|
30
31
|
# end
|
|
31
32
|
# end
|
|
33
|
+
#
|
|
34
|
+
# @example Hand off to the WebEngine's verify page (no custom verify action)
|
|
35
|
+
# class Onboarding::SignupsController < ApplicationController
|
|
36
|
+
# include StandardId::PasswordlessFlow
|
|
37
|
+
#
|
|
38
|
+
# def create
|
|
39
|
+
# start_passwordless_login(username: params[:email], return_to: welcome_path)
|
|
40
|
+
# redirect_to standard_id_web.login_verify_path, status: :see_other
|
|
41
|
+
# rescue StandardId::InvalidRequestError => e
|
|
42
|
+
# redirect_to new_signup_path, alert: e.message
|
|
43
|
+
# end
|
|
44
|
+
# end
|
|
32
45
|
module PasswordlessFlow
|
|
33
46
|
extend ActiveSupport::Concern
|
|
34
47
|
|
|
35
48
|
include StandardId::PasswordlessStrategy
|
|
36
49
|
|
|
50
|
+
# Session key for the signed hand-off the WebEngine's login_verify page reads.
|
|
51
|
+
OTP_PAYLOAD_SESSION_KEY = :standard_id_otp_payload
|
|
52
|
+
|
|
37
53
|
private
|
|
38
54
|
|
|
55
|
+
# Start a passwordless login that the WebEngine's login_verify page finishes.
|
|
56
|
+
#
|
|
57
|
+
# Sends the code (as `generate_passwordless_otp`), then stores the signed
|
|
58
|
+
# hand-off that login_verify requires, valid for `passwordless.code_ttl`.
|
|
59
|
+
# The caller redirects to that page itself, through the engine's mounted
|
|
60
|
+
# helper so the mount path is included (e.g. `standard_id_web.login_verify_path`).
|
|
61
|
+
#
|
|
62
|
+
# Nothing is stored when sending fails, so the verify page is never reachable
|
|
63
|
+
# for a code that was not sent.
|
|
64
|
+
#
|
|
65
|
+
# @param username [String] the recipient's email address or phone number
|
|
66
|
+
# @param connection [String] the delivery channel, defaults to `passwordless.connection`
|
|
67
|
+
# @param return_to [String, nil] where to send the account after it verifies;
|
|
68
|
+
# the verify page still checks it is a safe destination. Non-String values are ignored.
|
|
69
|
+
# @return [StandardId::CodeChallenge] the created code challenge
|
|
70
|
+
# @raise [StandardId::InvalidRequestError] when the username format is invalid
|
|
71
|
+
# or the connection type is unsupported
|
|
72
|
+
def start_passwordless_login(username:, connection: StandardId.config.passwordless.connection, return_to: nil)
|
|
73
|
+
challenge = generate_passwordless_otp(username: username, connection: connection)
|
|
74
|
+
store_passwordless_handoff(username: username, connection: connection)
|
|
75
|
+
session[:return_to_after_authenticating] = return_to if return_to.is_a?(String) && return_to.present?
|
|
76
|
+
challenge
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def store_passwordless_handoff(username:, connection:)
|
|
80
|
+
session[OTP_PAYLOAD_SESSION_KEY] = Rails.application.message_verifier(:otp).generate(
|
|
81
|
+
{ username: username, connection: connection },
|
|
82
|
+
expires_in: StandardId.config.passwordless.code_ttl.seconds
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The stored hand-off as `{ username:, connection: }`, or nil when there is
|
|
87
|
+
# none. A tampered or expired hand-off is deleted and raises
|
|
88
|
+
# ActiveSupport::MessageVerifier::InvalidSignature.
|
|
89
|
+
def read_passwordless_handoff
|
|
90
|
+
signed_payload = session[OTP_PAYLOAD_SESSION_KEY]
|
|
91
|
+
return if signed_payload.blank?
|
|
92
|
+
|
|
93
|
+
Rails.application.message_verifier(:otp).verify(signed_payload).symbolize_keys
|
|
94
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
|
95
|
+
clear_passwordless_handoff
|
|
96
|
+
raise
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def clear_passwordless_handoff
|
|
100
|
+
session.delete(OTP_PAYLOAD_SESSION_KEY)
|
|
101
|
+
end
|
|
102
|
+
|
|
39
103
|
# Generate a passwordless OTP code and send it to the user.
|
|
40
104
|
#
|
|
41
105
|
# Delegates to the appropriate strategy (EmailStrategy or SmsStrategy)
|
|
@@ -100,25 +100,16 @@ module StandardId
|
|
|
100
100
|
end
|
|
101
101
|
|
|
102
102
|
begin
|
|
103
|
-
|
|
103
|
+
# string_param rejects Array/Hash-shaped redirect_uri values (see
|
|
104
|
+
# BaseController#string_param); start_passwordless_login also ignores
|
|
105
|
+
# non-Strings, since login_verify passes the stored value to redirect_to.
|
|
106
|
+
start_passwordless_login(username: username, connection: connection, return_to: string_param(:redirect_uri))
|
|
104
107
|
rescue StandardId::InvalidRequestError => e
|
|
105
108
|
flash.now[:alert] = e.message
|
|
106
109
|
render_with_inertia action: :show, props: auth_page_props(passwordless_enabled: passwordless_enabled?), status: :unprocessable_content
|
|
107
110
|
return
|
|
108
111
|
end
|
|
109
112
|
|
|
110
|
-
code_ttl = StandardId.config.passwordless.code_ttl
|
|
111
|
-
signed_payload = Rails.application.message_verifier(:otp).generate(
|
|
112
|
-
{ username: username, connection: connection },
|
|
113
|
-
expires_in: code_ttl.seconds
|
|
114
|
-
)
|
|
115
|
-
session[:standard_id_otp_payload] = signed_payload
|
|
116
|
-
# Use string_param to reject Array/Hash-shaped values — login_verify_controller
|
|
117
|
-
# consumes this via after_authentication_url and passes it to redirect_to;
|
|
118
|
-
# storing a non-String here would crash that flow with ArgumentError.
|
|
119
|
-
redirect_uri = string_param(:redirect_uri)
|
|
120
|
-
session[:return_to_after_authenticating] = redirect_uri if redirect_uri
|
|
121
|
-
|
|
122
113
|
redirect_to engine_path(login_verify_path), status: :see_other
|
|
123
114
|
end
|
|
124
115
|
|
|
@@ -75,7 +75,7 @@ module StandardId
|
|
|
75
75
|
}
|
|
76
76
|
redirect_override = invoke_after_sign_in(account, context)
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
clear_passwordless_handoff
|
|
79
79
|
|
|
80
80
|
# after_authentication_url returns whatever was stashed in
|
|
81
81
|
# session[:return_to_after_authenticating] — which could be an attacker-controlled
|
|
@@ -85,7 +85,7 @@ module StandardId
|
|
|
85
85
|
destination = redirect_override || (safe_destination?(fallback) ? fallback : safe_post_signin_default)
|
|
86
86
|
redirect_after_authentication destination, notice: "Successfully signed in"
|
|
87
87
|
rescue StandardId::AuthenticationDenied => e
|
|
88
|
-
|
|
88
|
+
clear_passwordless_handoff
|
|
89
89
|
handle_authentication_denied(e, account: account, newly_created: newly_created)
|
|
90
90
|
end
|
|
91
91
|
|
|
@@ -96,19 +96,10 @@ module StandardId
|
|
|
96
96
|
end
|
|
97
97
|
|
|
98
98
|
def require_otp_payload!
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
begin
|
|
107
|
-
@otp_data = Rails.application.message_verifier(:otp).verify(signed_payload).symbolize_keys
|
|
108
|
-
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
|
109
|
-
session.delete(:standard_id_otp_payload)
|
|
110
|
-
redirect_to engine_path(login_path), alert: "Your verification session has expired. Please try again."
|
|
111
|
-
end
|
|
99
|
+
@otp_data = read_passwordless_handoff
|
|
100
|
+
redirect_to engine_path(login_path), alert: "Please start the login process" if @otp_data.nil?
|
|
101
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
|
102
|
+
redirect_to engine_path(login_path), alert: "Your verification session has expired. Please try again."
|
|
112
103
|
end
|
|
113
104
|
|
|
114
105
|
# Global web.passwordless_registration AND the active scope's
|
|
@@ -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
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
module StandardId
|
|
2
|
-
|
|
2
|
+
# Inherits from `StandardId.config.mailer_parent_class` (ActionMailer::Base by
|
|
3
|
+
# default). The class is resolved when this file loads, which is after the
|
|
4
|
+
# host's initializers have run.
|
|
5
|
+
class ApplicationMailer < StandardId.mailer_parent_class
|
|
3
6
|
# Use a delivery job that doesn't log its arguments — the gem's mailers pass
|
|
4
7
|
# sensitive params (OTP code, password-reset token) that ActiveJob would
|
|
5
8
|
# otherwise print in plaintext. Applies to all StandardId mailers.
|
|
6
9
|
self.delivery_job = StandardId::SecureMailDeliveryJob
|
|
7
10
|
|
|
8
|
-
|
|
9
|
-
|
|
11
|
+
# A host parent brings its own defaults and layout; keep the gem's only on
|
|
12
|
+
# the bare ActionMailer::Base.
|
|
13
|
+
if superclass == ActionMailer::Base
|
|
14
|
+
default from: "from@example.com"
|
|
15
|
+
layout "mailer"
|
|
16
|
+
end
|
|
10
17
|
end
|
|
11
18
|
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.
|
|
@@ -108,6 +108,11 @@ StandardId.configure do |c|
|
|
|
108
108
|
# Default: nil (StandardId's packaged layout)
|
|
109
109
|
# c.web_layout = "application"
|
|
110
110
|
|
|
111
|
+
# The class StandardId's mailers inherit from. Set it to your own mailer base
|
|
112
|
+
# so OTP and password-reset mail picks up your layout, helpers and locale.
|
|
113
|
+
# Default: "ActionMailer::Base"
|
|
114
|
+
# c.mailer_parent_class = "ApplicationMailer"
|
|
115
|
+
|
|
111
116
|
# Expose current_account as current_user (useful for ActionPolicy / Pundit).
|
|
112
117
|
# Default: false
|
|
113
118
|
# c.alias_current_user = true
|
|
@@ -9,6 +9,11 @@ StandardId::ConfigSchema.define do
|
|
|
9
9
|
field :cache_store, type: :any, default: nil
|
|
10
10
|
field :logger, type: :any, default: nil
|
|
11
11
|
field :web_layout, type: :string, default: nil
|
|
12
|
+
# The class StandardId's mailers inherit from, e.g. "ApplicationMailer" so
|
|
13
|
+
# they pick up the host's layout, helpers, default_url_options and locale
|
|
14
|
+
# handling. Read once, when the gem's mailers load, so set it in an
|
|
15
|
+
# initializer.
|
|
16
|
+
field :mailer_parent_class, type: :string, default: "ActionMailer::Base"
|
|
12
17
|
# Removed in 0.43 (deprecated since 0.1.7): assigning either raises
|
|
13
18
|
# StandardId::ConfigurationError with this hint.
|
|
14
19
|
removed :passwordless_email_sender,
|
data/lib/standard_id/version.rb
CHANGED
data/lib/standard_id.rb
CHANGED
|
@@ -116,6 +116,21 @@ module StandardId
|
|
|
116
116
|
config.account_class_name.constantize
|
|
117
117
|
end
|
|
118
118
|
|
|
119
|
+
# The class StandardId::ApplicationMailer inherits from
|
|
120
|
+
# (`c.mailer_parent_class`); it must be an ActionMailer::Base subclass,
|
|
121
|
+
# anything else raises ConfigurationError. safe_constantize returns nil only when the
|
|
122
|
+
# name itself is undefined; an error raised while loading that class (a
|
|
123
|
+
# typo inside the host's mailer) still propagates as itself.
|
|
124
|
+
def mailer_parent_class
|
|
125
|
+
name = config.mailer_parent_class
|
|
126
|
+
parent = name.to_s.safe_constantize
|
|
127
|
+
return parent if parent.is_a?(Class) && parent <= ActionMailer::Base
|
|
128
|
+
|
|
129
|
+
raise ConfigurationError,
|
|
130
|
+
"c.mailer_parent_class = #{name.inspect} does not name an ActionMailer::Base subclass. " \
|
|
131
|
+
"Set it to your mailer base, e.g. \"ApplicationMailer\", or leave it unset for ActionMailer::Base."
|
|
132
|
+
end
|
|
133
|
+
|
|
119
134
|
def scope_for(name)
|
|
120
135
|
return nil if config.scopes.blank? || name.blank?
|
|
121
136
|
scope_hash = config.scopes[name.to_sym]
|