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,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
@@ -0,0 +1,36 @@
1
+ module StandardId
2
+ module EmailPassword
3
+ # Mints the token response for an account that Authentication or
4
+ # Verification has just proven, through the same pipeline as every other
5
+ # grant: login-method policy (flow :api_email_password), audience checks,
6
+ # claims, OAUTH_TOKEN_ISSUING / OAUTH_TOKEN_ISSUED (so AccountStatus and
7
+ # AccountLocking refuse), refresh token and session persistence.
8
+ #
9
+ # client_id, scope and audience come from `c.email_password.token_*`,
10
+ # never from the request. Not reachable from /oauth/token.
11
+ class TokenFlow < StandardId::Oauth::TokenGrantFlow
12
+ def initialize(account:, request:)
13
+ super({}, request)
14
+ @account = account
15
+ end
16
+
17
+ def authenticate!
18
+ raise StandardId::InvalidGrantError, "Account is required" if @account.blank?
19
+ end
20
+
21
+ private
22
+
23
+ def login_method_policy_context
24
+ { auth_method: :password, flow: :api_email_password }
25
+ end
26
+
27
+ def subject_id = @account.id
28
+ def client_id = EmailPassword.config.token_client_id
29
+ def token_scope = EmailPassword.config.token_scope
30
+ def audience = EmailPassword.config.token_audience
31
+ def grant_type = "password"
32
+ def supports_refresh_token? = true
33
+ def token_account = @account
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,155 @@
1
+ module StandardId
2
+ module EmailPassword
3
+ # The sign-up code: issuing it (sign-up and resend) and confirming it.
4
+ module Verification
5
+ INVALID_CODE_MESSAGE = "Invalid or expired verification code."
6
+ TOO_MANY_ATTEMPTS_MESSAGE = "Too many incorrect attempts. Please request a new code."
7
+ LOCKED_OUT_MESSAGE = "Too many incorrect attempts. Please try again tomorrow."
8
+
9
+ class << self
10
+ # Confirms the code for a pending sign-up, verifies the address and runs
11
+ # `c.email_password.after_email_verified` when the sign-up was made
12
+ # through Registration (EmailPassword.api_signup?), as a reset does: an
13
+ # unverified web sign-up that verifies here is verified without it.
14
+ #
15
+ # Requires the sign-up password as well as the code. Code alone would be
16
+ # a takeover: anyone could start a sign-up for an address and the
17
+ # owner's code would then confirm an account the stranger controls. The
18
+ # password is checked FIRST and a mismatch is answered exactly like a
19
+ # wrong code, so a guessed code is never even compared without it.
20
+ #
21
+ # A wrong code sent WITH the right password counts toward
22
+ # rate_limits.email_password_failed_verifications_per_day; at the cap the
23
+ # address cannot verify, even with the right code, until the window
24
+ # passes. A wrong password does not count, so a stranger cannot burn the
25
+ # registrant's daily allowance, and only the right password is told the
26
+ # address is locked out (otherwise the lockout would reveal that the
27
+ # address has a pending sign-up). The controller's short per-email
28
+ # limit (rate_limits.email_password_verify_per_email) does count every
29
+ # request, so a stranger CAN hold verification off for that window.
30
+ #
31
+ # Given a block, yields the account with the password credential's row
32
+ # locked, after re-checking that the password has not changed since it
33
+ # was checked, as Authentication.call does: mint tokens inside it, so a
34
+ # password reset racing this verification either finishes first (and
35
+ # this answers :invalid_code) or revokes what it minted.
36
+ #
37
+ # @return [Account] ready for EmailPassword::TokenFlow
38
+ # @raise [StandardId::EmailPasswordError] :invalid_code
39
+ def verify!(email:, code:, password:, request: nil)
40
+ email = EmailPassword.normalize_email(email)
41
+ password_credential = EmailPassword.credential_for_password(email, password)
42
+ identifier = password_credential&.credential&.identifier
43
+ raise invalid_code(INVALID_CODE_MESSAGE) if identifier.nil?
44
+ raise invalid_code(LOCKED_OUT_MESSAGE) if DailyLimit.exceeded?(:failed_verification, email)
45
+
46
+ verified_digest = password_credential.password_digest
47
+
48
+ otp = StandardId::Otp.verify(
49
+ realm: EmailPassword.config.verification_realm,
50
+ target: email,
51
+ channel: :email,
52
+ code: code.to_s.strip,
53
+ request: request
54
+ )
55
+ failed!(email, otp.error_code) unless otp.success?
56
+
57
+ # The hook runs in the same transaction as the verification: if it
58
+ # raises, the address stays unverified, so the registrant can ask for
59
+ # a new code (this one is spent) and the hook runs again. Otherwise a
60
+ # failed hook would leave a verified account that login lets in
61
+ # without ever provisioning it.
62
+ account = ActiveRecord::Base.transaction do
63
+ password_unchanged!(password_credential, verified_digest)
64
+ EmailPassword.complete_verification!(
65
+ identifier,
66
+ name: otp.challenge&.metadata&.dig("name"),
67
+ request: request,
68
+ run_hook: EmailPassword.api_signup?(identifier)
69
+ )
70
+ identifier.account
71
+ end
72
+ return account unless block_given?
73
+
74
+ password_credential.with_lock do
75
+ password_unchanged!(password_credential, verified_digest)
76
+ yield account
77
+ end
78
+ end
79
+
80
+ # Resend: issues a fresh code for a pending sign-up, only when +password+
81
+ # is its password. Otherwise a silent no-op: the endpoint answers the
82
+ # same either way, so it cannot probe which addresses have accounts, and
83
+ # a stranger cannot spend the registrant's daily code allowance.
84
+ #
85
+ # @return [Boolean] whether a code was issued
86
+ def send_code(email:, password:, name: nil, request: nil)
87
+ email = EmailPassword.normalize_email(email)
88
+ return false unless EmailPassword.pending_identifier(email, password)
89
+
90
+ issue_code(email, name: name, request: request)
91
+ end
92
+
93
+ # Issues and delivers a code for +email+ without checking for a pending
94
+ # sign-up (Registration has just done so). At most
95
+ # rate_limits.email_password_codes_per_day per address; past that, and
96
+ # while passwordless.retry_delay is still running (the code already
97
+ # sent stays valid), a silent no-op.
98
+ #
99
+ # The name given at sign-up rides on the challenge's metadata until the
100
+ # code is confirmed; a resend carries it forward.
101
+ #
102
+ # @return [Boolean] whether a code was issued
103
+ def issue_code(email, name: nil, request: nil)
104
+ return false if DailyLimit.exceeded?(:code_issued, email)
105
+
106
+ config = EmailPassword.config
107
+ built_in = config.code_delivery.to_sym == :built_in
108
+ result = StandardId::Otp.issue(
109
+ realm: config.verification_realm,
110
+ target: email,
111
+ channel: :email,
112
+ request: request,
113
+ delivery: built_in ? :manual : :custom,
114
+ metadata: { "name" => name.presence || EmailPassword.signup_name(email) }.compact
115
+ )
116
+ return false unless result.success?
117
+
118
+ DailyLimit.record!(:code_issued, email)
119
+ if built_in
120
+ StandardId::PasswordlessMailer
121
+ .with(email: email, otp_code: result.code, realm: config.verification_realm)
122
+ .verification_email
123
+ .deliver_later
124
+ end
125
+ true
126
+ end
127
+
128
+ private
129
+
130
+ # Locks the credential (in the caller's transaction) and refuses the
131
+ # verification if a reset changed the password after it was checked.
132
+ def password_unchanged!(password_credential, verified_digest)
133
+ password_credential.lock!
134
+ raise invalid_code(INVALID_CODE_MESSAGE) unless password_credential.password_digest == verified_digest
135
+ end
136
+
137
+ def failed!(email, error_code)
138
+ count = DailyLimit.record!(:failed_verification, email)
139
+ message = if count >= DailyLimit.limit(:failed_verification)
140
+ LOCKED_OUT_MESSAGE
141
+ elsif error_code == :max_attempts
142
+ TOO_MANY_ATTEMPTS_MESSAGE
143
+ else
144
+ INVALID_CODE_MESSAGE
145
+ end
146
+ raise invalid_code(message)
147
+ end
148
+
149
+ def invalid_code(message)
150
+ StandardId::EmailPasswordError.new(:invalid_code, message)
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end