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,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
|
|
@@ -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
|