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,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
@@ -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
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.54.0"
2
+ VERSION = "0.55.0"
3
3
  end
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.54.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