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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +69 -0
- data/README.md +123 -0
- data/app/controllers/standard_id/api/email_password_controller.rb +131 -0
- data/app/controllers/standard_id/web/verify_email/confirm_controller.rb +18 -22
- data/app/controllers/standard_id/web/verify_phone/confirm_controller.rb +18 -22
- data/app/forms/standard_id/web/reset_password_confirm_form.rb +41 -1
- data/app/jobs/standard_id/purge_unverified_email_signups_job.rb +141 -0
- data/app/mailers/standard_id/account_notice_mailer.rb +33 -0
- data/app/models/standard_id/identifier.rb +22 -2
- data/app/models/standard_id/session.rb +4 -2
- data/app/views/standard_id/account_notice_mailer/existing_account.html.erb +22 -0
- data/app/views/standard_id/account_notice_mailer/existing_account.text.erb +10 -0
- data/config/locales/en.yml +8 -0
- data/config/routes/api.rb +14 -0
- data/db/migrate/20261022000000_add_signup_source_to_standard_id_identifiers.rb +13 -0
- data/lib/standard_id/access_revocation.rb +40 -0
- data/lib/standard_id/config/callable_validator.rb +49 -0
- data/lib/standard_id/config/schema.rb +108 -0
- data/lib/standard_id/email_password/authentication.rb +71 -0
- data/lib/standard_id/email_password/daily_limit.rb +67 -0
- data/lib/standard_id/email_password/password_reset.rb +148 -0
- data/lib/standard_id/email_password/registration.rb +141 -0
- data/lib/standard_id/email_password/token_flow.rb +36 -0
- data/lib/standard_id/email_password/verification.rb +155 -0
- data/lib/standard_id/email_password.rb +209 -0
- data/lib/standard_id/errors.rb +39 -0
- data/lib/standard_id/login_method_policy.rb +2 -1
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id.rb +8 -0
- 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
|