standard_id 0.54.0 → 0.55.0

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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -0
  3. data/README.md +123 -0
  4. data/app/controllers/standard_id/api/email_password_controller.rb +131 -0
  5. data/app/controllers/standard_id/web/verify_email/confirm_controller.rb +18 -22
  6. data/app/controllers/standard_id/web/verify_phone/confirm_controller.rb +18 -22
  7. data/app/forms/standard_id/web/reset_password_confirm_form.rb +41 -1
  8. data/app/jobs/standard_id/purge_unverified_email_signups_job.rb +141 -0
  9. data/app/mailers/standard_id/account_notice_mailer.rb +33 -0
  10. data/app/models/standard_id/identifier.rb +22 -2
  11. data/app/models/standard_id/session.rb +4 -2
  12. data/app/views/standard_id/account_notice_mailer/existing_account.html.erb +22 -0
  13. data/app/views/standard_id/account_notice_mailer/existing_account.text.erb +10 -0
  14. data/config/locales/en.yml +8 -0
  15. data/config/routes/api.rb +14 -0
  16. data/db/migrate/20261022000000_add_signup_source_to_standard_id_identifiers.rb +13 -0
  17. data/lib/standard_id/access_revocation.rb +40 -0
  18. data/lib/standard_id/config/callable_validator.rb +49 -0
  19. data/lib/standard_id/config/schema.rb +108 -0
  20. data/lib/standard_id/email_password/authentication.rb +71 -0
  21. data/lib/standard_id/email_password/daily_limit.rb +67 -0
  22. data/lib/standard_id/email_password/password_reset.rb +148 -0
  23. data/lib/standard_id/email_password/registration.rb +141 -0
  24. data/lib/standard_id/email_password/token_flow.rb +36 -0
  25. data/lib/standard_id/email_password/verification.rb +155 -0
  26. data/lib/standard_id/email_password.rb +209 -0
  27. data/lib/standard_id/errors.rb +39 -0
  28. data/lib/standard_id/login_method_policy.rb +2 -1
  29. data/lib/standard_id/version.rb +1 -1
  30. data/lib/standard_id.rb +8 -0
  31. metadata +15 -1
@@ -0,0 +1,141 @@
1
+ module StandardId
2
+ # Deletes email + password sign-ups (StandardId::EmailPassword) that were
3
+ # never verified. A no-op unless `c.email_password.enabled`; not part of
4
+ # CleanupAllJob, so schedule it yourself (daily is plenty).
5
+ #
6
+ # A sign-up that sits unverified is dead weight at best and, at worst, a
7
+ # stranger squatting someone else's address (see
8
+ # EmailPassword::Registration). After `c.email_password.unverified_purge_after`
9
+ # seconds (3 days by default, far beyond the code TTL and the daily code cap)
10
+ # the pending password credential and its email identifier are removed, and
11
+ # the account too when nothing else is left on it.
12
+ #
13
+ # Only an email identifier that is unverified, has no provider, carries a
14
+ # password credential, is older than the cutoff AND was created by
15
+ # EmailPassword::Registration (`signup_source`, EmailPassword.api_signup?)
16
+ # is considered. That last condition keeps the job away from other
17
+ # unverified password sign-ups, such as Web::SignupForm's.
18
+ # Even then the whole account is skipped, unchanged, at any sign of real use:
19
+ # * any verified identifier, or any with a provider (a social sign-in
20
+ # linked to it does not verify the identifier, so the provider is
21
+ # checked separately);
22
+ # * any session or refresh token;
23
+ # * `c.email_password.account_in_use` returning true (e.g. the host's
24
+ # profile rows);
25
+ # * any other row still referencing the account: the delete is guarded by
26
+ # restrict / foreign keys, and a guard failure rolls the unit back.
27
+ # A sign-up whose owner asked for a code within
28
+ # `c.email_password.purge_recent_code_window` seconds is kept, so someone
29
+ # who comes back on day 4 is not deleted minutes before typing the code in.
30
+ # So is one whose address asked for a password reset in the last 24 hours
31
+ # (EmailPassword::DailyLimit's counter; a reset leaves no row), so the
32
+ # link in flight still works. That check is only as good as the rate-limit
33
+ # store: with a null store it never fires.
34
+ # Each sign-up is re-checked under a row lock in its own transaction, so a
35
+ # verification that lands mid-run wins. The locks are taken in the order
36
+ # verify and reset take them (password credential, then identifier); a
37
+ # deadlock or lock wait timeout anyway leaves the sign-up for the next run.
38
+ class PurgeUnverifiedEmailSignupsJob < ApplicationJob
39
+ queue_as :default
40
+
41
+ # @param older_than_seconds [Integer, nil] defaults to
42
+ # c.email_password.unverified_purge_after
43
+ # @return [Hash] `{ purged_count:, skipped_count: }`
44
+ def perform(older_than_seconds: nil)
45
+ return { purged_count: 0, skipped_count: 0 } unless StandardId::EmailPassword.enabled?
46
+
47
+ cutoff = (older_than_seconds || config.unverified_purge_after).to_i.seconds.ago
48
+ purged = 0
49
+ skipped = 0
50
+
51
+ candidates(cutoff).find_each do |identifier|
52
+ if purge(identifier, cutoff)
53
+ purged += 1
54
+ else
55
+ skipped += 1
56
+ end
57
+ end
58
+
59
+ Rails.logger.info("[StandardId] Purged #{purged} unverified email sign-ups older than #{cutoff} (skipped #{skipped})")
60
+ { purged_count: purged, skipped_count: skipped }
61
+ end
62
+
63
+ private
64
+
65
+ def config
66
+ StandardId::EmailPassword.config
67
+ end
68
+
69
+ def candidates(cutoff)
70
+ StandardId::EmailIdentifier
71
+ .where(verified_at: nil, provider: nil, signup_source: StandardId::EmailPassword::SIGNUP_SOURCE)
72
+ .where(created_at: ...cutoff)
73
+ .where(id: StandardId::Credential.where(credentialable_type: StandardId::PasswordCredential.name).select(:identifier_id))
74
+ end
75
+
76
+ # true when the sign-up was removed, false when it was left alone.
77
+ def purge(identifier, cutoff)
78
+ ActiveRecord::Base.transaction do
79
+ lock_password_credentials(identifier)
80
+ identifier.lock!
81
+ return false unless identifier.verified_at.nil? && identifier.provider.nil? && identifier.created_at < cutoff &&
82
+ StandardId::EmailPassword.api_signup?(identifier)
83
+
84
+ account = StandardId.account_class.find_by(id: identifier.account_id)
85
+ return false if account.nil? || account_in_use?(account)
86
+ return false if recent_code_requested?(identifier.value) || reset_requested?(identifier.value)
87
+
88
+ credentials = StandardId::Credential.where(identifier_id: identifier.id).to_a
89
+ return false unless credentials.all? { |c| c.credentialable_type == StandardId::PasswordCredential.name }
90
+
91
+ password_credentials = StandardId::PasswordCredential.where(id: credentials.map(&:credentialable_id)).to_a
92
+ credentials.each(&:destroy!)
93
+ password_credentials.each(&:destroy!)
94
+ identifier.destroy!
95
+
96
+ unless StandardId::Identifier.exists?(account_id: account.id)
97
+ # The dependent-destroy cascade lazy-loads associations, which a host
98
+ # running strict_loading_by_default would refuse.
99
+ account.strict_loading!(false)
100
+ account.destroy!
101
+ end
102
+ true
103
+ end
104
+ rescue ActiveRecord::DeleteRestrictionError, ActiveRecord::InvalidForeignKey, ActiveRecord::RecordNotDestroyed,
105
+ ActiveRecord::Deadlocked, ActiveRecord::LockWaitTimeout => e
106
+ Rails.logger.warn("[StandardId] Left unverified email sign-up #{identifier.id} alone: #{e.class}")
107
+ false
108
+ end
109
+
110
+ def lock_password_credentials(identifier)
111
+ StandardId::PasswordCredential
112
+ .where(id: StandardId::Credential
113
+ .where(identifier_id: identifier.id, credentialable_type: StandardId::PasswordCredential.name)
114
+ .select(:credentialable_id))
115
+ .order(:id)
116
+ .lock
117
+ .to_a
118
+ end
119
+
120
+ def account_in_use?(account)
121
+ return true if StandardId::Identifier.where(account_id: account.id).where("verified_at IS NOT NULL OR provider IS NOT NULL").exists?
122
+ return true if StandardId::Session.where(account_id: account.id).exists?
123
+ return true if StandardId::RefreshToken.where(account_id: account.id).exists?
124
+
125
+ hook = config.account_in_use
126
+ # Any truthy answer keeps the account: when in doubt, keep.
127
+ hook.respond_to?(:call) && StandardId::EmailPassword.call_hook(hook, account: account) ? true : false
128
+ end
129
+
130
+ def reset_requested?(email)
131
+ StandardId::EmailPassword::DailyLimit.count(:reset_requested, email).positive?
132
+ end
133
+
134
+ def recent_code_requested?(email)
135
+ StandardId::CodeChallenge
136
+ .where(realm: config.verification_realm, target: StandardId::EmailPassword.normalize_email(email))
137
+ .where(created_at: config.purge_recent_code_window.to_i.seconds.ago..)
138
+ .exists?
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,33 @@
1
+ module StandardId
2
+ # Notices about an account, sent to its address rather than to whoever made
3
+ # the request.
4
+ #
5
+ # * #existing_account: sign-up was attempted for an address that already has
6
+ # an account, while `c.email_password.reveal_existing_accounts` is false
7
+ # (the API then answers as if the sign-up went ahead). The owner learns
8
+ # about it and how to get in; the requester learns nothing.
9
+ #
10
+ # Copy lives under `standard_id.account_notice_mailer.<action>.*` in
11
+ # config/locales/en.yml; override any key in the host's locale files, or the
12
+ # templates in app/views/standard_id/account_notice_mailer/.
13
+ class AccountNoticeMailer < ApplicationMailer
14
+ layout false
15
+
16
+ helper_method :notice_copy
17
+
18
+ # @param email [String]
19
+ def existing_account
20
+ @email = params[:email]
21
+
22
+ mail(to: @email, from: StandardId.config.passwordless.mailer_from, subject: notice_copy(:subject))
23
+ end
24
+
25
+ # Copy for the current action, falling back to the gem's English (see
26
+ # PasswordlessMailer#otp_copy).
27
+ def notice_copy(key, default: nil, **options)
28
+ scope = "standard_id.account_notice_mailer.#{action_name}"
29
+ english = I18n.t(key, scope: scope, locale: :en, default: default, **options)
30
+ I18n.t(key, scope: scope, default: english, **options)
31
+ end
32
+ end
33
+ end
@@ -20,9 +20,17 @@ module StandardId
20
20
  verified_at.present?
21
21
  end
22
22
 
23
- def verify!
23
+ # +defer_event+ holds IDENTIFIER_VERIFICATION_SUCCEEDED until the
24
+ # enclosing transaction commits, and drops it if that rolls back. The
25
+ # verification has committed by the time it fires, so a failing
26
+ # subscriber is logged rather than raised.
27
+ def verify!(defer_event: false)
24
28
  update!(verified_at: Time.current)
25
- emit_verification_succeeded
29
+ if defer_event
30
+ ActiveRecord.after_all_transactions_commit { emit_verification_succeeded_after_commit }
31
+ else
32
+ emit_verification_succeeded
33
+ end
26
34
  end
27
35
 
28
36
  def unverify!
@@ -63,6 +71,18 @@ module StandardId
63
71
  )
64
72
  end
65
73
 
74
+ def emit_verification_succeeded_after_commit
75
+ emit_verification_succeeded
76
+ rescue StandardError => e
77
+ logger = StandardId.logger
78
+ return unless logger.respond_to?(:error)
79
+
80
+ logger.error(
81
+ "[StandardId::Identifier] Failed to publish IDENTIFIER_VERIFICATION_SUCCEEDED " \
82
+ "for identifier #{id}: #{e.class}: #{e.message}"
83
+ )
84
+ end
85
+
66
86
  def emit_account_verified
67
87
  StandardId::Events.publish(
68
88
  StandardId::Events::ACCOUNT_VERIFIED,
@@ -176,8 +176,10 @@ module StandardId
176
176
  )
177
177
  end
178
178
 
179
- # DB state is already committed by the time this runs; event publishing is
180
- # best-effort audit emission. A failing subscriber must not short-circuit the
179
+ # DB state is already committed by the time this runs, unless the caller
180
+ # wraps revoke_all_for! in its own transaction, as StandardId::AccessRevocation
181
+ # does for the password-reset paths (Web::ResetPasswordConfirmForm and
182
+ # EmailPassword::PasswordReset). Event publishing is 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.
183
185
  def self.publish_session_revocations(sessions, account:, reason:, now:)
@@ -0,0 +1,22 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <style>
6
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background-color: #f5f5f5; margin: 0; padding: 0; }
7
+ .container { max-width: 480px; margin: 40px auto; background-color: #ffffff; border-radius: 8px; padding: 40px; }
8
+ .footer { margin-top: 32px; font-size: 13px; color: #888888; text-align: center; }
9
+ </style>
10
+ </head>
11
+ <body>
12
+ <div class="container">
13
+ <p><%= notice_copy(:greeting) %></p>
14
+ <p><%= notice_copy(:intro) %></p>
15
+ <p><%= notice_copy(:action) %></p>
16
+ <p><%= notice_copy(:ignore) %></p>
17
+ <div class="footer">
18
+ <p><%= notice_copy(:footer) %></p>
19
+ </div>
20
+ </div>
21
+ </body>
22
+ </html>
@@ -0,0 +1,10 @@
1
+ <%= notice_copy(:greeting) %>
2
+
3
+ <%= notice_copy(:intro) %>
4
+
5
+ <%= notice_copy(:action) %>
6
+
7
+ <%= notice_copy(:ignore) %>
8
+
9
+ --
10
+ <%= notice_copy(:footer) %>
@@ -20,3 +20,11 @@ en:
20
20
  other: "This code will expire in %{count} minutes."
21
21
  ignore: "If you did not request this code, you can safely ignore this email."
22
22
  footer: "This is an automated message. Please do not reply."
23
+ account_notice_mailer:
24
+ existing_account:
25
+ subject: "Someone tried to sign up with your email"
26
+ greeting: "Hi,"
27
+ intro: "Someone just tried to create an account with this email address, but you already have one."
28
+ action: "If that was you, sign in the way you did before, or reset your password if you have forgotten it."
29
+ ignore: "If it wasn't you, you can safely ignore this email. Your account has not changed."
30
+ footer: "This is an automated message. Please do not reply."
data/config/routes/api.rb CHANGED
@@ -10,6 +10,20 @@ StandardId::ApiEngine.routes.draw do
10
10
  post :start
11
11
  end
12
12
 
13
+ # Email + password for JSON clients (StandardId::EmailPassword). Drawn
14
+ # always but matched only while c.email_password.enabled is true, so the
15
+ # endpoints 404 otherwise.
16
+ constraints(->(_request) { StandardId::EmailPassword.enabled? }) do
17
+ scope "auth", controller: :email_password, as: :email_password do
18
+ post "register", action: :register, as: :register
19
+ post "login", action: :login, as: :login
20
+ post "verify-email", action: :verify, as: :verify
21
+ post "verify-email/resend", action: :resend, as: :resend
22
+ post "password/forgot", action: :forgot_password, as: :forgot_password
23
+ post "password/reset", action: :reset_password, as: :reset_password
24
+ end
25
+ end
26
+
13
27
  namespace :oidc do
14
28
  resource :logout, only: [:show], controller: :logout
15
29
  end
@@ -0,0 +1,13 @@
1
+ # Records which flow created an identifier's sign-up. EmailPassword::Registration
2
+ # writes "email_password"; nothing else writes it, so NULL means any other
3
+ # sign-up (Web::SignupForm, social, passwordless, the host's own). The email +
4
+ # password API reads it to tell its own pending sign-ups apart: a password
5
+ # reset runs `after_email_verified` only for them, and
6
+ # PurgeUnverifiedEmailSignupsJob only ever deletes them. One nullable string
7
+ # column, no default, no index: metadata-only on PostgreSQL. Required before
8
+ # enabling `c.email_password`.
9
+ class AddSignupSourceToStandardIdIdentifiers < ActiveRecord::Migration[8.0]
10
+ def change
11
+ add_column :standard_id_identifiers, :signup_source, :string
12
+ end
13
+ end
@@ -0,0 +1,40 @@
1
+ module StandardId
2
+ # Ends everything an account is signed in with: every session (cascading to
3
+ # the refresh tokens under it), then every refresh token with no session, such
4
+ # as one minted by a grant flow. Sessions alone would leave those alive.
5
+ # Revoking rather than destroying keeps the audit trail and fires one
6
+ # SESSION_REVOKED per session.
7
+ #
8
+ # Runs in one transaction, so a caller that wraps it in its own (a password
9
+ # change, say) commits the change and the revocation together. The
10
+ # SESSION_REVOKED events then publish before that commit; each is rescued
11
+ # (see Session.publish_session_revocations), so a subscriber cannot roll the
12
+ # change back, but one that reads the rows must do so after commit.
13
+ #
14
+ # Access tokens already issued are stateless JWTs and stay valid until they
15
+ # expire.
16
+ module AccessRevocation
17
+ module_function
18
+
19
+ # @param account [ActiveRecord::Base]
20
+ # @param reason [String] carried on each SESSION_REVOKED event
21
+ # @param issued_before [Time, nil] only revoke sessions and refresh tokens
22
+ # CREATED before this. Activity (updated_at) never spares one.
23
+ def revoke_all!(account, reason:, issued_before: nil)
24
+ ActiveRecord::Base.transaction do
25
+ if issued_before
26
+ sessions = StandardId::Session
27
+ .where(account_id: account.id, revoked_at: nil)
28
+ .where(created_at: ...issued_before)
29
+ StandardId::Session.revoke_sessions!(sessions, account: account, reason: reason)
30
+ else
31
+ StandardId::Session.revoke_all_for!(account, reason: reason)
32
+ end
33
+
34
+ refresh_tokens = StandardId::RefreshToken.where(account_id: account.id, revoked_at: nil)
35
+ refresh_tokens = refresh_tokens.where(created_at: ...issued_before) if issued_before
36
+ refresh_tokens.update_all(revoked_at: Time.current)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -70,6 +70,41 @@ module StandardId
70
70
  signature: "(audience:, client:, configured_scopes:)",
71
71
  kind: :keyword,
72
72
  keywords: %i[audience client configured_scopes]
73
+ },
74
+ "email_password.reset_url" => {
75
+ signature: "(request:)",
76
+ kind: :keyword,
77
+ keywords: %i[request]
78
+ },
79
+ "email_password.client_key" => {
80
+ signature: "(request:)",
81
+ kind: :keyword,
82
+ keywords: %i[request]
83
+ },
84
+ "email_password.account_creator" => {
85
+ signature: "(email:, name:, request:)",
86
+ kind: :keyword,
87
+ keywords: %i[email name request]
88
+ },
89
+ "email_password.password_validator" => {
90
+ signature: "(password:, email:)",
91
+ kind: :keyword,
92
+ keywords: %i[password email]
93
+ },
94
+ "email_password.after_email_verified" => {
95
+ signature: "(account:, name:, request:)",
96
+ kind: :keyword,
97
+ keywords: %i[account name request]
98
+ },
99
+ "email_password.account_in_use" => {
100
+ signature: "(account:)",
101
+ kind: :keyword,
102
+ keywords: %i[account]
103
+ },
104
+ "email_password.error_renderer" => {
105
+ signature: "(error:, request:)",
106
+ kind: :keyword,
107
+ keywords: %i[error request]
73
108
  }
74
109
  }.freeze
75
110
 
@@ -154,6 +189,7 @@ module StandardId
154
189
  def validate_keyword!(path, callable, allowed_keywords, signature)
155
190
  params = extract_parameters(callable)
156
191
  return if params.nil?
192
+ validate_no_required_positional!(path, callable, params, signature)
157
193
  # If the callable takes a double-splat, it accepts anything — fine.
158
194
  return if params.any? { |type, _| type == :keyrest }
159
195
 
@@ -167,6 +203,19 @@ module StandardId
167
203
  "(allowed keywords: #{allowed_keywords.inspect})."
168
204
  end
169
205
 
206
+ # EmailPassword.call_hook passes only the keywords a hook declares, so a
207
+ # lambda with a required positional argument (`->(request) { }`) would
208
+ # raise on its first call. Limited to the email_password hooks, which
209
+ # are new: older keyword hooks keep accepting what they always did.
210
+ def validate_no_required_positional!(path, callable, params, signature)
211
+ return unless path.start_with?("email_password.") && lambda_callable?(callable)
212
+ return unless params.any? { |type, _| type == :req }
213
+
214
+ raise StandardId::ConfigurationError,
215
+ "StandardId config: `#{path}` takes a positional argument, but it is called " \
216
+ "with keyword arguments. Expected signature #{signature}."
217
+ end
218
+
170
219
  def validate_claim_resolvers!(config)
171
220
  resolvers = config.oauth.claim_resolvers
172
221
  return if resolvers.nil? || resolvers.empty?
@@ -277,6 +277,93 @@ StandardId::ConfigSchema.define do
277
277
  field :mailer_subject, type: :string, default: "Reset your password"
278
278
  end
279
279
 
280
+ # Email + password sign-up and sign-in over JSON (Api::EmailPasswordController,
281
+ # StandardId::EmailPassword), for native and SPA clients that cannot use the
282
+ # WebEngine's cookie forms. Off by default: until `enabled` is true the
283
+ # routes are not drawn at all (a routing constraint, so a host's own routes
284
+ # behind the mount point still match). See README "Email + password API".
285
+ #
286
+ # Sign-up creates an UNVERIFIED account and emails a 6-digit code; tokens are
287
+ # issued only when the code is confirmed, together with the sign-up password.
288
+ scope :email_password do
289
+ field :enabled, type: :boolean, default: false
290
+
291
+ # Stamped on the tokens this flow mints. It plays the role a provider name
292
+ # plays for social tokens: the client presents it on the refresh_token
293
+ # grant, which must match the token's client_id claim.
294
+ field :token_client_id, type: :string, default: "email"
295
+ field :token_scope, type: :string, default: "profile"
296
+ field :token_audience, type: :string, default: nil
297
+
298
+ # OTP realm for the sign-up code. Deliberately not "authentication": that
299
+ # realm resolves (and can create) an account on verify.
300
+ field :verification_realm, type: :string, default: "email_verification"
301
+
302
+ # What sign-up answers for an address that already has a verified password
303
+ # account, or belongs to a social-only account:
304
+ # true — (default) 409 :account_exists / :social_account
305
+ # false — the same 202 as a new address, and the owner is emailed
306
+ # (AccountNoticeMailer#existing_account), so sign-up cannot be
307
+ # used to learn which addresses are registered.
308
+ field :reveal_existing_accounts, type: :boolean, default: true
309
+
310
+ field :require_name, type: :boolean, default: false
311
+ field :name_max_length, type: :integer, default: 100
312
+
313
+ # Who emails the sign-up code:
314
+ # :built_in — the engine, with PasswordlessMailer#verification_email
315
+ # :custom — a host PASSWORDLESS_CODE_GENERATED subscriber (the event
316
+ # carries delivery: :custom and realm verification_realm)
317
+ # Independent of passwordless.delivery, so turning this on does not start
318
+ # delivering codes for the passwordless-login endpoint.
319
+ field :code_delivery, type: :symbol, default: :built_in
320
+
321
+ # Link emailed by forgot-password. A String containing "{token}", or a
322
+ # callable `(request:)` returning one. Required once enabled: the reset
323
+ # page lives on the host, not the API engine. Delivery follows
324
+ # reset_password.delivery.
325
+ field :reset_url, type: :any, default: nil
326
+
327
+ # Callable `(request:)` returning the key the per-IP limits and the daily
328
+ # per-network reset cap count under. Default: request.remote_ip. Return a
329
+ # masked network (an IPv4 /24, IPv6 /64) to stop address rotation inside
330
+ # one allocation from resetting the count.
331
+ field :client_key, type: :any, default: nil
332
+
333
+ # Callable `(email:, name:, request:)` returning a new, persisted account.
334
+ # Default: account_class.create! with email and name (the local part when
335
+ # name is blank), each only when the account has that attribute.
336
+ field :account_creator, type: :any, default: nil
337
+
338
+ # Callable `(password:, email:)` returning an Array of messages (empty when
339
+ # acceptable), run after PasswordStrength on sign-up and reset. For checks
340
+ # that must not be a model validation, e.g. a breached-password lookup.
341
+ field :password_validator, type: :any, default: nil
342
+
343
+ # Callable `(account:, name:, request:)` run when a sign-up's address is
344
+ # first verified, by its code or by a reset link, before tokens are
345
+ # minted, e.g. to provision a profile. +name+ is the one given at sign-up.
346
+ # Only sign-ups made through this API run it (a reset of a web sign-up
347
+ # does not). It runs in the verification's transaction, with the password
348
+ # credential and identifier rows locked: a raise undoes it, and a slow
349
+ # hook holds those locks for as long as it takes.
350
+ field :after_email_verified, type: :any, default: nil
351
+
352
+ # Callable `(account:)`; truthy keeps an account from
353
+ # PurgeUnverifiedEmailSignupsJob on top of the built-in checks.
354
+ field :account_in_use, type: :any, default: nil
355
+
356
+ # Callable `(error:, request:)` returning the JSON body for a
357
+ # StandardId::EmailPasswordError; the status stays error.http_status.
358
+ # Default: { error:, error_description:, details? }.
359
+ field :error_renderer, type: :any, default: nil
360
+
361
+ # PurgeUnverifiedEmailSignupsJob: unverified sign-ups older than this are
362
+ # removed, unless a code was requested within purge_recent_code_window.
363
+ field :unverified_purge_after, type: :integer, default: 259200 # 3 days in seconds
364
+ field :purge_recent_code_window, type: :integer, default: 86400 # 24 hours in seconds
365
+ end
366
+
280
367
  scope :session do
281
368
  field :browser_session_lifetime, type: :integer, default: 86400 # 24 hours in seconds
282
369
  field :browser_session_remember_me_lifetime, type: :integer, default: 2592000 # 30 days in seconds
@@ -875,5 +962,26 @@ StandardId::ConfigSchema.define do
875
962
  # "throttled" from "token invalid" and turn the limiter into a token-validity
876
963
  # oracle.
877
964
  field :introspection_per_ip, type: :integer, default: 30 # per 15 minutes
965
+
966
+ # Email + password API (Api::EmailPasswordController). The other actions
967
+ # reuse the limits above: register signup_per_ip + verification_start_per_target,
968
+ # login login_per_*, resend verification_start_*, forgot password_reset_start_*.
969
+ field :email_password_verify_per_email, type: :integer, default: 10 # per 15 minutes
970
+ field :email_password_reset_per_ip, type: :integer, default: 20 # per 15 minutes
971
+
972
+ # Daily (24h) per-address caps (StandardId::EmailPassword::DailyLimit), on
973
+ # top of the short windows. Counted whether or not the address has an
974
+ # account, so reaching one reveals nothing.
975
+ # failed_verifications — a wrong code sent WITH the right password; at the
976
+ # cap the address cannot verify until the window passes. Wrong-password
977
+ # attempts never count, so a stranger cannot lock the registrant out.
978
+ # codes — sign-up codes (and existing-account notices) emailed.
979
+ # reset_requests_per_network — forgot-password emails per address per
980
+ # client_key, so one attacker cannot spend the owner's allowance.
981
+ # reset_requests — the same across all networks.
982
+ field :email_password_failed_verifications_per_day, type: :integer, default: 10
983
+ field :email_password_codes_per_day, type: :integer, default: 10
984
+ field :email_password_reset_requests_per_network_per_day, type: :integer, default: 5
985
+ field :email_password_reset_requests_per_day, type: :integer, default: 20
878
986
  end
879
987
  end
@@ -0,0 +1,71 @@
1
+ module StandardId
2
+ module EmailPassword
3
+ # The email + password check for login. Token minting is TokenFlow.
4
+ #
5
+ # An unknown address, a wrong password and an account that signs in only
6
+ # another way all raise one generic :invalid_credentials, so login cannot
7
+ # enumerate accounts. :unverified is only reachable WITH the right
8
+ # password, so it reveals nothing the caller did not already know.
9
+ # Publishes the events Oauth::PasswordFlow does.
10
+ #
11
+ # Given a block, yields the account with the password credential's row
12
+ # locked, after re-checking that the password has not changed since it was
13
+ # verified. Mint tokens inside it: PasswordReset.complete! takes the same
14
+ # lock to change the password and revoke access, so a login racing a reset
15
+ # either finishes first (and its tokens are revoked) or fails.
16
+ module Authentication
17
+ class << self
18
+ # @return [Account]
19
+ # @raise [StandardId::EmailPasswordError] :invalid_credentials, :unverified
20
+ def call(email:, password:)
21
+ email = EmailPassword.normalize_email(email)
22
+ password = password.to_s
23
+
24
+ StandardId::Events.publish(
25
+ StandardId::Events::AUTHENTICATION_ATTEMPT_STARTED,
26
+ account_lookup: email,
27
+ auth_method: "password"
28
+ )
29
+
30
+ password_credential = EmailPassword.find_password_credential(email)
31
+ unless password_credential
32
+ BCrypt::Password.new(DUMMY_PASSWORD_DIGEST.value).is_password?(password)
33
+ failed!(email)
34
+ end
35
+
36
+ identifier = password_credential.credential&.identifier
37
+ verified_digest = password_credential.password_digest
38
+ failed!(email) unless identifier && password_credential.authenticate(password)
39
+
40
+ account = identifier.account
41
+ StandardId::Events.publish(
42
+ StandardId::Events::PASSWORD_VALIDATED,
43
+ account: account,
44
+ credential_id: password_credential.id
45
+ )
46
+
47
+ raise StandardId::EmailPasswordError.new(:unverified) unless identifier.verified?
48
+ return account unless block_given?
49
+
50
+ password_credential.with_lock do
51
+ failed!(email) unless password_credential.password_digest == verified_digest
52
+ yield account
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def failed!(email)
59
+ StandardId::Events.publish(
60
+ StandardId::Events::AUTHENTICATION_FAILED,
61
+ account_lookup: email,
62
+ auth_method: "password",
63
+ error_code: "invalid_credentials",
64
+ error_message: "Invalid email or password"
65
+ )
66
+ raise StandardId::EmailPasswordError.new(:invalid_credentials)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end