standard_id 0.15.0 → 0.16.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/README.md +10 -1
- data/app/controllers/concerns/standard_id/audience_verification.rb +23 -3
- data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +20 -5
- data/app/controllers/concerns/standard_id/set_current_request_details.rb +33 -0
- data/app/controllers/concerns/standard_id/social_authentication.rb +19 -0
- data/app/controllers/standard_id/api/oauth/revocations_controller.rb +55 -6
- data/app/controllers/standard_id/api/sessions_controller.rb +7 -3
- data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +6 -0
- data/app/controllers/standard_id/web/reset_password/start_controller.rb +27 -1
- data/app/controllers/standard_id/web/verify_email/start_controller.rb +1 -7
- data/app/controllers/standard_id/web/verify_phone/start_controller.rb +1 -7
- data/app/forms/standard_id/web/reset_password_start_form.rb +20 -19
- data/app/jobs/standard_id/password_reset_delivery_job.rb +42 -0
- data/app/mailers/standard_id/password_reset_mailer.rb +16 -0
- data/app/models/concerns/standard_id/account_associations.rb +43 -0
- data/app/models/standard_id/authorization_code.rb +15 -2
- data/app/models/standard_id/client_application.rb +78 -3
- data/app/models/standard_id/refresh_token.rb +23 -27
- data/app/models/standard_id/session.rb +8 -1
- data/app/views/standard_id/password_reset_mailer/reset_email.html.erb +27 -0
- data/app/views/standard_id/password_reset_mailer/reset_email.text.erb +12 -0
- data/db/migrate/20260416180511_add_partial_indexes_for_active_session_and_challenge_lookups.rb +117 -0
- data/lib/generators/standard_id/install/templates/standard_id.rb +15 -1
- data/lib/standard_id/account_locking.rb +23 -10
- data/lib/standard_id/account_status.rb +22 -8
- data/lib/standard_id/api/authentication_guard.rb +26 -18
- data/lib/standard_id/api/token_manager.rb +21 -1
- data/lib/standard_id/api_engine.rb +2 -0
- data/lib/standard_id/config/callable_validator.rb +163 -0
- data/lib/standard_id/config/schema.rb +61 -1
- data/lib/standard_id/config/scope_claims_validator.rb +47 -0
- data/lib/standard_id/engine.rb +60 -1
- data/lib/standard_id/errors.rb +3 -5
- data/lib/standard_id/events/definitions.rb +5 -2
- data/lib/standard_id/events/subscribers/password_reset_delivery_subscriber.rb +36 -0
- data/lib/standard_id/jwt_service.rb +43 -3
- data/lib/standard_id/oauth/authorization_code_authorization_flow.rb +10 -0
- data/lib/standard_id/oauth/authorization_flow.rb +8 -0
- data/lib/standard_id/otp.rb +1 -1
- data/lib/standard_id/passwordless/base_strategy.rb +9 -3
- data/lib/standard_id/passwordless/verification_service.rb +94 -71
- data/lib/standard_id/passwordless.rb +34 -2
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id/web_engine.rb +2 -0
- data/lib/standard_id.rb +1 -0
- data/lib/tasks/standard_id_tasks.rake +34 -8
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1cc415579c94e298230fc9d647869d7c2ea2b422b1130eac38bd05bba262956a
|
|
4
|
+
data.tar.gz: 653d1cd02170d5ba6933973635cb50d62729be158b0037812da8b2f832fb0c0e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 16fa94e1dde5fa46e92999ef2fb24b5bb683ff5f5ab3e6549e91f8c738e4cdd42d8e872ab0d13705659ec6ab2bb4c4e184f4947c1d95bf5e00afbc3b9dfe0328
|
|
7
|
+
data.tar.gz: b730ef767275f8d71aa9b93a8c8fca55a1935c84e1e4e6eaf022ec935d2d152d7b72e99576b5075e3dffba8269b07be66abf43f4fd96e1f7728858d64244f7e2
|
data/README.md
CHANGED
|
@@ -529,6 +529,7 @@ Every StandardId event automatically carries tracing metadata (`event_id`, `time
|
|
|
529
529
|
| | `social.account.created` | `account`, `provider`, `social_info` | When a social login creates a new account |
|
|
530
530
|
| | `social.account.linked` | `account`, `provider`, `identifier` | When a social identity links to an existing account |
|
|
531
531
|
| | `social.auth.completed` | `account`, `provider`, `tokens` | After social login completes |
|
|
532
|
+
| | `social.auth.failed` | `provider`, `error`, `error_class`, `account` | When social login fails due to an infrastructure error (HTTP/DNS/SSL/timeout) |
|
|
532
533
|
| Credential | `credential.password.created` | `credential`, `account` | After a password credential is created |
|
|
533
534
|
| | `credential.password.reset_initiated` | `credential`, `account`, `reset_token_expires_at` | After a password reset is initiated |
|
|
534
535
|
| | `credential.password.reset_completed` | `credential`, `account` | After a password reset is confirmed |
|
|
@@ -1109,9 +1110,17 @@ bundle exec rspec spec/controllers/
|
|
|
1109
1110
|
- CSRF protection enabled for web requests
|
|
1110
1111
|
- Secure session management with proper expiry
|
|
1111
1112
|
- Client secrets are rotatable with audit trail
|
|
1112
|
-
- PKCE
|
|
1113
|
+
- PKCE is enforced whenever `ClientApplication#require_pkce?` is true (the
|
|
1114
|
+
default). Public clients cannot disable it — a model-level validation
|
|
1115
|
+
rejects any public client saved with `require_pkce: false`. Authorize
|
|
1116
|
+
requests that omit `code_challenge` for a PKCE-required client are
|
|
1117
|
+
rejected with `invalid_request`.
|
|
1113
1118
|
- Rate limiting on authentication endpoints
|
|
1114
1119
|
|
|
1120
|
+
## Scheduled Maintenance
|
|
1121
|
+
|
|
1122
|
+
StandardId ships cleanup jobs (`StandardId::CleanupExpiredSessionsJob`, `StandardId::CleanupExpiredRefreshTokensJob`) and rake wrappers (`standard_id:cleanup:all`, `:sessions`, `:refresh_tokens`) to prune expired rows. See [docs/OPERATIONS.md](docs/OPERATIONS.md) for SolidQueue, sidekiq-cron, whenever, and system-cron scheduling examples.
|
|
1123
|
+
|
|
1115
1124
|
## Contributing
|
|
1116
1125
|
|
|
1117
1126
|
1. Fork the repository
|
|
@@ -3,9 +3,29 @@
|
|
|
3
3
|
module StandardId
|
|
4
4
|
# Per-controller audience verification for API endpoints.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
6
|
+
# StandardId enforces audience in three layers:
|
|
7
|
+
#
|
|
8
|
+
# 1. At encode time (`Oauth::TokenGrantFlow#validate_audience!`):
|
|
9
|
+
# rejects issuance of tokens with an audience outside the global
|
|
10
|
+
# `StandardId.config.oauth.allowed_audiences` list.
|
|
11
|
+
# 2. At decode time (`JwtService.decode(..., allowed_audiences:)`):
|
|
12
|
+
# rejects tokens whose `aud` claim does not match the caller-supplied
|
|
13
|
+
# list, raising `StandardId::InvalidAudienceError`. The engine's
|
|
14
|
+
# `Api::TokenManager#verify_jwt_token` wires this automatically when
|
|
15
|
+
# `StandardId.config.oauth.allowed_audiences` is non-empty — a
|
|
16
|
+
# mismatch there is normalised to the same 401 "invalid token"
|
|
17
|
+
# response as a bad signature. Call sites that pass no arguments
|
|
18
|
+
# to `decode` directly still skip aud checks by design.
|
|
19
|
+
# 3. At the controller, via this concern: layers on top as
|
|
20
|
+
# per-endpoint defense-in-depth. Required when a controller serves
|
|
21
|
+
# a strict subset of the global allowed audiences (e.g., the global
|
|
22
|
+
# list is `%w[web api admin]` but `AdminController` must only
|
|
23
|
+
# accept `admin`).
|
|
24
|
+
#
|
|
25
|
+
# With the global decode-time check now automatic, this concern is
|
|
26
|
+
# primarily useful for tightening the allowed audience per controller,
|
|
27
|
+
# not for plugging the "controller forgot to verify aud" gap (which
|
|
28
|
+
# is closed globally when `config.oauth.allowed_audiences` is set).
|
|
9
29
|
#
|
|
10
30
|
# In addition, when `StandardId.config.oauth.audience_profile_types` is set,
|
|
11
31
|
# this concern enforces the audience → profile-type binding: after the
|
|
@@ -144,12 +144,27 @@ module StandardId
|
|
|
144
144
|
end
|
|
145
145
|
|
|
146
146
|
# Determine if this is the account's first browser session.
|
|
147
|
-
#
|
|
148
|
-
#
|
|
149
|
-
#
|
|
147
|
+
# Uses `exists?` (which compiles to `SELECT 1 ... LIMIT 1`) instead of
|
|
148
|
+
# `count` — we only care whether *any other* active browser session is
|
|
149
|
+
# present, not the exact number. This short-circuits as soon as a row is
|
|
150
|
+
# found, so it's dramatically cheaper on accounts with many sessions.
|
|
151
|
+
#
|
|
152
|
+
# When called before session creation (before_sign_in), "first" means no
|
|
153
|
+
# active browser session exists at all.
|
|
154
|
+
# When called after session creation (after_sign_in), the just-created
|
|
155
|
+
# session counts as one, so "first" means no OTHER active browser session
|
|
156
|
+
# exists — i.e. exclude the current session before checking existence.
|
|
157
|
+
#
|
|
158
|
+
# Invariant: when `session_created: true`, `session_manager.current_session`
|
|
159
|
+
# is always set — invoke_after_sign_in runs immediately after
|
|
160
|
+
# session_manager.sign_in_account, which populates current_session. The
|
|
161
|
+
# nil guard below is defensive only; it would behave differently from the
|
|
162
|
+
# old `count <= 1` path (new: false, old: true) if that invariant were
|
|
163
|
+
# ever violated, but today no call site can reach it.
|
|
150
164
|
def first_sign_in?(account, session_created: true)
|
|
151
|
-
|
|
152
|
-
|
|
165
|
+
scope = account.sessions.where(type: "StandardId::BrowserSession").active
|
|
166
|
+
scope = scope.where.not(id: session_manager.current_session.id) if session_created && session_manager.current_session
|
|
167
|
+
!scope.exists?
|
|
153
168
|
end
|
|
154
169
|
|
|
155
170
|
# Handle AuthenticationDenied by revoking the session and redirecting to login.
|
|
@@ -4,6 +4,7 @@ module StandardId
|
|
|
4
4
|
|
|
5
5
|
included do
|
|
6
6
|
before_action :set_current_request_details
|
|
7
|
+
after_action :clear_rails_event_context
|
|
7
8
|
end
|
|
8
9
|
|
|
9
10
|
private
|
|
@@ -14,6 +15,38 @@ module StandardId
|
|
|
14
15
|
::Current.request_id = request.request_id if ::Current.respond_to?(:request_id=)
|
|
15
16
|
::Current.ip_address = StandardId::Utils::IpNormalizer.normalize(request.remote_ip) if ::Current.respond_to?(:ip_address=)
|
|
16
17
|
::Current.user_agent = request.user_agent if ::Current.respond_to?(:user_agent=)
|
|
18
|
+
|
|
19
|
+
set_rails_event_context
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Mirror request details into the Rails 8.1+ structured event reporter so
|
|
23
|
+
# that `Rails.event.notify` calls made during this request automatically
|
|
24
|
+
# carry request_id / ip_address / user_agent. Feature-detected: on older
|
|
25
|
+
# Rails versions this is a no-op. Reads straight from `::Current` — setters
|
|
26
|
+
# and getters on `ActiveSupport::CurrentAttributes` are paired, so the
|
|
27
|
+
# `respond_to?(:foo=)` checks above also guarantee the getter exists.
|
|
28
|
+
def set_rails_event_context
|
|
29
|
+
return unless defined?(::Current) && rails_event_available?
|
|
30
|
+
|
|
31
|
+
Rails.event.set_context(
|
|
32
|
+
request_id: (::Current.request_id if ::Current.respond_to?(:request_id)),
|
|
33
|
+
ip_address: (::Current.ip_address if ::Current.respond_to?(:ip_address)),
|
|
34
|
+
user_agent: (::Current.user_agent if ::Current.respond_to?(:user_agent))
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Rails 8.1 clears fiber-local state between requests via middleware, but
|
|
39
|
+
# thread-pooled servers (Puma, Falcon) can reuse the same fiber across
|
|
40
|
+
# requests. An explicit clear ensures a denied-upstream value cannot leak
|
|
41
|
+
# into the next request handled by the same worker.
|
|
42
|
+
def clear_rails_event_context
|
|
43
|
+
return unless rails_event_available?
|
|
44
|
+
|
|
45
|
+
Rails.event.clear_context
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def rails_event_available?
|
|
49
|
+
Rails.respond_to?(:event) && Rails.event.respond_to?(:set_context)
|
|
17
50
|
end
|
|
18
51
|
end
|
|
19
52
|
end
|
|
@@ -200,5 +200,24 @@ module StandardId
|
|
|
200
200
|
source: "social"
|
|
201
201
|
)
|
|
202
202
|
end
|
|
203
|
+
|
|
204
|
+
# Emit SOCIAL_AUTH_FAILED for infrastructure-level failures during the
|
|
205
|
+
# social authentication flow (HTTP errors, DNS/SSL/timeouts surfaced as
|
|
206
|
+
# OAuthError by provider implementations).
|
|
207
|
+
#
|
|
208
|
+
# Host apps can subscribe to this event to forward failures to Sentry or
|
|
209
|
+
# similar observability tools without monkey-patching the controller.
|
|
210
|
+
#
|
|
211
|
+
# @param error [StandardId::OAuthError] the captured failure
|
|
212
|
+
# @param account [Object, nil] the account if one was resolved before the failure
|
|
213
|
+
def emit_social_auth_failed(error, account: nil)
|
|
214
|
+
StandardId::Events.publish(
|
|
215
|
+
StandardId::Events::SOCIAL_AUTH_FAILED,
|
|
216
|
+
provider: provider&.provider_name,
|
|
217
|
+
error: error.message,
|
|
218
|
+
error_class: error.class.name,
|
|
219
|
+
account: account
|
|
220
|
+
)
|
|
221
|
+
end
|
|
203
222
|
end
|
|
204
223
|
end
|
|
@@ -29,15 +29,64 @@ module StandardId
|
|
|
29
29
|
# revocation via sub claim regardless of token type (RFC 7009 §2.1)
|
|
30
30
|
revoked_sessions = sessions.to_a
|
|
31
31
|
if revoked_sessions.any?
|
|
32
|
+
now = Time.current
|
|
33
|
+
session_ids = revoked_sessions.map(&:id)
|
|
34
|
+
|
|
35
|
+
# Bulk-revoke in two queries (one UPDATE per table) instead of
|
|
36
|
+
# issuing session.revoke! per row, which would be O(N) UPDATEs plus
|
|
37
|
+
# another O(N) cascades to refresh_tokens.
|
|
38
|
+
#
|
|
39
|
+
# Tradeoff: update_all skips ActiveRecord callbacks, so the per-row
|
|
40
|
+
# SESSION_REVOKED event emitted by Session#revoke! is not fired
|
|
41
|
+
# automatically. We re-emit it explicitly below so audit-trail
|
|
42
|
+
# subscribers (account status/locking, etc.) still see one event
|
|
43
|
+
# per revoked session — the semantics are preserved, only the SQL
|
|
44
|
+
# shape has changed.
|
|
32
45
|
ActiveRecord::Base.transaction do
|
|
33
|
-
|
|
46
|
+
StandardId::Session.where(id: session_ids).update_all(revoked_at: now)
|
|
47
|
+
StandardId::RefreshToken
|
|
48
|
+
.where(session_id: session_ids, revoked_at: nil)
|
|
49
|
+
.update_all(revoked_at: now)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# DB state is already committed above; event publishing is best-effort
|
|
53
|
+
# audit emission. A failing subscriber must not short-circuit the loop
|
|
54
|
+
# and leave later sessions without their SESSION_REVOKED event, which
|
|
55
|
+
# would permanently desync audit-trail consumers from the DB.
|
|
56
|
+
#
|
|
57
|
+
# All revoked_sessions share the same account_id (we filtered by it
|
|
58
|
+
# at line 25), so we load the account once rather than calling
|
|
59
|
+
# session.account per row, which would issue N extra SELECTs.
|
|
60
|
+
shared_account = revoked_sessions.first.account
|
|
61
|
+
revoked_sessions.each do |session|
|
|
62
|
+
session.revoked_at = now
|
|
63
|
+
begin
|
|
64
|
+
StandardId::Events.publish(
|
|
65
|
+
StandardId::Events::SESSION_REVOKED,
|
|
66
|
+
session: session,
|
|
67
|
+
account: shared_account,
|
|
68
|
+
reason: "token_revocation"
|
|
69
|
+
)
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
StandardId.logger.error(
|
|
72
|
+
"[StandardId::Revocations] Failed to publish SESSION_REVOKED " \
|
|
73
|
+
"for session #{session.id}: #{e.class}: #{e.message}"
|
|
74
|
+
)
|
|
75
|
+
end
|
|
34
76
|
end
|
|
35
77
|
|
|
36
|
-
|
|
37
|
-
StandardId::Events
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
78
|
+
begin
|
|
79
|
+
StandardId::Events.publish(
|
|
80
|
+
StandardId::Events::OAUTH_TOKEN_REVOKED,
|
|
81
|
+
account_id: account_id,
|
|
82
|
+
sessions_revoked: revoked_sessions.size
|
|
83
|
+
)
|
|
84
|
+
rescue StandardError => e
|
|
85
|
+
StandardId.logger.error(
|
|
86
|
+
"[StandardId::Revocations] Failed to publish OAUTH_TOKEN_REVOKED " \
|
|
87
|
+
"for account #{account_id}: #{e.class}: #{e.message}"
|
|
88
|
+
)
|
|
89
|
+
end
|
|
41
90
|
end
|
|
42
91
|
|
|
43
92
|
head :ok
|
|
@@ -26,15 +26,19 @@ module StandardId
|
|
|
26
26
|
|
|
27
27
|
private
|
|
28
28
|
|
|
29
|
+
# All Session subclasses live on the same STI table and therefore
|
|
30
|
+
# always respond to these columns — the prior `respond_to?` guards
|
|
31
|
+
# were defensive overhead that allocated per record. Direct access
|
|
32
|
+
# is both cheaper and clearer.
|
|
29
33
|
def serialize_session(session)
|
|
30
34
|
{
|
|
31
35
|
id: session.id,
|
|
32
36
|
type: session.type&.demodulize,
|
|
33
37
|
created_at: session.created_at.iso8601,
|
|
34
|
-
last_refreshed_at: session.
|
|
35
|
-
ip_address: session.
|
|
38
|
+
last_refreshed_at: session.last_refreshed_at&.iso8601,
|
|
39
|
+
ip_address: session.ip_address,
|
|
36
40
|
# user_agent is the API-facing name for the device_agent model attribute
|
|
37
|
-
user_agent: session.
|
|
41
|
+
user_agent: session.device_agent
|
|
38
42
|
}.compact
|
|
39
43
|
end
|
|
40
44
|
end
|
|
@@ -61,7 +61,13 @@ module StandardId
|
|
|
61
61
|
redirect_to destination, redirect_options
|
|
62
62
|
rescue StandardId::AuthenticationDenied => e
|
|
63
63
|
handle_authentication_denied(e, account: account, newly_created: newly_created)
|
|
64
|
+
rescue StandardId::SocialLinkError => e
|
|
65
|
+
# Policy/link error — SOCIAL_LINK_BLOCKED has already been emitted
|
|
66
|
+
# by validate_social_link!, so do not also emit SOCIAL_AUTH_FAILED
|
|
67
|
+
# (which is reserved for infrastructure-level failures).
|
|
68
|
+
redirect_to StandardId::WebEngine.routes.url_helpers.login_path(redirect_uri: state_data&.dig("redirect_uri")), alert: "Authentication failed: #{e.message}"
|
|
64
69
|
rescue StandardId::OAuthError => e
|
|
70
|
+
emit_social_auth_failed(e, account: account)
|
|
65
71
|
redirect_to StandardId::WebEngine.routes.url_helpers.login_path(redirect_uri: state_data&.dig("redirect_uri")), alert: "Authentication failed: #{e.message}"
|
|
66
72
|
end
|
|
67
73
|
end
|
|
@@ -14,7 +14,10 @@ module StandardId
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def create
|
|
17
|
-
form = StandardId::Web::ResetPasswordStartForm.new(
|
|
17
|
+
form = StandardId::Web::ResetPasswordStartForm.new(
|
|
18
|
+
email: params[:email],
|
|
19
|
+
reset_url_template: build_reset_url_template
|
|
20
|
+
)
|
|
18
21
|
|
|
19
22
|
if form.submit
|
|
20
23
|
flash[:notice] = "If an account with that email exists, we've sent password reset instructions."
|
|
@@ -24,6 +27,29 @@ module StandardId
|
|
|
24
27
|
render :show, status: :unprocessable_content
|
|
25
28
|
end
|
|
26
29
|
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# Build a password-reset URL template containing a literal "{token}"
|
|
34
|
+
# placeholder. The async delivery job substitutes the placeholder with
|
|
35
|
+
# the generated token once the account lookup completes. We build this
|
|
36
|
+
# here (rather than in the job) so the URL reflects the request's
|
|
37
|
+
# scheme/host/port — the job has no access to the HTTP request.
|
|
38
|
+
#
|
|
39
|
+
# The route helper may be absent if the host app mounts the engine
|
|
40
|
+
# without the `:password_reset` mechanism, or raise UrlGenerationError
|
|
41
|
+
# if required params are missing; fall back to a request-derived URL
|
|
42
|
+
# in those cases. Any other exception should surface normally.
|
|
43
|
+
def build_reset_url_template
|
|
44
|
+
base = begin
|
|
45
|
+
reset_password_confirm_url
|
|
46
|
+
rescue NameError, NoMethodError, ActionController::UrlGenerationError
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
base ||= "#{request.base_url}/reset_password/confirm"
|
|
50
|
+
separator = base.include?("?") ? "&" : "?"
|
|
51
|
+
"#{base}#{separator}token={token}"
|
|
52
|
+
end
|
|
27
53
|
end
|
|
28
54
|
end
|
|
29
55
|
end
|
|
@@ -32,7 +32,7 @@ module StandardId
|
|
|
32
32
|
realm: "verification",
|
|
33
33
|
channel: "email",
|
|
34
34
|
target: email,
|
|
35
|
-
code: generate_otp_code,
|
|
35
|
+
code: StandardId::Passwordless.generate_otp_code,
|
|
36
36
|
expires_at: 10.minutes.from_now,
|
|
37
37
|
ip_address: StandardId::Utils::IpNormalizer.normalize(request.remote_ip),
|
|
38
38
|
user_agent: request.user_agent
|
|
@@ -42,12 +42,6 @@ module StandardId
|
|
|
42
42
|
|
|
43
43
|
redirect_to standard_id_web.login_path, notice: "Verification code sent to your email", status: :see_other
|
|
44
44
|
end
|
|
45
|
-
|
|
46
|
-
private
|
|
47
|
-
|
|
48
|
-
def generate_otp_code
|
|
49
|
-
(SecureRandom.random_number(900_000) + 100_000).to_s
|
|
50
|
-
end
|
|
51
45
|
end
|
|
52
46
|
end
|
|
53
47
|
end
|
|
@@ -32,7 +32,7 @@ module StandardId
|
|
|
32
32
|
realm: "verification",
|
|
33
33
|
channel: "sms",
|
|
34
34
|
target: phone,
|
|
35
|
-
code: generate_otp_code,
|
|
35
|
+
code: StandardId::Passwordless.generate_otp_code,
|
|
36
36
|
expires_at: 10.minutes.from_now,
|
|
37
37
|
ip_address: StandardId::Utils::IpNormalizer.normalize(request.remote_ip),
|
|
38
38
|
user_agent: request.user_agent
|
|
@@ -42,12 +42,6 @@ module StandardId
|
|
|
42
42
|
|
|
43
43
|
redirect_to standard_id_web.login_path, notice: "Verification code sent via SMS", status: :see_other
|
|
44
44
|
end
|
|
45
|
-
|
|
46
|
-
private
|
|
47
|
-
|
|
48
|
-
def generate_otp_code
|
|
49
|
-
(SecureRandom.random_number(900_000) + 100_000).to_s
|
|
50
|
-
end
|
|
51
45
|
end
|
|
52
46
|
end
|
|
53
47
|
end
|
|
@@ -6,32 +6,33 @@ module StandardId
|
|
|
6
6
|
|
|
7
7
|
attribute :email, :string
|
|
8
8
|
|
|
9
|
-
attr_reader :password_credential, :token
|
|
10
|
-
|
|
11
9
|
validates :email, presence: { message: "Please enter your email address" }, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
12
10
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
11
|
+
# Constructor accepts the reset URL template so the form is decoupled
|
|
12
|
+
# from routing. The controller builds a URL from `reset_password_confirm_url`
|
|
13
|
+
# (or a request-derived fallback) and appends a literal `?token={token}` (or
|
|
14
|
+
# `&token={token}`) marker via string concatenation. The delivery job
|
|
15
|
+
# substitutes that placeholder with the actual token after account lookup.
|
|
16
|
+
def initialize(attributes = {})
|
|
17
|
+
@reset_url_template = attributes.delete(:reset_url_template) if attributes.is_a?(Hash)
|
|
18
|
+
super
|
|
21
19
|
end
|
|
22
20
|
|
|
23
|
-
|
|
24
|
-
@password_credential ||= identifier&.account&.credentials&.where(credentialable_type: "StandardId::PasswordCredential")&.sole&.credentialable
|
|
25
|
-
end
|
|
21
|
+
attr_reader :reset_url_template
|
|
26
22
|
|
|
27
|
-
def
|
|
28
|
-
|
|
29
|
-
end
|
|
23
|
+
def submit
|
|
24
|
+
return false unless valid?
|
|
30
25
|
|
|
31
|
-
|
|
26
|
+
# Enqueue the full lookup + token generation + mailer delivery pipeline
|
|
27
|
+
# so the controller response time does not depend on whether an account
|
|
28
|
+
# exists for the submitted email. This closes the user-enumeration
|
|
29
|
+
# timing side channel.
|
|
30
|
+
StandardId::PasswordResetDeliveryJob.perform_later(
|
|
31
|
+
email: email.to_s,
|
|
32
|
+
reset_url_template: reset_url_template.to_s
|
|
33
|
+
)
|
|
32
34
|
|
|
33
|
-
|
|
34
|
-
@identifier ||= StandardId::EmailIdentifier.find_by(value: email.to_s.strip.downcase)
|
|
35
|
+
true
|
|
35
36
|
end
|
|
36
37
|
end
|
|
37
38
|
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
# Handles the full password-reset email delivery pipeline asynchronously.
|
|
3
|
+
#
|
|
4
|
+
# Running this work in a job (rather than inline in the request) eliminates
|
|
5
|
+
# the timing side-channel that would otherwise leak whether an account
|
|
6
|
+
# exists for a given email: every request enqueues the same job, so the
|
|
7
|
+
# synchronous request path is constant-time regardless of account state.
|
|
8
|
+
class PasswordResetDeliveryJob < ApplicationJob
|
|
9
|
+
queue_as :default
|
|
10
|
+
|
|
11
|
+
# @param email [String] the raw email submitted by the user
|
|
12
|
+
# @param reset_url_template [String] URL with a literal "{token}" placeholder
|
|
13
|
+
# that the job will substitute with the generated reset token
|
|
14
|
+
def perform(email:, reset_url_template:)
|
|
15
|
+
normalized = email.to_s.strip.downcase
|
|
16
|
+
return if normalized.blank?
|
|
17
|
+
|
|
18
|
+
identifier = StandardId::EmailIdentifier.find_by(value: normalized)
|
|
19
|
+
return if identifier.nil?
|
|
20
|
+
|
|
21
|
+
password_credential = identifier.account
|
|
22
|
+
&.credentials
|
|
23
|
+
&.where(credentialable_type: "StandardId::PasswordCredential")
|
|
24
|
+
&.first
|
|
25
|
+
&.credentialable
|
|
26
|
+
return if password_credential.nil?
|
|
27
|
+
|
|
28
|
+
token = password_credential.generate_token_for(:password_reset)
|
|
29
|
+
return if token.blank?
|
|
30
|
+
|
|
31
|
+
reset_url = reset_url_template.to_s.sub("{token}", token)
|
|
32
|
+
|
|
33
|
+
StandardId::Events.publish(
|
|
34
|
+
StandardId::Events::CREDENTIAL_PASSWORD_RESET_INITIATED,
|
|
35
|
+
account: identifier.account,
|
|
36
|
+
identifier: normalized,
|
|
37
|
+
token: token,
|
|
38
|
+
reset_url: reset_url
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
class PasswordResetMailer < ApplicationMailer
|
|
3
|
+
layout false
|
|
4
|
+
|
|
5
|
+
def reset_email
|
|
6
|
+
@reset_url = params[:reset_url]
|
|
7
|
+
@email = params[:email]
|
|
8
|
+
|
|
9
|
+
mail(
|
|
10
|
+
to: @email,
|
|
11
|
+
from: StandardId.config.reset_password.mailer_from,
|
|
12
|
+
subject: StandardId.config.reset_password.mailer_subject
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -12,6 +12,31 @@ module StandardId
|
|
|
12
12
|
accepts_nested_attributes_for :identifiers
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
+
# Returns the account's StandardId::EmailIdentifier, if any.
|
|
16
|
+
#
|
|
17
|
+
# Uses the in-memory collection when identifiers is already loaded to
|
|
18
|
+
# avoid issuing an extra query (N+1 safety). Falls back to a scoped
|
|
19
|
+
# query otherwise.
|
|
20
|
+
#
|
|
21
|
+
# @return [StandardId::EmailIdentifier, nil]
|
|
22
|
+
def email_identifier
|
|
23
|
+
typed_identifier(StandardId::EmailIdentifier)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns the account's StandardId::PhoneNumberIdentifier, if any.
|
|
27
|
+
#
|
|
28
|
+
# @return [StandardId::PhoneNumberIdentifier, nil]
|
|
29
|
+
def phone_number_identifier
|
|
30
|
+
typed_identifier(StandardId::PhoneNumberIdentifier)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Returns the account's StandardId::UsernameIdentifier, if any.
|
|
34
|
+
#
|
|
35
|
+
# @return [StandardId::UsernameIdentifier, nil]
|
|
36
|
+
def username_identifier
|
|
37
|
+
typed_identifier(StandardId::UsernameIdentifier)
|
|
38
|
+
end
|
|
39
|
+
|
|
15
40
|
class_methods do
|
|
16
41
|
def find_or_create_by_verified_email!(email, **account_attributes)
|
|
17
42
|
raise ArgumentError, "email is required" if email.blank?
|
|
@@ -54,5 +79,23 @@ module StandardId
|
|
|
54
79
|
identifier.account
|
|
55
80
|
end
|
|
56
81
|
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# Fetch the first identifier of the given STI subclass.
|
|
86
|
+
#
|
|
87
|
+
# When the identifiers association is already loaded, filters in memory
|
|
88
|
+
# to avoid triggering an additional query. Otherwise issues a scoped
|
|
89
|
+
# query that returns at most one row.
|
|
90
|
+
#
|
|
91
|
+
# @param klass [Class] subclass of StandardId::Identifier
|
|
92
|
+
# @return [StandardId::Identifier, nil]
|
|
93
|
+
def typed_identifier(klass)
|
|
94
|
+
if association(:identifiers).loaded?
|
|
95
|
+
identifiers.detect { |i| i.is_a?(klass) }
|
|
96
|
+
else
|
|
97
|
+
identifiers.where(type: klass.sti_name).first
|
|
98
|
+
end
|
|
99
|
+
end
|
|
57
100
|
end
|
|
58
101
|
end
|
|
@@ -18,9 +18,22 @@ module StandardId
|
|
|
18
18
|
|
|
19
19
|
def self.issue!(plaintext_code:, client_id:, redirect_uri:, scope: nil, audience: nil, account: nil, code_challenge: nil, code_challenge_method: nil, nonce: nil, metadata: {})
|
|
20
20
|
# Fail fast: reject unsupported PKCE methods at issuance rather than
|
|
21
|
-
# storing a code that will always fail at redemption time.
|
|
21
|
+
# storing a code that will always fail at redemption time. When the
|
|
22
|
+
# client record has PKCE required, defer to the client's configured
|
|
23
|
+
# `code_challenge_methods`. In all other cases (client lookup missing,
|
|
24
|
+
# or client opted out of PKCE but still sent a challenge) fall back
|
|
25
|
+
# to an S256-only belt-and-suspenders check so we never silently
|
|
26
|
+
# accept a weaker method.
|
|
22
27
|
if code_challenge.present?
|
|
23
|
-
|
|
28
|
+
client = StandardId::ClientApplication.find_by(client_id: client_id)
|
|
29
|
+
method_supported =
|
|
30
|
+
if client&.require_pkce?
|
|
31
|
+
client.supports_pkce_method?(code_challenge_method)
|
|
32
|
+
else
|
|
33
|
+
code_challenge_method.to_s.downcase == "s256"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
unless method_supported
|
|
24
37
|
raise StandardId::InvalidRequestError, "Unsupported code_challenge_method: only S256 is allowed"
|
|
25
38
|
end
|
|
26
39
|
end
|