standard_id 0.54.1 → 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 (29) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/README.md +123 -0
  4. data/app/controllers/standard_id/api/email_password_controller.rb +131 -0
  5. data/app/forms/standard_id/web/reset_password_confirm_form.rb +6 -7
  6. data/app/jobs/standard_id/purge_unverified_email_signups_job.rb +141 -0
  7. data/app/mailers/standard_id/account_notice_mailer.rb +33 -0
  8. data/app/models/standard_id/identifier.rb +22 -2
  9. data/app/models/standard_id/session.rb +4 -4
  10. data/app/views/standard_id/account_notice_mailer/existing_account.html.erb +22 -0
  11. data/app/views/standard_id/account_notice_mailer/existing_account.text.erb +10 -0
  12. data/config/locales/en.yml +8 -0
  13. data/config/routes/api.rb +14 -0
  14. data/db/migrate/20261022000000_add_signup_source_to_standard_id_identifiers.rb +13 -0
  15. data/lib/standard_id/access_revocation.rb +40 -0
  16. data/lib/standard_id/config/callable_validator.rb +49 -0
  17. data/lib/standard_id/config/schema.rb +108 -0
  18. data/lib/standard_id/email_password/authentication.rb +71 -0
  19. data/lib/standard_id/email_password/daily_limit.rb +67 -0
  20. data/lib/standard_id/email_password/password_reset.rb +148 -0
  21. data/lib/standard_id/email_password/registration.rb +141 -0
  22. data/lib/standard_id/email_password/token_flow.rb +36 -0
  23. data/lib/standard_id/email_password/verification.rb +155 -0
  24. data/lib/standard_id/email_password.rb +209 -0
  25. data/lib/standard_id/errors.rb +39 -0
  26. data/lib/standard_id/login_method_policy.rb +2 -1
  27. data/lib/standard_id/version.rb +1 -1
  28. data/lib/standard_id.rb +8 -0
  29. metadata +15 -1
@@ -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
@@ -0,0 +1,67 @@
1
+ require "digest"
2
+
3
+ module StandardId
4
+ module EmailPassword
5
+ # Per-address caps over a 24-hour window, on top of the controller's short
6
+ # per-IP / per-email limits. Those bound bursts; these bound what one
7
+ # address can be put through in a day, however patiently it is spread out.
8
+ # The limits are `c.rate_limits.email_password_*_per_day` (see the schema).
9
+ #
10
+ # Counted per normalised address whether or not an account exists, so a cap
11
+ # being reached reveals nothing. Counters live in the engine's rate-limit
12
+ # store. The window is fixed from the first event: `increment(expires_in:)`
13
+ # only sets the expiry when it creates the entry.
14
+ #
15
+ # Check-then-record is not atomic, so concurrent requests can overshoot a
16
+ # cap by a few. That is fine for an abuse bound; do not use these as quotas.
17
+ module DailyLimit
18
+ WINDOW = 24.hours
19
+
20
+ LIMIT_FIELDS = {
21
+ failed_verification: :email_password_failed_verifications_per_day,
22
+ code_issued: :email_password_codes_per_day,
23
+ reset_requested_network: :email_password_reset_requests_per_network_per_day,
24
+ reset_requested: :email_password_reset_requests_per_day
25
+ }.freeze
26
+
27
+ class << self
28
+ def limit(kind)
29
+ StandardId.config.rate_limits.public_send(LIMIT_FIELDS.fetch(kind)).to_i
30
+ end
31
+
32
+ # +scope+ further partitions the counter (the client key for
33
+ # :reset_requested_network).
34
+ def exceeded?(kind, email, scope: nil)
35
+ count(kind, email, scope: scope) >= limit(kind)
36
+ end
37
+
38
+ def count(kind, email, scope: nil)
39
+ store.read(key(kind, email, scope: scope)).to_i
40
+ end
41
+
42
+ # Records one event; returns the new count.
43
+ def record!(kind, email, scope: nil)
44
+ store.increment(key(kind, email, scope: scope), 1, expires_in: WINDOW).to_i
45
+ end
46
+
47
+ # Records one event unless the cap is already reached. Returns true when
48
+ # the event may proceed.
49
+ def consume!(kind, email, scope: nil)
50
+ return false if exceeded?(kind, email, scope: scope)
51
+
52
+ record!(kind, email, scope: scope) <= limit(kind)
53
+ end
54
+
55
+ def key(kind, email, scope: nil)
56
+ LIMIT_FIELDS.fetch(kind) # unknown kinds raise
57
+ subject = [EmailPassword.normalize_email(email), scope].compact.join("|")
58
+ "standard_id:email-password-daily:#{kind}:#{Digest::SHA256.hexdigest(subject)}"
59
+ end
60
+
61
+ def store
62
+ StandardId::RateLimiter::STORE
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,148 @@
1
+ module StandardId
2
+ module EmailPassword
3
+ # Forgot-password and the reset it leads to.
4
+ module PasswordReset
5
+ class TokenAlreadyUsed < StandardError; end
6
+
7
+ class << self
8
+ # Queues the reset email for +email+. The lookup, the token and the
9
+ # CREDENTIAL_PASSWORD_RESET_INITIATED event all happen in
10
+ # PasswordResetDeliveryJob, so the request path is identical whether or
11
+ # not the address has an account. Delivery follows
12
+ # reset_password.delivery.
13
+ #
14
+ # Daily caps, counted whether or not the address has an account:
15
+ # rate_limits.email_password_reset_requests_per_network_per_day per
16
+ # address per client key, so an attacker who burns their own allowance
17
+ # does not stop the owner, elsewhere, from getting a link; and
18
+ # email_password_reset_requests_per_day per address overall. The
19
+ # network cap is checked first, so requests it refuses never spend the
20
+ # shared ceiling.
21
+ #
22
+ # @return [void]
23
+ def request(email:, request: nil)
24
+ email = EmailPassword.normalize_email(email)
25
+ return if email.blank?
26
+
27
+ url_template = reset_url_template(request)
28
+ client_key = EmailPassword.client_key(request)
29
+ return if DailyLimit.exceeded?(:reset_requested_network, email, scope: client_key)
30
+ return unless DailyLimit.consume!(:reset_requested, email)
31
+
32
+ DailyLimit.record!(:reset_requested_network, email, scope: client_key)
33
+ StandardId::PasswordResetDeliveryJob.perform_later(email: email, reset_url_template: url_template)
34
+ end
35
+
36
+ # Whether +token+ still resolves (not expired, used or tampered with).
37
+ def valid_token?(token)
38
+ find_credential(token).present?
39
+ end
40
+
41
+ # Sets a new password from a reset token, verifies the address and
42
+ # revokes every session and refresh token (AccessRevocation).
43
+ #
44
+ # A reset that verifies a pending sign-up is its first verification, so
45
+ # it runs `c.email_password.after_email_verified` too, in the same
46
+ # transaction (EmailPassword.complete_verification!): if the hook
47
+ # raises, nothing changes and the token still works. An unverified
48
+ # account that did not sign up through Registration (no signup_source,
49
+ # EmailPassword.api_signup?) is verified without the hook.
50
+ #
51
+ # The token is PasswordCredential's `generates_token_for
52
+ # :password_reset`: it embeds the current digest, so the change below is
53
+ # what makes it single-use. That holds under concurrency too: the row is
54
+ # locked and the token re-resolved inside the transaction, so of two
55
+ # requests racing with one token, the second sees the new digest.
56
+ #
57
+ # @param password_confirmation [String, nil] compared when given
58
+ # @return [Account]
59
+ # @raise [StandardId::EmailPasswordError] :invalid_reset_token,
60
+ # :weak_password
61
+ def complete!(token:, password:, password_confirmation: nil, request: nil)
62
+ password = password.to_s
63
+ password_credential = find_credential(token)
64
+ identifier = password_credential&.credential&.identifier
65
+ raise StandardId::EmailPasswordError.new(:invalid_reset_token) if identifier.nil?
66
+
67
+ validate!(password, password_confirmation, password_credential.login)
68
+
69
+ account = identifier.account
70
+ issued_for_digest = password_credential.password_digest
71
+
72
+ ActiveRecord::Base.transaction do
73
+ password_credential.lock!
74
+ unless password_credential.password_digest == issued_for_digest &&
75
+ find_credential(token)&.id == password_credential.id
76
+ raise TokenAlreadyUsed
77
+ end
78
+
79
+ password_credential.update!(password: password)
80
+ api_signup = !identifier.verified? && EmailPassword.api_signup?(identifier)
81
+ EmailPassword.complete_verification!(
82
+ identifier,
83
+ name: (EmailPassword.signup_name(password_credential.login) if api_signup),
84
+ request: request,
85
+ run_hook: api_signup
86
+ )
87
+ StandardId::AccessRevocation.revoke_all!(account, reason: "password_reset")
88
+ end
89
+
90
+ publish_reset_completed(account, password_credential)
91
+ account
92
+ rescue TokenAlreadyUsed
93
+ raise StandardId::EmailPasswordError.new(:invalid_reset_token)
94
+ end
95
+
96
+ private
97
+
98
+ def find_credential(token)
99
+ return nil if token.blank?
100
+
101
+ StandardId::PasswordCredential
102
+ .includes(credential: { identifier: :account })
103
+ .find_by_token_for(:password_reset, token.to_s)
104
+ end
105
+
106
+ def validate!(password, password_confirmation, login)
107
+ # has_secure_password ignores an empty assignment on an existing
108
+ # record, which would "succeed" without changing anything.
109
+ errors = password.empty? ? ["can't be blank"] : EmailPassword.password_errors(password, email: login)
110
+ details = {}
111
+ details[:password] = errors if errors.any?
112
+ if !password_confirmation.nil? && password_confirmation.to_s != password
113
+ details[:password_confirmation] = ["doesn't match Password"]
114
+ end
115
+ raise StandardId::EmailPasswordError.new(:weak_password, details: details) if details.any?
116
+ end
117
+
118
+ def reset_url_template(request)
119
+ setting = EmailPassword.config.reset_url
120
+ template = setting.respond_to?(:call) ? EmailPassword.call_hook(setting, request: request) : setting
121
+ return template.to_s if template.to_s.include?("{token}")
122
+
123
+ raise StandardId::ConfigurationError,
124
+ "c.email_password.reset_url must be a URL containing \"{token}\" (or a callable (request:) returning one), " \
125
+ "e.g. \"https://app.example.com/password-reset?token={token}\"."
126
+ end
127
+
128
+ # The reset has already happened, so a failing subscriber must not turn
129
+ # it into an error; log and carry on, as Web::ResetPasswordConfirmForm does.
130
+ def publish_reset_completed(account, password_credential)
131
+ StandardId::Events.publish(
132
+ StandardId::Events::CREDENTIAL_PASSWORD_RESET_COMPLETED,
133
+ credential: password_credential,
134
+ account: account
135
+ )
136
+ rescue StandardError => e
137
+ logger = StandardId.logger
138
+ return unless logger.respond_to?(:error)
139
+
140
+ logger.error(
141
+ "[StandardId::EmailPassword::PasswordReset] Failed to publish " \
142
+ "CREDENTIAL_PASSWORD_RESET_COMPLETED for account #{account.id}: #{e.class}: #{e.message}"
143
+ )
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,141 @@
1
+ module StandardId
2
+ module EmailPassword
3
+ # Sign-up: creates an unverified account with a password credential and
4
+ # emails it a code. No tokens here; those come from Verification.verify!.
5
+ #
6
+ # Re-submitting sign-up for an address whose sign-up was never verified
7
+ # NEVER changes the pending password: overwriting it would let anyone
8
+ # re-register a stranger's pending address with their own password and
9
+ # then ride the owner's code into the account. It is treated like a resend:
10
+ # * same password as the pending one -> a fresh code is sent;
11
+ # * different password -> nothing changes, nothing is sent.
12
+ # Both get the same answer as a brand-new address, so sign-up cannot be used
13
+ # to learn that someone started a sign-up. The owner of a squatted address
14
+ # recovers through forgot-password (the link verifies the address and
15
+ # replaces the password), a social sign-in (see
16
+ # EmailPassword.discard_pending_credentials!), or
17
+ # PurgeUnverifiedEmailSignupsJob.
18
+ #
19
+ # An address that already has a verified password account, or belongs to
20
+ # an account that signs in another way, raises :account_exists /
21
+ # :social_account, unless `c.email_password.reveal_existing_accounts` is
22
+ # false: then it returns normally and the address is emailed
23
+ # (AccountNoticeMailer#existing_account) instead.
24
+ #
25
+ # ACCOUNT_CREATED and CREDENTIAL_PASSWORD_CREATED fire here, inside the
26
+ # sign-up transaction, for an account whose address is NOT yet verified
27
+ # (as they do for Web::SignupForm). Provision anything that should wait for
28
+ # a proven address from `c.email_password.after_email_verified` instead.
29
+ module Registration
30
+ class << self
31
+ # @return [String] the normalised email
32
+ # @raise [StandardId::EmailPasswordError] :invalid_request,
33
+ # :weak_password, :account_exists, :social_account
34
+ def call(email:, password:, name: nil, request: nil)
35
+ email = EmailPassword.normalize_email(email)
36
+ name = name.to_s.strip
37
+ password = password.to_s
38
+ validate!(email, name, password)
39
+
40
+ existing = EmailPassword.find_password_credential(email)
41
+ if existing
42
+ identifier = existing.credential&.identifier
43
+ return existing_account!(email, :account_exists, password) if identifier.nil? || identifier.verified?
44
+
45
+ Verification.issue_code(email, name: name, request: request) if existing.authenticate(password)
46
+ return email
47
+ end
48
+
49
+ return existing_account!(email, :social_account, password) if StandardId::EmailIdentifier.exists?(value: email)
50
+
51
+ create_account!(email, name, password, request)
52
+ Verification.issue_code(email, name: name, request: request)
53
+ email
54
+ rescue ActiveRecord::RecordNotUnique
55
+ # Lost a race with a concurrent sign-up for the same address.
56
+ existing_account!(email, :account_exists)
57
+ rescue ActiveRecord::RecordInvalid => e
58
+ # The same race, caught by the uniqueness validation instead.
59
+ return existing_account!(email, :account_exists) if e.record.errors.of_kind?(:login, :taken)
60
+
61
+ # A host Account validation (or account_creator) refused the record.
62
+ raise StandardId::EmailPasswordError.new(:invalid_request, details: e.record.errors.to_hash)
63
+ end
64
+
65
+ private
66
+
67
+ def validate!(email, name, password)
68
+ config = EmailPassword.config
69
+ details = {}
70
+ details[:name] = ["can't be blank"] if config.require_name && name.blank?
71
+ if name.length > config.name_max_length.to_i
72
+ details[:name] = ["is too long (maximum is #{config.name_max_length} characters)"]
73
+ end
74
+ details[:email] = ["is invalid"] unless EmailPassword.valid_email?(email)
75
+ raise StandardId::EmailPasswordError.new(:invalid_request, details: details) if details.any?
76
+
77
+ errors = EmailPassword.password_errors(password, email: email)
78
+ raise StandardId::EmailPasswordError.new(:weak_password, details: { password: errors }) if errors.any?
79
+ end
80
+
81
+ # +password+ is hashed and thrown away when concealing: a new address
82
+ # pays one bcrypt to store its password, so an existing one must too,
83
+ # or the response time would tell them apart.
84
+ def existing_account!(email, code, password = nil)
85
+ raise StandardId::EmailPasswordError.new(code) if EmailPassword.config.reveal_existing_accounts
86
+
87
+ StandardId::PasswordCredential.new(password: password) unless password.nil?
88
+ if DailyLimit.consume!(:code_issued, email)
89
+ StandardId::AccountNoticeMailer.with(email: email).existing_account.deliver_later
90
+ end
91
+ email
92
+ end
93
+
94
+ def create_account!(email, name, password, request)
95
+ EmailPassword.require_signup_source_column!
96
+ ActiveRecord::Base.transaction do
97
+ StandardId::Events.publish(
98
+ StandardId::Events::ACCOUNT_CREATING,
99
+ account_params: { email: email, name: name.presence }.compact,
100
+ auth_method: "password"
101
+ )
102
+
103
+ account = build_account(email, name, request)
104
+ identifier = StandardId::EmailIdentifier.create!(
105
+ account: account, value: email, signup_source: EmailPassword::SIGNUP_SOURCE
106
+ )
107
+ password_credential = StandardId::PasswordCredential.create!(login: email, password: password)
108
+ StandardId::Credential.create!(identifier: identifier, credentialable: password_credential)
109
+
110
+ StandardId::Events.publish(
111
+ StandardId::Events::ACCOUNT_CREATED,
112
+ account: account,
113
+ auth_method: "password",
114
+ source: "email_password_signup"
115
+ )
116
+ StandardId::Events.publish(
117
+ StandardId::Events::CREDENTIAL_PASSWORD_CREATED,
118
+ credential: password_credential,
119
+ account: account
120
+ )
121
+ account
122
+ end
123
+ end
124
+
125
+ def build_account(email, name, request)
126
+ creator = EmailPassword.config.account_creator
127
+ if creator.respond_to?(:call)
128
+ account = EmailPassword.call_hook(creator, email: email, name: name.presence, request: request)
129
+ raise StandardId::ConfigurationError, "c.email_password.account_creator must return a persisted account" unless account&.persisted?
130
+
131
+ return account
132
+ end
133
+
134
+ account_class = StandardId.account_class
135
+ attributes = { email: email, name: name.presence || email.split("@").first }
136
+ account_class.create!(attributes.slice(*account_class.attribute_names.map(&:to_sym)))
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end