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,209 @@
|
|
|
1
|
+
require "concurrent/delay"
|
|
2
|
+
|
|
3
|
+
module StandardId
|
|
4
|
+
# Email + password sign-up and sign-in for JSON clients
|
|
5
|
+
# (Api::EmailPasswordController), switched on with
|
|
6
|
+
# `c.email_password.enabled = true`.
|
|
7
|
+
#
|
|
8
|
+
# Built on the engine's primitives rather than beside them: PasswordCredential
|
|
9
|
+
# (has_secure_password, PasswordStrength, the single-use password_reset token),
|
|
10
|
+
# StandardId::Otp for the 6-digit sign-up code, PasswordResetDeliveryJob for
|
|
11
|
+
# forgot-password, and a TokenGrantFlow (EmailPassword::TokenFlow) for tokens,
|
|
12
|
+
# so a sign-in here returns the same TokenResponse as the other grants and
|
|
13
|
+
# refreshes through /oauth/token.
|
|
14
|
+
#
|
|
15
|
+
# The rules:
|
|
16
|
+
# * Sign-up creates an UNVERIFIED account and emails a code. Tokens are
|
|
17
|
+
# issued only once the code is confirmed, together with the sign-up
|
|
18
|
+
# password: the code proves the inbox, the password proves the caller is
|
|
19
|
+
# whoever started this sign-up.
|
|
20
|
+
# * Login with the right password on an unverified address answers
|
|
21
|
+
# :unverified, so the client can send the user to its verify screen.
|
|
22
|
+
# Every other login failure is one generic :invalid_credentials.
|
|
23
|
+
# * Re-submitting sign-up for a pending address never changes its password.
|
|
24
|
+
# * Resend and forgot-password always answer the same, whatever the address.
|
|
25
|
+
# * A reset link sets the password, verifies the address (following the
|
|
26
|
+
# link proves the inbox) and revokes every session and refresh token.
|
|
27
|
+
#
|
|
28
|
+
# Each operation returns its result and raises StandardId::EmailPasswordError
|
|
29
|
+
# otherwise, so a host can drive them from its own controllers too.
|
|
30
|
+
module EmailPassword
|
|
31
|
+
# A digest to compare against when no credential exists, so a miss costs
|
|
32
|
+
# the same bcrypt work as a hit and timing does not reveal whether the
|
|
33
|
+
# address is registered.
|
|
34
|
+
DUMMY_PASSWORD_DIGEST = Concurrent::Delay.new { BCrypt::Password.create("").freeze }
|
|
35
|
+
|
|
36
|
+
# Identifier#signup_source of a sign-up made through Registration. A reset
|
|
37
|
+
# runs `after_email_verified` only for these, and
|
|
38
|
+
# PurgeUnverifiedEmailSignupsJob only deletes these.
|
|
39
|
+
SIGNUP_SOURCE = "email_password"
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
def config
|
|
43
|
+
StandardId.config.email_password
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def enabled?
|
|
47
|
+
config.enabled == true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def normalize_email(email)
|
|
51
|
+
email.to_s.strip.downcase
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def valid_email?(email)
|
|
55
|
+
StandardId::EmailIdentifier::EMAIL_FORMAT.match?(email.to_s)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The password credential whose login is +email+, with the identifier and
|
|
59
|
+
# account it hangs off preloaded.
|
|
60
|
+
def find_password_credential(email)
|
|
61
|
+
StandardId::PasswordCredential
|
|
62
|
+
.includes(credential: { identifier: :account })
|
|
63
|
+
.find_by(login: normalize_email(email))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The pending sign-up for +email+ when +password+ is its password: the
|
|
67
|
+
# identifier of an unverified password credential, else nil. A miss still
|
|
68
|
+
# pays for a bcrypt comparison.
|
|
69
|
+
def pending_identifier(email, password)
|
|
70
|
+
identifier = identifier_for_password(email, password)
|
|
71
|
+
identifier unless identifier.nil? || identifier.verified?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The identifier of the password credential for +email+, only when
|
|
75
|
+
# +password+ is that credential's password.
|
|
76
|
+
def identifier_for_password(email, password)
|
|
77
|
+
credential_for_password(email, password)&.credential&.identifier
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The password credential for +email+, only when +password+ is its
|
|
81
|
+
# password. A miss still pays for a bcrypt comparison.
|
|
82
|
+
def credential_for_password(email, password)
|
|
83
|
+
password = password.to_s
|
|
84
|
+
password_credential = find_password_credential(email)
|
|
85
|
+
unless password_credential
|
|
86
|
+
BCrypt::Password.new(DUMMY_PASSWORD_DIGEST.value).is_password?(password)
|
|
87
|
+
return nil
|
|
88
|
+
end
|
|
89
|
+
return nil if password.empty? || !password_credential.authenticate(password)
|
|
90
|
+
|
|
91
|
+
password_credential
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The first verification of an address, by code or by reset link: marks
|
|
95
|
+
# it verified and, for a sign-up made through Registration (+run_hook+),
|
|
96
|
+
# runs `c.email_password.after_email_verified`. A no-op when the address
|
|
97
|
+
# is already verified, so the hook runs once.
|
|
98
|
+
#
|
|
99
|
+
# Call it inside a transaction. If the hook raises, the address stays
|
|
100
|
+
# unverified and the next verification retries it; and
|
|
101
|
+
# IDENTIFIER_VERIFICATION_SUCCEEDED waits for the commit, so subscribers
|
|
102
|
+
# never see a verification that was rolled back.
|
|
103
|
+
def complete_verification!(identifier, name: nil, request: nil, run_hook: true)
|
|
104
|
+
identifier.lock!
|
|
105
|
+
return if identifier.verified?
|
|
106
|
+
|
|
107
|
+
identifier.verify!(defer_event: true)
|
|
108
|
+
hook = config.after_email_verified
|
|
109
|
+
return unless run_hook && hook.respond_to?(:call)
|
|
110
|
+
|
|
111
|
+
call_hook(hook, account: identifier.account, name: name, request: request)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# true when +identifier+ was created by Registration (its signup_source),
|
|
115
|
+
# as opposed to Web::SignupForm, a social sign-in or the host's own flow.
|
|
116
|
+
def api_signup?(identifier)
|
|
117
|
+
identifier.signup_source == SIGNUP_SOURCE
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Registration writes Identifier#signup_source, which migration
|
|
121
|
+
# 20261022000000 adds; without it a sign-up would fail with an unknown
|
|
122
|
+
# attribute, so say what to run instead.
|
|
123
|
+
def require_signup_source_column!
|
|
124
|
+
return if StandardId::Identifier.column_names.include?("signup_source")
|
|
125
|
+
|
|
126
|
+
raise StandardId::ConfigurationError,
|
|
127
|
+
"c.email_password needs migration 20261022000000_add_signup_source_to_standard_id_identifiers: " \
|
|
128
|
+
"run bin/rails standard_id:install:migrations && bin/rails db:migrate"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The name given at sign-up for +email+, carried on its latest
|
|
132
|
+
# verification code; nil once CleanupExpiredCodeChallengesJob has removed
|
|
133
|
+
# that code.
|
|
134
|
+
def signup_name(email)
|
|
135
|
+
StandardId::CodeChallenge
|
|
136
|
+
.where(realm: config.verification_realm, channel: "email", target: normalize_email(email))
|
|
137
|
+
.order(created_at: :desc)
|
|
138
|
+
.pick(:metadata)
|
|
139
|
+
&.dig("name")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Field messages for +password+ (empty when acceptable): PasswordStrength
|
|
143
|
+
# and has_secure_password's own checks on an unsaved probe, then
|
|
144
|
+
# `c.email_password.password_validator`, only once those pass.
|
|
145
|
+
def password_errors(password, email: nil)
|
|
146
|
+
probe = StandardId::PasswordCredential.new(login: email.presence || "probe@example.invalid", password: password)
|
|
147
|
+
probe.validate
|
|
148
|
+
errors = probe.errors.messages_for(:password)
|
|
149
|
+
return errors if errors.any?
|
|
150
|
+
|
|
151
|
+
validator = config.password_validator
|
|
152
|
+
return [] unless validator.respond_to?(:call)
|
|
153
|
+
|
|
154
|
+
Array(call_hook(validator, password: password, email: email)).compact.map(&:to_s)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The key per-IP limits count under (`c.email_password.client_key`).
|
|
158
|
+
def client_key(request)
|
|
159
|
+
hook = config.client_key
|
|
160
|
+
key = hook.respond_to?(:call) ? call_hook(hook, request: request) : nil
|
|
161
|
+
key.presence || request&.remote_ip.to_s
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Pre-account-takeover defence for hosts that link social sign-ins by
|
|
165
|
+
# email (c.social.link_strategy = :trust_provider). If someone started a
|
|
166
|
+
# sign-up for an address they do not own and never confirmed it, the
|
|
167
|
+
# owner signing in with a provider would land in THAT account with the
|
|
168
|
+
# stranger's password attached. The password was never usable (login
|
|
169
|
+
# needs the code, which only reaches the owner), but it is one reset away
|
|
170
|
+
# from becoming so. Drop it: the provider sign-in proved the address, the
|
|
171
|
+
# pending sign-up proved nothing.
|
|
172
|
+
#
|
|
173
|
+
# When anything was discarded, every session and refresh token the
|
|
174
|
+
# account already held is revoked as well: whatever was issued before
|
|
175
|
+
# the owner proved the address was issued to someone who had not.
|
|
176
|
+
# SOCIAL_ACCOUNT_LINKED fires after the social flow has issued its own
|
|
177
|
+
# session or tokens, so pass +issued_before+ (when that login started) to
|
|
178
|
+
# spare them. Call it from a SOCIAL_ACCOUNT_LINKED subscriber.
|
|
179
|
+
#
|
|
180
|
+
# @return [Integer] the number of pending credentials discarded
|
|
181
|
+
def discard_pending_credentials!(account, issued_before: nil)
|
|
182
|
+
return 0 if account.nil?
|
|
183
|
+
|
|
184
|
+
discarded = 0
|
|
185
|
+
StandardId::Credential
|
|
186
|
+
.joins(:identifier)
|
|
187
|
+
.where(standard_id_identifiers: { account_id: account.id, verified_at: nil })
|
|
188
|
+
.where(credentialable_type: StandardId::PasswordCredential.name)
|
|
189
|
+
.find_each do |credential|
|
|
190
|
+
ActiveRecord::Base.transaction do
|
|
191
|
+
password_credential = StandardId::PasswordCredential.find_by(id: credential.credentialable_id)
|
|
192
|
+
credential.destroy!
|
|
193
|
+
password_credential&.destroy!
|
|
194
|
+
end
|
|
195
|
+
discarded += 1
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
if discarded.positive?
|
|
199
|
+
StandardId::AccessRevocation.revoke_all!(account, reason: "pending_signup_discarded", issued_before: issued_before)
|
|
200
|
+
end
|
|
201
|
+
discarded
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def call_hook(hook, **context)
|
|
205
|
+
hook.call(**StandardId::Utils::CallableParameterFilter.filter(hook, context))
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
data/lib/standard_id/errors.rb
CHANGED
|
@@ -342,6 +342,45 @@ module StandardId
|
|
|
342
342
|
class InvalidAlgorithmError < InvalidTokenError; end
|
|
343
343
|
class InvalidAudienceTokenError < InvalidTokenError; end
|
|
344
344
|
|
|
345
|
+
# Raised by the API email + password endpoints (StandardId::EmailPassword).
|
|
346
|
+
# +code+ is one of CODES — stable symbols a client can branch on — and the
|
|
347
|
+
# status is fixed per code, so `c.email_password.error_renderer` can change
|
|
348
|
+
# the body without having to know the status. +details+ carries field
|
|
349
|
+
# messages for :weak_password and :invalid_request.
|
|
350
|
+
class EmailPasswordError < StandardError
|
|
351
|
+
CODES = {
|
|
352
|
+
invalid_credentials: { status: :unauthorized, message: "Invalid email or password" },
|
|
353
|
+
unverified: { status: :forbidden, message: "The email address has not been verified" },
|
|
354
|
+
account_exists: { status: :conflict, message: "An account with this email already exists" },
|
|
355
|
+
social_account: { status: :conflict, message: "This email is registered with a different sign-in method" },
|
|
356
|
+
invalid_code: { status: :unprocessable_content, message: "Invalid or expired verification code" },
|
|
357
|
+
invalid_reset_token: { status: :unprocessable_content, message: "The password reset link is invalid or has expired" },
|
|
358
|
+
weak_password: { status: :unprocessable_content, message: "The password does not meet the requirements" },
|
|
359
|
+
invalid_request: { status: :unprocessable_content, message: "The request is invalid" },
|
|
360
|
+
login_method_denied: { status: :forbidden, message: "This sign-in method is not available for this account" },
|
|
361
|
+
rate_limited: { status: :too_many_requests, message: "Too many requests. Please try again later." }
|
|
362
|
+
}.freeze
|
|
363
|
+
|
|
364
|
+
attr_reader :code, :details, :retry_after
|
|
365
|
+
|
|
366
|
+
def initialize(code, message = nil, details: {}, retry_after: nil)
|
|
367
|
+
@code = code.to_sym
|
|
368
|
+
raise ArgumentError, "Unknown EmailPasswordError code: #{code.inspect}" unless CODES.key?(@code)
|
|
369
|
+
|
|
370
|
+
@details = details.to_h
|
|
371
|
+
@retry_after = retry_after
|
|
372
|
+
super(message.presence || CODES.dig(@code, :message))
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def status
|
|
376
|
+
CODES.dig(code, :status)
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def http_status
|
|
380
|
+
Rack::Utils.status_code(status)
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
345
384
|
# Raised for engine misconfiguration — a required config value is missing,
|
|
346
385
|
# malformed, points at a non-callable with the wrong arity, or references a
|
|
347
386
|
# scope whose claims have no resolver registered. Always indicates a
|
|
@@ -19,6 +19,7 @@ module StandardId
|
|
|
19
19
|
# | :oauth_refresh_token | the ORIGINAL sign-in's, else :unspecified | POST /oauth/token grant_type=refresh_token |
|
|
20
20
|
# | :api_device_session | caller's, else :unspecified | Api::TokenManager#create_device_session (host code) |
|
|
21
21
|
# | :api_service_session | caller's, else :unspecified | Api::TokenManager#create_service_session (host code) |
|
|
22
|
+
# | :api_email_password | :password | POST /auth/login and /auth/verify-email (Api::EmailPasswordController) |
|
|
22
23
|
#
|
|
23
24
|
# `refresh_token` is re-checked with the method of the authentication it
|
|
24
25
|
# descends from (see StandardId::AuthLineage): a policy tightened after a
|
|
@@ -53,7 +54,7 @@ module StandardId
|
|
|
53
54
|
FLOWS = %i[
|
|
54
55
|
web_password web_signup web_passwordless web_social web_remember_me web_session
|
|
55
56
|
oauth_password_grant oauth_passwordless_otp_grant oauth_social_callback oauth_refresh_token
|
|
56
|
-
api_device_session api_service_session
|
|
57
|
+
api_device_session api_service_session api_email_password
|
|
57
58
|
].freeze
|
|
58
59
|
|
|
59
60
|
module_function
|
data/lib/standard_id/version.rb
CHANGED
data/lib/standard_id.rb
CHANGED
|
@@ -77,6 +77,14 @@ require "standard_id/authorization_bypass"
|
|
|
77
77
|
require "standard_id/utils/ip_normalizer"
|
|
78
78
|
require "standard_id/rate_limit_store"
|
|
79
79
|
require "standard_id/rate_limiter"
|
|
80
|
+
require "standard_id/access_revocation"
|
|
81
|
+
require "standard_id/email_password"
|
|
82
|
+
require "standard_id/email_password/daily_limit"
|
|
83
|
+
require "standard_id/email_password/verification"
|
|
84
|
+
require "standard_id/email_password/registration"
|
|
85
|
+
require "standard_id/email_password/authentication"
|
|
86
|
+
require "standard_id/email_password/password_reset"
|
|
87
|
+
require "standard_id/email_password/token_flow"
|
|
80
88
|
|
|
81
89
|
require "concurrent/delay"
|
|
82
90
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: standard_id
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.55.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jaryl Sim
|
|
@@ -132,6 +132,7 @@ files:
|
|
|
132
132
|
- app/controllers/concerns/standard_id/web_mechanism_gate.rb
|
|
133
133
|
- app/controllers/standard_id/api/authorization_controller.rb
|
|
134
134
|
- app/controllers/standard_id/api/base_controller.rb
|
|
135
|
+
- app/controllers/standard_id/api/email_password_controller.rb
|
|
135
136
|
- app/controllers/standard_id/api/oauth/base_controller.rb
|
|
136
137
|
- app/controllers/standard_id/api/oauth/callback/providers_controller.rb
|
|
137
138
|
- app/controllers/standard_id/api/oauth/introspections_controller.rb
|
|
@@ -175,7 +176,9 @@ files:
|
|
|
175
176
|
- app/jobs/standard_id/cleanup_expired_sessions_job.rb
|
|
176
177
|
- app/jobs/standard_id/password_reset_delivery_job.rb
|
|
177
178
|
- app/jobs/standard_id/purge_stale_dynamic_clients_job.rb
|
|
179
|
+
- app/jobs/standard_id/purge_unverified_email_signups_job.rb
|
|
178
180
|
- app/jobs/standard_id/secure_mail_delivery_job.rb
|
|
181
|
+
- app/mailers/standard_id/account_notice_mailer.rb
|
|
179
182
|
- app/mailers/standard_id/application_mailer.rb
|
|
180
183
|
- app/mailers/standard_id/password_reset_mailer.rb
|
|
181
184
|
- app/mailers/standard_id/passwordless_mailer.rb
|
|
@@ -200,6 +203,8 @@ files:
|
|
|
200
203
|
- app/models/standard_id/session.rb
|
|
201
204
|
- app/models/standard_id/social_identity.rb
|
|
202
205
|
- app/models/standard_id/username_identifier.rb
|
|
206
|
+
- app/views/standard_id/account_notice_mailer/existing_account.html.erb
|
|
207
|
+
- app/views/standard_id/account_notice_mailer/existing_account.text.erb
|
|
203
208
|
- app/views/standard_id/password_reset_mailer/reset_email.html.erb
|
|
204
209
|
- app/views/standard_id/password_reset_mailer/reset_email.text.erb
|
|
205
210
|
- app/views/standard_id/passwordless_mailer/otp_email.html.erb
|
|
@@ -243,10 +248,12 @@ files:
|
|
|
243
248
|
- db/migrate/20261004000000_add_auth_method_to_standard_id_refresh_tokens.rb
|
|
244
249
|
- db/migrate/20261020000000_add_client_application_id_to_standard_id_refresh_tokens.rb
|
|
245
250
|
- db/migrate/20261020120000_add_registration_access_token_digest_to_standard_id_client_applications.rb
|
|
251
|
+
- db/migrate/20261022000000_add_signup_source_to_standard_id_identifiers.rb
|
|
246
252
|
- lib/generators/standard_id/install/install_generator.rb
|
|
247
253
|
- lib/generators/standard_id/install/templates/standard_id.rb
|
|
248
254
|
- lib/generators/standard_id/install/templates/standard_id_reference.rb
|
|
249
255
|
- lib/standard_id.rb
|
|
256
|
+
- lib/standard_id/access_revocation.rb
|
|
250
257
|
- lib/standard_id/account_cleanup.rb
|
|
251
258
|
- lib/standard_id/account_locking.rb
|
|
252
259
|
- lib/standard_id/account_status.rb
|
|
@@ -267,6 +274,13 @@ files:
|
|
|
267
274
|
- lib/standard_id/config_schema.rb
|
|
268
275
|
- lib/standard_id/current_attributes.rb
|
|
269
276
|
- lib/standard_id/deprecator.rb
|
|
277
|
+
- lib/standard_id/email_password.rb
|
|
278
|
+
- lib/standard_id/email_password/authentication.rb
|
|
279
|
+
- lib/standard_id/email_password/daily_limit.rb
|
|
280
|
+
- lib/standard_id/email_password/password_reset.rb
|
|
281
|
+
- lib/standard_id/email_password/registration.rb
|
|
282
|
+
- lib/standard_id/email_password/token_flow.rb
|
|
283
|
+
- lib/standard_id/email_password/verification.rb
|
|
270
284
|
- lib/standard_id/engine.rb
|
|
271
285
|
- lib/standard_id/errors.rb
|
|
272
286
|
- lib/standard_id/events.rb
|