better_auth 0.10.0 → 0.11.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 +83 -0
- data/LICENSE.md +1 -1
- data/README.md +138 -91
- data/lib/better_auth/adapters/base.rb +64 -1
- data/lib/better_auth/adapters/internal_adapter.rb +197 -11
- data/lib/better_auth/adapters/join_support.rb +10 -0
- data/lib/better_auth/adapters/memory.rb +191 -56
- data/lib/better_auth/adapters/mssql.rb +7 -3
- data/lib/better_auth/adapters/sql.rb +294 -49
- data/lib/better_auth/api.rb +3 -24
- data/lib/better_auth/configuration.rb +41 -46
- data/lib/better_auth/context.rb +43 -67
- data/lib/better_auth/cookies.rb +110 -14
- data/lib/better_auth/core.rb +0 -2
- data/lib/better_auth/crypto.rb +4 -3
- data/lib/better_auth/doctor.rb +14 -3
- data/lib/better_auth/error.rb +3 -1
- data/lib/better_auth/logger.rb +1 -1
- data/lib/better_auth/middleware/origin_check.rb +9 -1
- data/lib/better_auth/migration_plan.rb +4 -4
- data/lib/better_auth/oauth_state.rb +147 -0
- data/lib/better_auth/plugin_loader.rb +220 -0
- data/lib/better_auth/plugins/access.rb +10 -13
- data/lib/better_auth/plugins/admin.rb +79 -28
- data/lib/better_auth/plugins/api_key.rb +4 -7
- data/lib/better_auth/plugins/bearer.rb +8 -22
- data/lib/better_auth/plugins/captcha.rb +24 -6
- data/lib/better_auth/plugins/device_authorization.rb +47 -9
- data/lib/better_auth/plugins/dub.rb +2 -2
- data/lib/better_auth/plugins/email_otp.rb +30 -47
- data/lib/better_auth/plugins/generic_oauth.rb +141 -146
- data/lib/better_auth/plugins/i18n.rb +215 -0
- data/lib/better_auth/plugins/jwt.rb +37 -19
- data/lib/better_auth/plugins/magic_link.rb +18 -21
- data/lib/better_auth/plugins/oauth_popup.rb +330 -0
- data/lib/better_auth/plugins/oauth_protocol.rb +142 -32
- data/lib/better_auth/plugins/oauth_provider.rb +4 -7
- data/lib/better_auth/plugins/oauth_proxy.rb +1 -1
- data/lib/better_auth/plugins/one_tap.rb +61 -60
- data/lib/better_auth/plugins/one_time_token.rb +1 -4
- data/lib/better_auth/plugins/open_api.rb +35 -7
- data/lib/better_auth/plugins/organization.rb +455 -122
- data/lib/better_auth/plugins/passkey.rb +4 -7
- data/lib/better_auth/plugins/phone_number.rb +24 -26
- data/lib/better_auth/plugins/scim.rb +4 -7
- data/lib/better_auth/plugins/siwe.rb +126 -16
- data/lib/better_auth/plugins/sso.rb +4 -7
- data/lib/better_auth/plugins/stripe.rb +4 -7
- data/lib/better_auth/plugins/two_factor.rb +186 -21
- data/lib/better_auth/plugins/username.rb +56 -13
- data/lib/better_auth/plugins.rb +57 -0
- data/lib/better_auth/rate_limiter.rb +297 -135
- data/lib/better_auth/request_ip.rb +123 -13
- data/lib/better_auth/router.rb +3 -3
- data/lib/better_auth/routes/account.rb +40 -8
- data/lib/better_auth/routes/email_verification.rb +88 -5
- data/lib/better_auth/routes/password.rb +5 -6
- data/lib/better_auth/routes/session.rb +7 -2
- data/lib/better_auth/routes/sign_in.rb +10 -1
- data/lib/better_auth/routes/sign_up.rb +45 -22
- data/lib/better_auth/routes/social.rb +169 -54
- data/lib/better_auth/routes/user.rb +11 -8
- data/lib/better_auth/schema/sql.rb +90 -24
- data/lib/better_auth/schema.rb +124 -3
- data/lib/better_auth/session.rb +27 -9
- data/lib/better_auth/session_store.rb +1 -1
- data/lib/better_auth/social_providers/apple.rb +6 -1
- data/lib/better_auth/social_providers/atlassian.rb +1 -0
- data/lib/better_auth/social_providers/base.rb +41 -14
- data/lib/better_auth/social_providers/cognito.rb +75 -5
- data/lib/better_auth/social_providers/facebook.rb +95 -1
- data/lib/better_auth/social_providers/figma.rb +2 -0
- data/lib/better_auth/social_providers/google.rb +12 -1
- data/lib/better_auth/social_providers/line.rb +35 -1
- data/lib/better_auth/social_providers/linear.rb +1 -0
- data/lib/better_auth/social_providers/microsoft_entra_id.rb +9 -0
- data/lib/better_auth/social_providers/naver.rb +24 -1
- data/lib/better_auth/social_providers/notion.rb +1 -0
- data/lib/better_auth/social_providers/paybin.rb +25 -1
- data/lib/better_auth/social_providers/paypal.rb +118 -2
- data/lib/better_auth/social_providers/reddit.rb +25 -4
- data/lib/better_auth/social_providers/salesforce.rb +1 -0
- data/lib/better_auth/social_providers/twitch.rb +24 -1
- data/lib/better_auth/social_providers/twitter.rb +36 -1
- data/lib/better_auth/social_providers/vk.rb +34 -1
- data/lib/better_auth/social_providers/wechat.rb +6 -4
- data/lib/better_auth/sql_migration.rb +5 -5
- data/lib/better_auth/version.rb +1 -1
- data/lib/better_auth.rb +11 -41
- metadata +5 -13
- data/lib/better_auth/plugins/mcp/authorization.rb +0 -111
- data/lib/better_auth/plugins/mcp/config.rb +0 -51
- data/lib/better_auth/plugins/mcp/consent.rb +0 -31
- data/lib/better_auth/plugins/mcp/legacy_aliases.rb +0 -43
- data/lib/better_auth/plugins/mcp/metadata.rb +0 -81
- data/lib/better_auth/plugins/mcp/registration.rb +0 -31
- data/lib/better_auth/plugins/mcp/resource_handler.rb +0 -37
- data/lib/better_auth/plugins/mcp/schema.rb +0 -91
- data/lib/better_auth/plugins/mcp/token.rb +0 -108
- data/lib/better_auth/plugins/mcp/userinfo.rb +0 -37
- data/lib/better_auth/plugins/mcp.rb +0 -256
- data/lib/better_auth/plugins/oidc_provider.rb +0 -769
|
@@ -4,11 +4,18 @@ require "json"
|
|
|
4
4
|
|
|
5
5
|
module BetterAuth
|
|
6
6
|
class RateLimiter
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
MISSING_CLIENT_IP = "no-trusted-ip"
|
|
8
|
+
MEMORY_STORE_MAX_ENTRIES = 100_000
|
|
9
|
+
DATABASE_CONSUME_ATTEMPTS = 8
|
|
10
|
+
LEGACY_STORAGE_WARNING = "Rate limiting is best-effort: the configured storage has no atomic `consume`, so concurrent requests may bypass the limit. Provide a storage that implements `consume` for strict enforcement."
|
|
11
|
+
MISSING_CLIENT_IP_WARNING = "Rate limiting could not determine a trustworthy client IP address and is falling back to a " \
|
|
12
|
+
"single shared per-path bucket. Ensure your runtime forwards a trusted client IP header, then configure " \
|
|
13
|
+
"`advanced.ip_address.ip_address_headers` and, for proxy chains, `advanced.ip_address.trusted_proxies`."
|
|
9
14
|
|
|
10
15
|
class MemoryStore
|
|
11
|
-
def initialize
|
|
16
|
+
def initialize(clock: -> { Time.now.to_f }, max_entries: MEMORY_STORE_MAX_ENTRIES)
|
|
17
|
+
@clock = clock
|
|
18
|
+
@max_entries = max_entries
|
|
12
19
|
@entries = {}
|
|
13
20
|
@mutex = Mutex.new
|
|
14
21
|
end
|
|
@@ -18,7 +25,7 @@ module BetterAuth
|
|
|
18
25
|
entry = @entries[key]
|
|
19
26
|
return nil unless entry
|
|
20
27
|
|
|
21
|
-
if
|
|
28
|
+
if clock.call > entry[:expires_at]
|
|
22
29
|
@entries.delete(key)
|
|
23
30
|
return nil
|
|
24
31
|
end
|
|
@@ -29,193 +36,342 @@ module BetterAuth
|
|
|
29
36
|
|
|
30
37
|
def set(key, value, ttl:, update: false)
|
|
31
38
|
@mutex.synchronize do
|
|
32
|
-
@entries
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
39
|
+
@entries.delete(key)
|
|
40
|
+
@entries[key] = {data: value, expires_at: clock.call + ttl.to_f}
|
|
41
|
+
prune!(sweep: @entries.size > @max_entries)
|
|
36
42
|
end
|
|
37
43
|
end
|
|
44
|
+
|
|
45
|
+
def consume(key, window:, max:)
|
|
46
|
+
@mutex.synchronize do
|
|
47
|
+
now = clock.call
|
|
48
|
+
prune!(now, sweep: true)
|
|
49
|
+
entry = @entries[key]
|
|
50
|
+
data = entry[:data] if entry && now <= entry[:expires_at]
|
|
51
|
+
decision = RateLimiter.decide_consume(data, window: window, max: max, now: now)
|
|
52
|
+
if decision[:allowed]
|
|
53
|
+
@entries.delete(key)
|
|
54
|
+
@entries[key] = {data: decision.fetch(:next).merge(key: key), expires_at: now + window.to_f}
|
|
55
|
+
prune!(now)
|
|
56
|
+
end
|
|
57
|
+
decision.slice(:allowed, :retry_after)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def size
|
|
62
|
+
@mutex.synchronize { @entries.size }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
attr_reader :clock
|
|
68
|
+
|
|
69
|
+
def prune!(now = clock.call, sweep: false)
|
|
70
|
+
@entries.delete_if { |_key, entry| now > entry[:expires_at] } if sweep
|
|
71
|
+
overflow = @entries.size - @max_entries
|
|
72
|
+
overflow.times { @entries.delete(@entries.first.first) } if overflow.positive?
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.decide_consume(data, window:, max:, now:)
|
|
77
|
+
return {next: {key: "", count: 1, last_request: now}, allowed: true, retry_after: nil} unless data
|
|
78
|
+
|
|
79
|
+
last_request = data.fetch(:last_request).to_f
|
|
80
|
+
if now - last_request > window.to_f
|
|
81
|
+
return {next: data.merge(count: 1, last_request: now), allowed: true, retry_after: nil}
|
|
82
|
+
end
|
|
83
|
+
if data.fetch(:count).to_i >= max.to_i
|
|
84
|
+
return {
|
|
85
|
+
next: data,
|
|
86
|
+
allowed: false,
|
|
87
|
+
retry_after: [(last_request + window.to_f - now).ceil, 0].max
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
{
|
|
92
|
+
next: data.merge(count: data.fetch(:count).to_i + 1, last_request: now),
|
|
93
|
+
allowed: true,
|
|
94
|
+
retry_after: nil
|
|
95
|
+
}
|
|
38
96
|
end
|
|
39
97
|
|
|
40
|
-
def initialize
|
|
41
|
-
@
|
|
98
|
+
def initialize(clock: -> { Time.now.to_f }, memory_store: nil)
|
|
99
|
+
@clock = clock
|
|
100
|
+
@memory_store = memory_store || MemoryStore.new(clock: clock)
|
|
42
101
|
@warned_missing_client_ip = false
|
|
102
|
+
@warned_legacy_storage = false
|
|
103
|
+
@warning_mutex = Mutex.new
|
|
43
104
|
end
|
|
44
105
|
|
|
45
106
|
def call(request, context, path)
|
|
46
107
|
config = context.rate_limit_config || {}
|
|
47
108
|
return unless config[:enabled]
|
|
109
|
+
return if context.options.advanced.dig(:ip_address, :disable_ip_tracking)
|
|
48
110
|
|
|
49
111
|
ip = client_ip(request, context.options)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
112
|
+
unless ip
|
|
113
|
+
warn_missing_client_ip(context)
|
|
114
|
+
ip = MISSING_CLIENT_IP
|
|
115
|
+
end
|
|
53
116
|
rule = rate_limit_rule(request, context, config, path)
|
|
54
117
|
return if rule == false
|
|
55
118
|
|
|
56
|
-
window = rule[:window] || 10
|
|
57
|
-
max = rule[:max] || 100
|
|
119
|
+
window = (rule[:window] || 10).to_f
|
|
120
|
+
max = (rule[:max] || 100).to_i
|
|
58
121
|
key = rate_limit_key(ip, path)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
data = read_storage(storage, key)
|
|
122
|
+
decision = consume(storage_for(context, config), key, window: window, max: max, context: context)
|
|
123
|
+
return if decision[:allowed]
|
|
62
124
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
125
|
+
rate_limit_response(decision[:retry_after] || window)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
attr_reader :clock
|
|
131
|
+
|
|
132
|
+
def consume((type, storage), key, window:, max:, context:)
|
|
133
|
+
case type
|
|
134
|
+
when :memory then storage.consume(key, window: window, max: max)
|
|
135
|
+
when :database then consume_database(storage, key, window: window, max: max, context: context)
|
|
136
|
+
when :secondary then consume_secondary(storage, key, window: window, max: max, context: context)
|
|
137
|
+
when :custom
|
|
138
|
+
if storage.respond_to?(:consume)
|
|
139
|
+
normalize_consume_result(storage.consume(key, window: window, max: max))
|
|
140
|
+
else
|
|
141
|
+
legacy_consume(storage, key, window: window, max: max, context: context)
|
|
142
|
+
end
|
|
66
143
|
end
|
|
144
|
+
end
|
|
67
145
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
146
|
+
def consume_database(adapter, key, window:, max:, context:)
|
|
147
|
+
DATABASE_CONSUME_ATTEMPTS.times do
|
|
148
|
+
now = clock.call
|
|
149
|
+
now_ms = (now * 1000).to_i
|
|
150
|
+
data = read_database_storage(adapter, key)
|
|
151
|
+
unless data
|
|
152
|
+
created = adapter.create_if_absent(
|
|
153
|
+
model: "rateLimit",
|
|
154
|
+
data: {key: key, count: 1, lastRequest: now_ms},
|
|
155
|
+
conflict_field: "key"
|
|
156
|
+
)
|
|
157
|
+
return {allowed: true, retry_after: nil} if created
|
|
158
|
+
next
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
last_request = data.fetch(:last_request).to_f
|
|
162
|
+
if now - last_request > window
|
|
163
|
+
reset = adapter.increment_one(
|
|
164
|
+
model: "rateLimit",
|
|
165
|
+
where: [
|
|
166
|
+
{field: "key", value: key},
|
|
167
|
+
{field: "lastRequest", operator: "lte", value: (last_request * 1000).to_i}
|
|
168
|
+
],
|
|
169
|
+
increment: {},
|
|
170
|
+
set: {count: 1, lastRequest: now_ms}
|
|
171
|
+
)
|
|
172
|
+
if reset
|
|
173
|
+
schedule_database_cleanup(adapter, context, now_ms)
|
|
174
|
+
return {allowed: true, retry_after: nil}
|
|
175
|
+
end
|
|
176
|
+
next
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
incremented = adapter.increment_one(
|
|
180
|
+
model: "rateLimit",
|
|
181
|
+
where: [
|
|
182
|
+
{field: "key", value: key},
|
|
183
|
+
{field: "lastRequest", operator: "gte", value: ((now - window) * 1000).to_i},
|
|
184
|
+
{field: "count", operator: "lt", value: max}
|
|
185
|
+
],
|
|
186
|
+
increment: {count: 1},
|
|
187
|
+
set: {lastRequest: now_ms}
|
|
188
|
+
)
|
|
189
|
+
return {allowed: true, retry_after: nil} if incremented
|
|
190
|
+
|
|
191
|
+
fresh = read_database_storage(adapter, key)
|
|
192
|
+
next unless fresh
|
|
193
|
+
next if now - fresh.fetch(:last_request).to_f > window
|
|
194
|
+
|
|
195
|
+
return {allowed: false, retry_after: retry_after(fresh.fetch(:last_request).to_f, window, now)}
|
|
72
196
|
end
|
|
73
197
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
198
|
+
data = read_database_storage(adapter, key)
|
|
199
|
+
return {allowed: false, retry_after: window.ceil} unless data
|
|
200
|
+
|
|
201
|
+
{allowed: false, retry_after: retry_after(data.fetch(:last_request).to_f, window, clock.call)}
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def consume_secondary(storage, key, window:, max:, context:)
|
|
205
|
+
if storage.respond_to?(:increment)
|
|
206
|
+
existing = storage.get(key)
|
|
207
|
+
legacy_data = parse_secondary_legacy_data(existing)
|
|
208
|
+
if legacy_data
|
|
209
|
+
return legacy_consume(
|
|
210
|
+
storage, key, window: window, max: max, context: context, secondary: true, data: legacy_data
|
|
211
|
+
)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
count = storage.increment(key, window)
|
|
215
|
+
return {allowed: count.to_i <= max, retry_after: (count.to_i > max) ? window.ceil : nil}
|
|
78
216
|
end
|
|
79
217
|
|
|
80
|
-
|
|
218
|
+
legacy_consume(storage, key, window: window, max: max, context: context, secondary: true)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def legacy_consume(storage, key, window:, max:, context:, secondary: false, data: nil)
|
|
222
|
+
warn_legacy_storage(context)
|
|
223
|
+
data ||= read_legacy_storage(storage, key, secondary: secondary)
|
|
224
|
+
decision = self.class.decide_consume(data, window: window, max: max, now: clock.call)
|
|
225
|
+
return decision.slice(:allowed, :retry_after) unless decision[:allowed]
|
|
226
|
+
|
|
227
|
+
next_data = decision.fetch(:next).merge(key: key)
|
|
228
|
+
value = secondary ? JSON.generate(secondary_storage_data(next_data)) : next_data
|
|
229
|
+
call_legacy_set(storage, key, value, ttl: window, update: !data.nil?, secondary: secondary)
|
|
230
|
+
decision.slice(:allowed, :retry_after)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def normalize_consume_result(result)
|
|
234
|
+
invalid_consume!("must return a Hash") unless result.is_a?(Hash)
|
|
235
|
+
allowed = result.key?(:allowed) ? result[:allowed] : result["allowed"]
|
|
236
|
+
retry_after = result.key?(:retry_after) ? result[:retry_after] : result["retry_after"]
|
|
237
|
+
invalid_consume!("must include boolean allowed") unless allowed == true || allowed == false
|
|
238
|
+
valid_retry_after = retry_after.nil? || (retry_after.is_a?(Numeric) && retry_after.finite? && retry_after >= 0)
|
|
239
|
+
unless valid_retry_after
|
|
240
|
+
invalid_consume!("retry_after must be nil or a finite nonnegative number")
|
|
241
|
+
end
|
|
242
|
+
{allowed: allowed, retry_after: retry_after&.ceil}
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def invalid_consume!(message)
|
|
246
|
+
raise APIError.new("INTERNAL_SERVER_ERROR", message: "Invalid rate limit consume result: #{message}")
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def parse_secondary_legacy_data(value)
|
|
250
|
+
return normalize_rate_limit_data(symbolize_keys(value)) if value.is_a?(Hash)
|
|
251
|
+
return nil unless value.is_a?(String) && value.lstrip.start_with?("{")
|
|
252
|
+
|
|
253
|
+
parsed = JSON.parse(value)
|
|
254
|
+
normalize_rate_limit_data(symbolize_keys(parsed)) if parsed.is_a?(Hash)
|
|
255
|
+
rescue JSON::ParserError
|
|
81
256
|
nil
|
|
82
257
|
end
|
|
83
258
|
|
|
84
|
-
|
|
259
|
+
def read_legacy_storage(storage, key, secondary:)
|
|
260
|
+
data = storage.get(key)
|
|
261
|
+
data = JSON.parse(data) if secondary && data.is_a?(String)
|
|
262
|
+
normalize_rate_limit_data(symbolize_keys(data))
|
|
263
|
+
rescue JSON::ParserError
|
|
264
|
+
nil
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def call_legacy_set(storage, key, value, ttl:, update:, secondary:)
|
|
268
|
+
parameters = storage.method(:set).parameters
|
|
269
|
+
keyword_capable = parameters.any? { |type, _name| [:key, :keyreq].include?(type) }
|
|
270
|
+
if !secondary && keyword_capable
|
|
271
|
+
storage.set(key, value, ttl: ttl, update: update)
|
|
272
|
+
elsif secondary
|
|
273
|
+
storage.set(key, value, ttl)
|
|
274
|
+
else
|
|
275
|
+
storage.set(key, value, ttl, update)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def read_database_storage(adapter, key)
|
|
280
|
+
data = adapter.find_one(model: "rateLimit", where: [{field: "key", value: key}])
|
|
281
|
+
normalize_rate_limit_data(symbolize_keys(data))
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def schedule_database_cleanup(adapter, context, now_ms)
|
|
285
|
+
longest_window = longest_static_window(context)
|
|
286
|
+
return unless longest_window
|
|
287
|
+
|
|
288
|
+
task = lambda do
|
|
289
|
+
adapter.delete_many(
|
|
290
|
+
model: "rateLimit",
|
|
291
|
+
where: [{field: "lastRequest", operator: "lt", value: now_ms - (longest_window * 1000).to_i}]
|
|
292
|
+
)
|
|
293
|
+
rescue => error
|
|
294
|
+
log(context.logger, :error, "Error pruning rate limit rows", error)
|
|
295
|
+
end
|
|
296
|
+
context.run_in_background(task)
|
|
297
|
+
rescue => error
|
|
298
|
+
log(context.logger, :error, "Error scheduling rate limit row pruning", error)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def longest_static_window(context)
|
|
302
|
+
config = context.rate_limit_config || {}
|
|
303
|
+
custom_rules = config[:custom_rules] || {}
|
|
304
|
+
return nil if custom_rules.values.any? { |rule| rule.respond_to?(:call) }
|
|
305
|
+
|
|
306
|
+
windows = [(config[:window] || 10).to_f, 10, 60]
|
|
307
|
+
context.options.plugins.each do |plugin|
|
|
308
|
+
Array(plugin[:rate_limit]).each do |rule|
|
|
309
|
+
window = rule[:window]
|
|
310
|
+
return nil if window.respond_to?(:call)
|
|
311
|
+
windows << window.to_f if window
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
custom_rules.each_value do |rule|
|
|
315
|
+
next if rule == false
|
|
316
|
+
|
|
317
|
+
window = rule[:window]
|
|
318
|
+
return nil if window.respond_to?(:call)
|
|
319
|
+
windows << window.to_f if window
|
|
320
|
+
end
|
|
321
|
+
windows.max
|
|
322
|
+
end
|
|
85
323
|
|
|
86
324
|
def rate_limit_response(retry_after)
|
|
87
325
|
[
|
|
88
326
|
429,
|
|
89
|
-
{"content-type" => "application/json", "x-retry-after" => retry_after.to_s},
|
|
327
|
+
{"content-type" => "application/json", "x-retry-after" => retry_after.ceil.to_s},
|
|
90
328
|
[JSON.generate({message: "Too many requests. Please try again later."})]
|
|
91
329
|
]
|
|
92
330
|
end
|
|
93
331
|
|
|
94
|
-
def should_rate_limit?(max, window, count, last_request, now)
|
|
95
|
-
now - last_request < window && count >= max
|
|
96
|
-
end
|
|
97
|
-
|
|
98
332
|
def retry_after(last_request, window, now)
|
|
99
333
|
[(last_request + window - now).ceil, 0].max
|
|
100
334
|
end
|
|
101
335
|
|
|
102
|
-
def rate_limit_data(key, count, last_request)
|
|
103
|
-
{
|
|
104
|
-
key: key,
|
|
105
|
-
count: count,
|
|
106
|
-
last_request: last_request
|
|
107
|
-
}
|
|
108
|
-
end
|
|
109
|
-
|
|
110
336
|
def rate_limit_rule(request, context, config, path)
|
|
111
|
-
rule = {
|
|
112
|
-
window: config[:window] || 10,
|
|
113
|
-
max: config[:max] || 100
|
|
114
|
-
}
|
|
337
|
+
rule = {window: config[:window] || 10, max: config[:max] || 100}
|
|
115
338
|
rule = default_special_rule(path) || rule
|
|
116
339
|
rule = matching_plugin_rule(context, path) || rule
|
|
117
340
|
custom_rule = matching_custom_rule(config, path)
|
|
118
341
|
return resolve_custom_rule(custom_rule, request, rule) unless custom_rule.nil?
|
|
119
|
-
|
|
120
342
|
rule
|
|
121
343
|
end
|
|
122
344
|
|
|
123
345
|
def default_special_rule(path)
|
|
124
346
|
return {window: 10, max: 3} if path.start_with?("/sign-in", "/sign-up", "/change-password", "/change-email")
|
|
125
347
|
return {window: 60, max: 3} if path == "/request-password-reset" ||
|
|
126
|
-
path == "/send-verification-email" ||
|
|
127
|
-
path
|
|
128
|
-
path == "/email-otp/send-verification-otp" ||
|
|
129
|
-
path == "/email-otp/request-password-reset"
|
|
130
|
-
|
|
348
|
+
path == "/send-verification-email" || path.start_with?("/forget-password") ||
|
|
349
|
+
path == "/email-otp/send-verification-otp" || path == "/email-otp/request-password-reset"
|
|
131
350
|
nil
|
|
132
351
|
end
|
|
133
352
|
|
|
134
353
|
def matching_custom_rule(config, path)
|
|
135
354
|
custom_rules = config[:custom_rules] || {}
|
|
136
|
-
custom_rules.find
|
|
137
|
-
path_matches?(pattern.to_s, path)
|
|
138
|
-
end&.last
|
|
355
|
+
custom_rules.find { |pattern, _rule| path_matches?(pattern.to_s, path) }&.last
|
|
139
356
|
end
|
|
140
357
|
|
|
141
358
|
def resolve_custom_rule(rule, request, current)
|
|
142
359
|
return false if rule == false
|
|
143
360
|
return rule.call(request, current) if rule.respond_to?(:call)
|
|
144
|
-
|
|
145
361
|
rule || current
|
|
146
362
|
end
|
|
147
363
|
|
|
148
364
|
def storage_for(context, config)
|
|
149
365
|
return [:custom, config[:custom_storage]] if config[:custom_storage]
|
|
150
366
|
return [:database, context.internal_adapter.adapter] if config[:storage] == "database"
|
|
151
|
-
|
|
152
367
|
if config[:storage] == "secondary-storage" && context.options.secondary_storage
|
|
153
368
|
return [:secondary, context.options.secondary_storage]
|
|
154
369
|
end
|
|
155
|
-
|
|
156
370
|
[:memory, @memory_store]
|
|
157
371
|
end
|
|
158
372
|
|
|
159
|
-
def read_storage((type, storage), key)
|
|
160
|
-
return read_database_storage(storage, key) if type == :database
|
|
161
|
-
|
|
162
|
-
data = storage.get(key)
|
|
163
|
-
data = JSON.parse(data) if type == :secondary && data.is_a?(String)
|
|
164
|
-
normalize_rate_limit_data(symbolize_keys(data))
|
|
165
|
-
rescue JSON::ParserError
|
|
166
|
-
nil
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
def write_storage((type, storage), key, data, ttl:, update:)
|
|
170
|
-
return write_database_storage(storage, key, data) if type == :database
|
|
171
|
-
|
|
172
|
-
value = (type == :secondary) ? JSON.generate(secondary_storage_data(data)) : data
|
|
173
|
-
return call_secondary_storage_set(storage, key, value, ttl: ttl, update: update) if type == :secondary
|
|
174
|
-
|
|
175
|
-
call_storage_set(storage, key, value, ttl: ttl, update: update)
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
def read_database_storage(adapter, key)
|
|
179
|
-
data = adapter.find_one(model: "rateLimit", where: [{field: "key", value: key}])
|
|
180
|
-
normalize_rate_limit_data(symbolize_keys(data))
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
def write_database_storage(adapter, key, data)
|
|
184
|
-
value = secondary_storage_data(data)
|
|
185
|
-
existing = adapter.find_one(model: "rateLimit", where: [{field: "key", value: key}])
|
|
186
|
-
if existing
|
|
187
|
-
adapter.update(model: "rateLimit", where: [{field: "key", value: key}], update: value)
|
|
188
|
-
else
|
|
189
|
-
adapter.create(model: "rateLimit", data: value)
|
|
190
|
-
end
|
|
191
|
-
end
|
|
192
|
-
|
|
193
373
|
def secondary_storage_data(data)
|
|
194
|
-
{
|
|
195
|
-
key: data[:key],
|
|
196
|
-
count: data[:count],
|
|
197
|
-
lastRequest: (data[:last_request].to_f * 1000).to_i
|
|
198
|
-
}
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
def call_secondary_storage_set(storage, key, value, ttl:, update:)
|
|
202
|
-
storage.set(key, value, ttl)
|
|
203
|
-
rescue ArgumentError
|
|
204
|
-
call_storage_set(storage, key, value, ttl: ttl, update: update)
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
def call_storage_set(storage, key, value, ttl:, update:)
|
|
208
|
-
storage.set(key, value, ttl: ttl, update: update)
|
|
209
|
-
rescue ArgumentError
|
|
210
|
-
begin
|
|
211
|
-
storage.set(key, value, ttl, update)
|
|
212
|
-
rescue ArgumentError
|
|
213
|
-
begin
|
|
214
|
-
storage.set(key, value, ttl)
|
|
215
|
-
rescue ArgumentError
|
|
216
|
-
storage.set(key, value)
|
|
217
|
-
end
|
|
218
|
-
end
|
|
374
|
+
{key: data[:key], count: data[:count], lastRequest: (data[:last_request].to_f * 1000).to_i}
|
|
219
375
|
end
|
|
220
376
|
|
|
221
377
|
def symbolize_keys(value)
|
|
@@ -231,7 +387,6 @@ module BetterAuth
|
|
|
231
387
|
|
|
232
388
|
last_request = data[:last_request]
|
|
233
389
|
return data unless last_request.is_a?(Numeric) && last_request > 10_000_000_000
|
|
234
|
-
|
|
235
390
|
data.merge(last_request: last_request / 1000.0)
|
|
236
391
|
end
|
|
237
392
|
|
|
@@ -244,32 +399,39 @@ module BetterAuth
|
|
|
244
399
|
end
|
|
245
400
|
|
|
246
401
|
def warn_missing_client_ip(context)
|
|
247
|
-
|
|
248
|
-
|
|
402
|
+
warn_once(:@warned_missing_client_ip) { log(context.logger, :warn, MISSING_CLIENT_IP_WARNING) }
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def warn_legacy_storage(context)
|
|
406
|
+
warn_once(:@warned_legacy_storage) { log(context.logger, :warn, LEGACY_STORAGE_WARNING) }
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def warn_once(variable)
|
|
410
|
+
@warning_mutex.synchronize do
|
|
411
|
+
return if instance_variable_get(variable)
|
|
249
412
|
|
|
250
|
-
|
|
251
|
-
|
|
413
|
+
instance_variable_set(variable, true)
|
|
414
|
+
yield
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def log(logger, level, message, *arguments)
|
|
252
419
|
if logger.respond_to?(:call)
|
|
253
|
-
logger.call(
|
|
254
|
-
elsif logger.respond_to?(
|
|
255
|
-
logger.
|
|
420
|
+
logger.call(level, message, *arguments)
|
|
421
|
+
elsif logger.respond_to?(level)
|
|
422
|
+
logger.public_send(level, message, *arguments)
|
|
256
423
|
end
|
|
257
424
|
end
|
|
258
425
|
|
|
259
426
|
def matching_plugin_rule(context, path)
|
|
260
|
-
context.options.plugins
|
|
261
|
-
.
|
|
262
|
-
.find do |rule|
|
|
263
|
-
matcher = rule[:path_matcher]
|
|
264
|
-
matcher&.call(path)
|
|
265
|
-
end
|
|
427
|
+
context.options.plugins.flat_map { |plugin| Array(plugin[:rate_limit]) }
|
|
428
|
+
.find { |rule| rule[:path_matcher]&.call(path) }
|
|
266
429
|
end
|
|
267
430
|
|
|
268
431
|
def path_matches?(pattern, path)
|
|
269
432
|
return path == pattern unless pattern.include?("*")
|
|
270
433
|
|
|
271
|
-
|
|
272
|
-
/\A#{regex}\z/.match?(path)
|
|
434
|
+
/\A#{Regexp.escape(pattern).gsub("\\*", ".*")}\z/.match?(path)
|
|
273
435
|
end
|
|
274
436
|
end
|
|
275
437
|
end
|