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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +47 -0
- data/README.md +123 -0
- data/app/controllers/standard_id/api/email_password_controller.rb +131 -0
- data/app/forms/standard_id/web/reset_password_confirm_form.rb +6 -7
- 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 -4
- 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,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
|