standard_id 0.8.1 → 0.10.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/app/controllers/concerns/standard_id/inertia_rendering.rb +2 -1
- data/app/controllers/concerns/standard_id/rate_limit_handling.rb +27 -0
- data/app/controllers/standard_id/api/base_controller.rb +2 -0
- data/app/controllers/standard_id/api/oauth/tokens_controller.rb +6 -0
- data/app/controllers/standard_id/api/passwordless_controller.rb +15 -0
- data/app/controllers/standard_id/web/base_controller.rb +1 -0
- data/app/controllers/standard_id/web/login_controller.rb +15 -0
- data/app/controllers/standard_id/web/login_verify_controller.rb +28 -4
- data/app/controllers/standard_id/web/verify_email/start_controller.rb +15 -0
- data/app/controllers/standard_id/web/verify_phone/start_controller.rb +15 -0
- data/app/forms/standard_id/web/reset_password_confirm_form.rb +1 -1
- data/app/forms/standard_id/web/signup_form.rb +2 -1
- data/app/mailers/standard_id/passwordless_mailer.rb +16 -0
- data/app/models/concerns/standard_id/password_strength.rb +31 -0
- data/app/models/standard_id/authorization_code.rb +29 -10
- data/app/models/standard_id/password_credential.rb +2 -1
- data/app/models/standard_id/refresh_token.rb +9 -14
- data/app/models/standard_id/session.rb +7 -5
- data/app/views/standard_id/passwordless_mailer/otp_email.html.erb +24 -0
- data/app/views/standard_id/passwordless_mailer/otp_email.text.erb +12 -0
- data/db/migrate/20260311100001_add_nullify_to_refresh_token_previous_token_fk.rb +7 -0
- data/lib/generators/standard_id/install/templates/standard_id.rb +5 -0
- data/lib/standard_id/config/schema.rb +43 -3
- data/lib/standard_id/engine.rb +1 -0
- data/lib/standard_id/events/subscribers/passwordless_delivery_subscriber.rb +37 -0
- data/lib/standard_id/jwt_service.rb +4 -3
- data/lib/standard_id/oauth/refresh_token_flow.rb +54 -24
- data/lib/standard_id/oauth/token_grant_flow.rb +17 -1
- data/lib/standard_id/passwordless/base_strategy.rb +34 -2
- data/lib/standard_id/passwordless/email_strategy.rb +7 -0
- data/lib/standard_id/passwordless/sms_strategy.rb +5 -0
- data/lib/standard_id/passwordless/verification_service.rb +56 -16
- data/lib/standard_id/passwordless.rb +56 -0
- data/lib/standard_id/rate_limit_store.rb +42 -0
- data/lib/standard_id/testing/factories/credentials.rb +2 -2
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id.rb +3 -0
- metadata +11 -2
- /data/db/migrate/{20260311000000_create_standard_id_refresh_tokens.rb → 20260311100000_create_standard_id_refresh_tokens.rb} +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7ad192cf3b1bad92d1ec8322e203804401d8c10e54ebfcde4340f7840fa624bd
|
|
4
|
+
data.tar.gz: 0eb0cc7c613bd3fdd981818c6d4fd359f809003da34d6e15f5e2fea9fd0eab74
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 478082fd5a80a66ded10834c7bd1db32912a222086f5fc8db521a90acceb1459418492581d89ca90d5a7d8fb438b3c74845372fe551d52eb892c297136acc002
|
|
7
|
+
data.tar.gz: bca63014a4ca013f152bb5bd9112f3649d0e7848d22b7d1114e9bd2715e2847369610e735b83f20bda3aad3fca45936d0f7452438f2a8144d24ddba7b3cb2c07
|
|
@@ -57,7 +57,8 @@ module StandardId
|
|
|
57
57
|
password_reset: web.password_reset,
|
|
58
58
|
email_verification: web.email_verification,
|
|
59
59
|
phone_verification: web.phone_verification,
|
|
60
|
-
sessions_management: web.sessions_management
|
|
60
|
+
sessions_management: web.sessions_management,
|
|
61
|
+
passwordless_registration: web.passwordless_registration
|
|
61
62
|
}
|
|
62
63
|
end
|
|
63
64
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
module RateLimitHandling
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
RATE_LIMIT_STORE = StandardId::RateLimitStore.new
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
rescue_from ActionController::TooManyRequests, with: :handle_rate_limited
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def handle_rate_limited(_exception)
|
|
14
|
+
response.set_header("Retry-After", 15.minutes.to_i.to_s)
|
|
15
|
+
|
|
16
|
+
if self.class.ancestors.include?(ActionController::API)
|
|
17
|
+
render json: {
|
|
18
|
+
error: "rate_limit_exceeded",
|
|
19
|
+
error_description: "Too many requests. Please try again later."
|
|
20
|
+
}, status: :too_many_requests
|
|
21
|
+
else
|
|
22
|
+
flash[:alert] = "Too many requests. Please try again later."
|
|
23
|
+
redirect_to request.referer || main_app.root_path, status: :see_other
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
module StandardId
|
|
2
2
|
module Api
|
|
3
3
|
class BaseController < ActionController::API
|
|
4
|
+
include ActionController::RateLimiting
|
|
4
5
|
include StandardId::ControllerPolicy
|
|
5
6
|
include StandardId::ApiAuthentication
|
|
6
7
|
include StandardId::SetCurrentRequestDetails
|
|
8
|
+
include StandardId::RateLimitHandling
|
|
7
9
|
|
|
8
10
|
before_action -> { Current.scope = :api if defined?(::Current) }
|
|
9
11
|
before_action :validate_content_type!
|
|
@@ -6,6 +6,12 @@ module StandardId
|
|
|
6
6
|
|
|
7
7
|
skip_before_action :validate_content_type!
|
|
8
8
|
|
|
9
|
+
# RAR-51/RAR-60: Rate limit token requests by IP (30 per 15 minutes)
|
|
10
|
+
rate_limit to: StandardId.config.rate_limits.api_token_per_ip,
|
|
11
|
+
within: 15.minutes,
|
|
12
|
+
only: :create,
|
|
13
|
+
store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
|
|
14
|
+
|
|
9
15
|
FLOW_STRATEGIES = {
|
|
10
16
|
"client_credentials" => StandardId::Oauth::ClientCredentialsFlow,
|
|
11
17
|
"authorization_code" => StandardId::Oauth::AuthorizationCodeFlow,
|
|
@@ -5,6 +5,21 @@ module StandardId
|
|
|
5
5
|
|
|
6
6
|
include StandardId::PasswordlessStrategy
|
|
7
7
|
|
|
8
|
+
# RAR-60: Rate limit OTP initiation by IP (10 per hour)
|
|
9
|
+
rate_limit to: StandardId.config.rate_limits.api_passwordless_start_per_ip,
|
|
10
|
+
within: 1.hour,
|
|
11
|
+
name: "passwordless-ip",
|
|
12
|
+
only: :start,
|
|
13
|
+
store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
|
|
14
|
+
|
|
15
|
+
# RAR-60: Rate limit OTP initiation by target (5 per 15 minutes)
|
|
16
|
+
rate_limit to: StandardId.config.rate_limits.api_passwordless_start_per_target,
|
|
17
|
+
within: 15.minutes,
|
|
18
|
+
by: -> { "api-passwordless:#{(params[:username] || params[:email] || params[:phone_number]).to_s.strip.downcase}" },
|
|
19
|
+
name: "passwordless-target",
|
|
20
|
+
only: :start,
|
|
21
|
+
store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
|
|
22
|
+
|
|
8
23
|
def start
|
|
9
24
|
raise StandardId::InvalidRequestError, "username, email, or phone_number parameter is required" if start_params[:username].blank?
|
|
10
25
|
|
|
@@ -5,6 +5,7 @@ module StandardId
|
|
|
5
5
|
include StandardId::WebAuthentication
|
|
6
6
|
include StandardId::SetCurrentRequestDetails
|
|
7
7
|
include StandardId::WebMechanismGate
|
|
8
|
+
include StandardId::RateLimitHandling
|
|
8
9
|
|
|
9
10
|
include StandardId::WebEngine.routes.url_helpers
|
|
10
11
|
helper StandardId::WebEngine.routes.url_helpers
|
|
@@ -10,6 +10,21 @@ module StandardId
|
|
|
10
10
|
|
|
11
11
|
layout "public"
|
|
12
12
|
|
|
13
|
+
# RAR-51: Rate limit login attempts by IP (20 per 15 minutes)
|
|
14
|
+
rate_limit to: StandardId.config.rate_limits.password_login_per_ip,
|
|
15
|
+
within: 15.minutes,
|
|
16
|
+
name: "login-ip",
|
|
17
|
+
only: :create,
|
|
18
|
+
store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
|
|
19
|
+
|
|
20
|
+
# RAR-51: Rate limit login attempts by email target (5 per 15 minutes)
|
|
21
|
+
rate_limit to: StandardId.config.rate_limits.password_login_per_email,
|
|
22
|
+
within: 15.minutes,
|
|
23
|
+
by: -> { "login-email:#{params.dig(:login, :email).to_s.strip.downcase}" },
|
|
24
|
+
name: "login-email",
|
|
25
|
+
only: :create,
|
|
26
|
+
store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
|
|
27
|
+
|
|
13
28
|
skip_before_action :require_browser_session!, only: [:show, :create]
|
|
14
29
|
|
|
15
30
|
before_action :redirect_if_authenticated, only: [:show]
|
|
@@ -9,6 +9,13 @@ module StandardId
|
|
|
9
9
|
|
|
10
10
|
layout "public"
|
|
11
11
|
|
|
12
|
+
# RAR-60: Rate limit OTP verification attempts by IP (20 per 15 minutes)
|
|
13
|
+
rate_limit to: StandardId.config.rate_limits.otp_verify_per_ip,
|
|
14
|
+
within: 15.minutes,
|
|
15
|
+
name: "otp-verify-ip",
|
|
16
|
+
only: :update,
|
|
17
|
+
store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
|
|
18
|
+
|
|
12
19
|
skip_before_action :require_browser_session!, only: [:show, :update]
|
|
13
20
|
before_action :redirect_if_authenticated, only: [:show]
|
|
14
21
|
before_action :require_otp_payload!
|
|
@@ -26,11 +33,12 @@ module StandardId
|
|
|
26
33
|
return
|
|
27
34
|
end
|
|
28
35
|
|
|
29
|
-
result = StandardId::Passwordless
|
|
30
|
-
connection: @otp_data[:connection],
|
|
36
|
+
result = StandardId::Passwordless.verify(
|
|
31
37
|
username: @otp_data[:username],
|
|
32
38
|
code: code,
|
|
33
|
-
|
|
39
|
+
connection: @otp_data[:connection],
|
|
40
|
+
request: request,
|
|
41
|
+
allow_registration: passwordless_registration_enabled?
|
|
34
42
|
)
|
|
35
43
|
|
|
36
44
|
unless result.success?
|
|
@@ -45,7 +53,10 @@ module StandardId
|
|
|
45
53
|
session_manager.sign_in_account(account)
|
|
46
54
|
emit_authentication_succeeded(account)
|
|
47
55
|
|
|
48
|
-
|
|
56
|
+
if newly_created
|
|
57
|
+
emit_passwordless_account_created(account)
|
|
58
|
+
invoke_after_account_created(account, { mechanism: "passwordless", provider: nil })
|
|
59
|
+
end
|
|
49
60
|
|
|
50
61
|
context = { connection: @otp_data[:connection], provider: nil }
|
|
51
62
|
redirect_override = invoke_after_sign_in(account, context)
|
|
@@ -81,6 +92,19 @@ module StandardId
|
|
|
81
92
|
end
|
|
82
93
|
end
|
|
83
94
|
|
|
95
|
+
def passwordless_registration_enabled?
|
|
96
|
+
StandardId.config.web.passwordless_registration
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def emit_passwordless_account_created(account)
|
|
100
|
+
StandardId::Events.publish(
|
|
101
|
+
StandardId::Events::PASSWORDLESS_ACCOUNT_CREATED,
|
|
102
|
+
account: account,
|
|
103
|
+
channel: @otp_data[:connection],
|
|
104
|
+
identifier: @otp_data[:username]
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
84
108
|
def emit_authentication_succeeded(account)
|
|
85
109
|
StandardId::Events.publish(
|
|
86
110
|
StandardId::Events::AUTHENTICATION_SUCCEEDED,
|
|
@@ -2,6 +2,21 @@ module StandardId
|
|
|
2
2
|
module Web
|
|
3
3
|
module VerifyEmail
|
|
4
4
|
class StartController < BaseController
|
|
5
|
+
# RAR-56: Rate limit verification code generation by IP (10 per hour)
|
|
6
|
+
rate_limit to: StandardId.config.rate_limits.verification_start_per_ip,
|
|
7
|
+
within: 1.hour,
|
|
8
|
+
name: "verify-email-ip",
|
|
9
|
+
only: :create,
|
|
10
|
+
store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
|
|
11
|
+
|
|
12
|
+
# RAR-56: Rate limit verification code generation by email target (3 per 15 minutes)
|
|
13
|
+
rate_limit to: StandardId.config.rate_limits.verification_start_per_target,
|
|
14
|
+
within: 15.minutes,
|
|
15
|
+
by: -> { "verify-email:#{params[:email].to_s.strip.downcase}" },
|
|
16
|
+
name: "verify-email-target",
|
|
17
|
+
only: :create,
|
|
18
|
+
store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
|
|
19
|
+
|
|
5
20
|
def show
|
|
6
21
|
render plain: "verify email start", status: :ok
|
|
7
22
|
end
|
|
@@ -2,6 +2,21 @@ module StandardId
|
|
|
2
2
|
module Web
|
|
3
3
|
module VerifyPhone
|
|
4
4
|
class StartController < BaseController
|
|
5
|
+
# RAR-56: Rate limit verification code generation by IP (10 per hour)
|
|
6
|
+
rate_limit to: StandardId.config.rate_limits.verification_start_per_ip,
|
|
7
|
+
within: 1.hour,
|
|
8
|
+
name: "verify-phone-ip",
|
|
9
|
+
only: :create,
|
|
10
|
+
store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
|
|
11
|
+
|
|
12
|
+
# RAR-56: Rate limit verification code generation by phone target (3 per 15 minutes)
|
|
13
|
+
rate_limit to: StandardId.config.rate_limits.verification_start_per_target,
|
|
14
|
+
within: 15.minutes,
|
|
15
|
+
by: -> { "verify-phone:#{params[:phone_number].to_s.strip}" },
|
|
16
|
+
name: "verify-phone-target",
|
|
17
|
+
only: :create,
|
|
18
|
+
store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
|
|
19
|
+
|
|
5
20
|
def show
|
|
6
21
|
render plain: "verify phone start", status: :ok
|
|
7
22
|
end
|
|
@@ -3,6 +3,7 @@ module StandardId
|
|
|
3
3
|
class ResetPasswordConfirmForm
|
|
4
4
|
include ActiveModel::Model
|
|
5
5
|
include ActiveModel::Attributes
|
|
6
|
+
include StandardId::PasswordStrength
|
|
6
7
|
|
|
7
8
|
attribute :password, :string
|
|
8
9
|
attribute :password_confirmation, :string
|
|
@@ -11,7 +12,6 @@ module StandardId
|
|
|
11
12
|
|
|
12
13
|
validates :password,
|
|
13
14
|
presence: { message: "cannot be blank" },
|
|
14
|
-
length: { minimum: 8, too_short: "must be at least 8 characters long" },
|
|
15
15
|
confirmation: { message: "confirmation doesn't match" }
|
|
16
16
|
|
|
17
17
|
def initialize(password_credential, params = {})
|
|
@@ -3,6 +3,7 @@ module StandardId
|
|
|
3
3
|
class SignupForm
|
|
4
4
|
include ActiveModel::Model
|
|
5
5
|
include ActiveModel::Attributes
|
|
6
|
+
include StandardId::PasswordStrength
|
|
6
7
|
|
|
7
8
|
attribute :email, :string
|
|
8
9
|
attribute :password, :string
|
|
@@ -11,7 +12,7 @@ module StandardId
|
|
|
11
12
|
attr_reader :account
|
|
12
13
|
|
|
13
14
|
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
14
|
-
validates :password, presence: true,
|
|
15
|
+
validates :password, presence: true, confirmation: true
|
|
15
16
|
|
|
16
17
|
def submit
|
|
17
18
|
return false unless valid?
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
class PasswordlessMailer < ApplicationMailer
|
|
3
|
+
layout false
|
|
4
|
+
|
|
5
|
+
def otp_email
|
|
6
|
+
@otp_code = params[:otp_code]
|
|
7
|
+
@email = params[:email]
|
|
8
|
+
|
|
9
|
+
mail(
|
|
10
|
+
to: @email,
|
|
11
|
+
from: StandardId.config.passwordless.mailer_from,
|
|
12
|
+
subject: StandardId.config.passwordless.mailer_subject
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
module PasswordStrength
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
included do
|
|
6
|
+
validate :password_meets_strength_requirements, if: -> { password.present? }
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def password_meets_strength_requirements
|
|
12
|
+
config = StandardId.config.password
|
|
13
|
+
|
|
14
|
+
if password.length < config.minimum_length
|
|
15
|
+
errors.add(:password, "must be at least #{config.minimum_length} characters long")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
if config.require_uppercase && password !~ /[A-Z]/
|
|
19
|
+
errors.add(:password, "must include at least one uppercase letter")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
if config.require_numbers && password !~ /\d/
|
|
23
|
+
errors.add(:password, "must include at least one number")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
if config.require_special_chars && password !~ /[^a-zA-Z0-9]/
|
|
27
|
+
errors.add(:password, "must include at least one special character")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -17,6 +17,20 @@ module StandardId
|
|
|
17
17
|
before_validation :set_issued_and_expiry, on: :create
|
|
18
18
|
|
|
19
19
|
def self.issue!(plaintext_code:, client_id:, redirect_uri:, scope: nil, audience: nil, account: nil, code_challenge: nil, code_challenge_method: nil, nonce: nil, metadata: {})
|
|
20
|
+
# Fail fast: reject unsupported PKCE methods at issuance rather than
|
|
21
|
+
# storing a code that will always fail at redemption time.
|
|
22
|
+
if code_challenge.present?
|
|
23
|
+
unless code_challenge_method.to_s.downcase == "s256"
|
|
24
|
+
raise StandardId::InvalidRequestError, "Unsupported code_challenge_method: only S256 is allowed"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Hash the code_challenge for defense-in-depth (RAR-58).
|
|
29
|
+
# The stored value is SHA256(S256_challenge), where S256_challenge is
|
|
30
|
+
# base64url(SHA256(verifier)). This is intentionally a double-hash:
|
|
31
|
+
# S256 derives the challenge from the verifier, and we hash again for storage.
|
|
32
|
+
hashed_challenge = code_challenge.present? ? Digest::SHA256.hexdigest(code_challenge) : nil
|
|
33
|
+
|
|
20
34
|
create!(
|
|
21
35
|
account: account,
|
|
22
36
|
code_hash: hash_for(plaintext_code),
|
|
@@ -24,7 +38,7 @@ module StandardId
|
|
|
24
38
|
redirect_uri: redirect_uri,
|
|
25
39
|
scope: scope,
|
|
26
40
|
audience: audience,
|
|
27
|
-
code_challenge:
|
|
41
|
+
code_challenge: hashed_challenge,
|
|
28
42
|
code_challenge_method: code_challenge_method,
|
|
29
43
|
nonce: nonce,
|
|
30
44
|
issued_at: Time.current,
|
|
@@ -58,15 +72,20 @@ module StandardId
|
|
|
58
72
|
|
|
59
73
|
return false if code_verifier.blank?
|
|
60
74
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
75
|
+
# Only S256 is supported (OAuth 2.1). The "plain" method is rejected
|
|
76
|
+
# because it transmits the verifier in cleartext, defeating PKCE's purpose.
|
|
77
|
+
return false unless (code_challenge_method || "").downcase == "s256"
|
|
78
|
+
|
|
79
|
+
s256_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier)).delete("=")
|
|
80
|
+
|
|
81
|
+
# New format: stored value is SHA256(S256_challenge)
|
|
82
|
+
hashed_expected = Digest::SHA256.hexdigest(s256_challenge)
|
|
83
|
+
return true if ActiveSupport::SecurityUtils.secure_compare(hashed_expected, code_challenge)
|
|
84
|
+
|
|
85
|
+
# Legacy fallback: codes issued before RAR-58 store the raw S256 challenge.
|
|
86
|
+
# This handles in-flight codes during deployment (max 10-minute TTL).
|
|
87
|
+
# Safe to remove after one deployment cycle.
|
|
88
|
+
ActiveSupport::SecurityUtils.secure_compare(s256_challenge, code_challenge)
|
|
70
89
|
end
|
|
71
90
|
|
|
72
91
|
def mark_as_used!
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
module StandardId
|
|
2
2
|
class PasswordCredential < ApplicationRecord
|
|
3
3
|
include StandardId::Credentiable
|
|
4
|
+
include StandardId::PasswordStrength
|
|
4
5
|
|
|
5
6
|
has_secure_password
|
|
6
7
|
|
|
@@ -13,7 +14,7 @@ module StandardId
|
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
validates :login, presence: true, uniqueness: true
|
|
16
|
-
validates :password,
|
|
17
|
+
validates :password, confirmation: true, if: :validate_password?
|
|
17
18
|
|
|
18
19
|
private
|
|
19
20
|
|
|
@@ -34,7 +34,8 @@ module StandardId
|
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
def revoke!
|
|
37
|
-
|
|
37
|
+
rows = self.class.where(id: id, revoked_at: nil).update_all(revoked_at: Time.current)
|
|
38
|
+
reload if rows > 0
|
|
38
39
|
end
|
|
39
40
|
|
|
40
41
|
# Revoke this token and all tokens in the same family chain.
|
|
@@ -45,22 +46,18 @@ module StandardId
|
|
|
45
46
|
family_tokens.where(revoked_at: nil).update_all(revoked_at: Time.current)
|
|
46
47
|
end
|
|
47
48
|
|
|
48
|
-
# Max depth guard to prevent unbounded traversal in case of
|
|
49
|
-
# corrupted data or extremely long-lived token chains.
|
|
50
|
-
MAX_FAMILY_DEPTH = 50
|
|
51
|
-
|
|
52
49
|
private
|
|
53
50
|
|
|
54
51
|
# Find the root of this token's family and return all descendants.
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
# would collapse to a single query if performance becomes a concern.
|
|
52
|
+
# Backward traversal uses a visited set for cycle detection in case
|
|
53
|
+
# of corrupted data. Forward traversal collects all descendants.
|
|
58
54
|
def family_tokens
|
|
59
55
|
root = self
|
|
60
|
-
|
|
61
|
-
while root.previous_token.present?
|
|
56
|
+
visited = Set.new([root.id])
|
|
57
|
+
while root.previous_token.present?
|
|
58
|
+
break if visited.include?(root.previous_token_id)
|
|
59
|
+
visited.add(root.previous_token_id)
|
|
62
60
|
root = root.previous_token
|
|
63
|
-
depth += 1
|
|
64
61
|
end
|
|
65
62
|
|
|
66
63
|
self.class.where(id: collect_family_ids(root.id))
|
|
@@ -69,15 +66,13 @@ module StandardId
|
|
|
69
66
|
def collect_family_ids(root_id)
|
|
70
67
|
ids = [root_id]
|
|
71
68
|
current_ids = [root_id]
|
|
72
|
-
depth = 0
|
|
73
69
|
|
|
74
|
-
|
|
70
|
+
loop do
|
|
75
71
|
next_ids = self.class.where(previous_token_id: current_ids).pluck(:id)
|
|
76
72
|
break if next_ids.empty?
|
|
77
73
|
|
|
78
74
|
ids.concat(next_ids)
|
|
79
75
|
current_ids = next_ids
|
|
80
|
-
depth += 1
|
|
81
76
|
end
|
|
82
77
|
|
|
83
78
|
ids
|
|
@@ -7,7 +7,7 @@ module StandardId
|
|
|
7
7
|
belongs_to :account, class_name: StandardId.config.account_class_name
|
|
8
8
|
has_many :refresh_tokens, class_name: "StandardId::RefreshToken", dependent: :nullify
|
|
9
9
|
|
|
10
|
-
before_destroy :revoke_active_refresh_tokens
|
|
10
|
+
before_destroy :revoke_active_refresh_tokens, prepend: true
|
|
11
11
|
|
|
12
12
|
scope :active, -> { where(revoked_at: nil).where("expires_at > ?", Time.current) }
|
|
13
13
|
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
|
@@ -38,10 +38,12 @@ module StandardId
|
|
|
38
38
|
|
|
39
39
|
def revoke!(reason: nil)
|
|
40
40
|
@reason = reason
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
transaction do
|
|
42
|
+
update!(revoked_at: Time.current)
|
|
43
|
+
# Cascade revocation to refresh tokens. Uses update_all for efficiency;
|
|
44
|
+
# intentionally skips updated_at since revocation is tracked via revoked_at.
|
|
45
|
+
refresh_tokens.active.update_all(revoked_at: Time.current)
|
|
46
|
+
end
|
|
45
47
|
end
|
|
46
48
|
|
|
47
49
|
private
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<style>
|
|
6
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background-color: #f5f5f5; margin: 0; padding: 0; }
|
|
7
|
+
.container { max-width: 480px; margin: 40px auto; background-color: #ffffff; border-radius: 8px; padding: 40px; }
|
|
8
|
+
.code { font-size: 32px; font-weight: bold; letter-spacing: 8px; text-align: center; padding: 20px; background-color: #f0f0f0; border-radius: 6px; margin: 24px 0; font-family: monospace; }
|
|
9
|
+
.footer { margin-top: 32px; font-size: 13px; color: #888888; text-align: center; }
|
|
10
|
+
</style>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<div class="container">
|
|
14
|
+
<p>Hi,</p>
|
|
15
|
+
<p>Use the following code to sign in:</p>
|
|
16
|
+
<div class="code"><%= @otp_code %></div>
|
|
17
|
+
<p>This code will expire in <%= StandardId.config.passwordless.code_ttl / 60 %> minutes.</p>
|
|
18
|
+
<p>If you did not request this code, you can safely ignore this email.</p>
|
|
19
|
+
<div class="footer">
|
|
20
|
+
<p>This is an automated message. Please do not reply.</p>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
</body>
|
|
24
|
+
</html>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Hi,
|
|
2
|
+
|
|
3
|
+
Use the following code to sign in:
|
|
4
|
+
|
|
5
|
+
<%= @otp_code %>
|
|
6
|
+
|
|
7
|
+
This code will expire in <%= StandardId.config.passwordless.code_ttl / 60 %> minutes.
|
|
8
|
+
|
|
9
|
+
If you did not request this code, you can safely ignore this email.
|
|
10
|
+
|
|
11
|
+
--
|
|
12
|
+
This is an automated message. Please do not reply.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
class AddNullifyToRefreshTokenPreviousTokenFk < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
remove_foreign_key :standard_id_refresh_tokens, column: :previous_token_id
|
|
4
|
+
add_foreign_key :standard_id_refresh_tokens, :standard_id_refresh_tokens,
|
|
5
|
+
column: :previous_token_id, on_delete: :nullify
|
|
6
|
+
end
|
|
7
|
+
end
|
|
@@ -54,6 +54,11 @@ StandardId.configure do |c|
|
|
|
54
54
|
# }
|
|
55
55
|
# }
|
|
56
56
|
# c.oauth.allowed_audiences = %w[web mobile admin] # Empty = no validation
|
|
57
|
+
#
|
|
58
|
+
# Custom claims added to every access token (independent of scopes).
|
|
59
|
+
# Receives keyword arguments: account:, client:, request:, audience:
|
|
60
|
+
# Must return a Hash. Reserved JWT keys (sub, exp, iat, etc.) are excluded.
|
|
61
|
+
# c.oauth.custom_claims = ->(account:, **) { { channel_id: account.channel_id } }
|
|
57
62
|
|
|
58
63
|
# JWT Signing Configuration (Asymmetric Algorithms)
|
|
59
64
|
# By default, JWTs are signed with HS256 using Rails.application.secret_key_base.
|
|
@@ -50,13 +50,27 @@ StandardConfig.schema.draw do
|
|
|
50
50
|
field :max_attempts, type: :integer, default: 3
|
|
51
51
|
field :retry_delay, type: :integer, default: 30 # 30 seconds
|
|
52
52
|
field :bypass_code, type: :string, default: nil # E2E testing only — NEVER set in production
|
|
53
|
+
|
|
54
|
+
# Custom account factory for passwordless registration.
|
|
55
|
+
# When set, replaces the default find_or_create_account! logic in strategies.
|
|
56
|
+
# Must be a callable (lambda/proc) that receives (identifier:, params:, request:)
|
|
57
|
+
# and returns an Account (or account-like) record.
|
|
58
|
+
# When nil (default), uses the built-in strategy behavior.
|
|
59
|
+
field :account_factory, type: :any, default: nil
|
|
60
|
+
|
|
61
|
+
# OTP email delivery mode:
|
|
62
|
+
# :custom — (default) host app handles delivery via event subscriber
|
|
63
|
+
# :built_in — engine sends OTP emails automatically using PasswordlessMailer
|
|
64
|
+
field :delivery, type: :symbol, default: :custom
|
|
65
|
+
field :mailer_from, type: :string, default: "noreply@example.com"
|
|
66
|
+
field :mailer_subject, type: :string, default: "Your sign-in code"
|
|
53
67
|
end
|
|
54
68
|
|
|
55
69
|
scope :password do
|
|
56
70
|
field :minimum_length, type: :integer, default: 8
|
|
57
|
-
field :require_special_chars, type: :boolean, default:
|
|
58
|
-
field :require_uppercase, type: :boolean, default:
|
|
59
|
-
field :require_numbers, type: :boolean, default:
|
|
71
|
+
field :require_special_chars, type: :boolean, default: true
|
|
72
|
+
field :require_uppercase, type: :boolean, default: true
|
|
73
|
+
field :require_numbers, type: :boolean, default: true
|
|
60
74
|
end
|
|
61
75
|
|
|
62
76
|
scope :session do
|
|
@@ -90,6 +104,12 @@ StandardConfig.schema.draw do
|
|
|
90
104
|
# Asymmetric (RSA): :rs256, :rs384, :rs512
|
|
91
105
|
# Asymmetric (ECDSA): :es256, :es384, :es512
|
|
92
106
|
field :signing_algorithm, type: :symbol, default: :hs256
|
|
107
|
+
|
|
108
|
+
# Custom claims callable for encoding additional claims into JWT access tokens.
|
|
109
|
+
# Receives keyword arguments: account:, client:, request:, audience:
|
|
110
|
+
# Must return a Hash of custom claims to merge into the JWT payload.
|
|
111
|
+
# Example: ->(account:, **) { { channel_id: account.channel_id } }
|
|
112
|
+
field :custom_claims, type: :any, default: nil
|
|
93
113
|
end
|
|
94
114
|
|
|
95
115
|
scope :social do
|
|
@@ -108,5 +128,25 @@ StandardConfig.schema.draw do
|
|
|
108
128
|
field :email_verification, type: :boolean, default: true
|
|
109
129
|
field :phone_verification, type: :boolean, default: true
|
|
110
130
|
field :sessions_management, type: :boolean, default: true
|
|
131
|
+
field :passwordless_registration, type: :boolean, default: false
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Rate limiting defaults (used by Rails 8 built-in rate_limit DSL)
|
|
135
|
+
scope :rate_limits do
|
|
136
|
+
# RAR-51: Password login
|
|
137
|
+
field :password_login_per_ip, type: :integer, default: 20 # per 15 minutes
|
|
138
|
+
field :password_login_per_email, type: :integer, default: 5 # per 15 minutes
|
|
139
|
+
|
|
140
|
+
# RAR-60: OTP verification
|
|
141
|
+
field :otp_verify_per_ip, type: :integer, default: 20 # per 15 minutes
|
|
142
|
+
|
|
143
|
+
# RAR-56: Email/phone verification code generation
|
|
144
|
+
field :verification_start_per_target, type: :integer, default: 3 # per 15 minutes
|
|
145
|
+
field :verification_start_per_ip, type: :integer, default: 10 # per hour
|
|
146
|
+
|
|
147
|
+
# API equivalents
|
|
148
|
+
field :api_passwordless_start_per_ip, type: :integer, default: 10 # per hour
|
|
149
|
+
field :api_passwordless_start_per_target, type: :integer, default: 5 # per 15 minutes
|
|
150
|
+
field :api_token_per_ip, type: :integer, default: 30 # per 15 minutes
|
|
111
151
|
end
|
|
112
152
|
end
|
data/lib/standard_id/engine.rb
CHANGED
|
@@ -23,6 +23,7 @@ module StandardId
|
|
|
23
23
|
|
|
24
24
|
StandardId::Events::Subscribers::AccountStatusSubscriber.attach
|
|
25
25
|
StandardId::Events::Subscribers::AccountLockingSubscriber.attach
|
|
26
|
+
StandardId::Events::Subscribers::PasswordlessDeliverySubscriber.attach
|
|
26
27
|
|
|
27
28
|
if StandardId.config.issuer.blank?
|
|
28
29
|
Rails.logger.warn("[StandardId] No issuer configured. JWT tokens will not include or verify the 'iss' claim. " \
|