better_auth 0.8.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 +92 -0
- data/LICENSE.md +1 -1
- data/README.md +142 -95
- 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 +303 -54
- data/lib/better_auth/adapters/mssql.rb +7 -3
- data/lib/better_auth/adapters/sql.rb +390 -63
- data/lib/better_auth/api.rb +3 -24
- data/lib/better_auth/configuration.rb +48 -47
- data/lib/better_auth/context.rb +43 -67
- data/lib/better_auth/cookies.rb +119 -15
- data/lib/better_auth/core.rb +0 -2
- data/lib/better_auth/crypto.rb +4 -3
- data/lib/better_auth/doctor.rb +108 -0
- data/lib/better_auth/endpoint.rb +88 -5
- data/lib/better_auth/error.rb +3 -1
- data/lib/better_auth/http_client.rb +46 -0
- 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 +15 -0
- data/lib/better_auth/oauth2.rb +1 -1
- 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 +85 -29
- data/lib/better_auth/plugins/anonymous.rb +2 -0
- 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 +77 -5
- data/lib/better_auth/plugins/dub.rb +10 -2
- data/lib/better_auth/plugins/email_otp.rb +30 -47
- data/lib/better_auth/plugins/generic_oauth.rb +162 -140
- data/lib/better_auth/plugins/have_i_been_pwned.rb +1 -1
- data/lib/better_auth/plugins/i18n.rb +215 -0
- data/lib/better_auth/plugins/jwt.rb +44 -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 +233 -46
- 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 +78 -65
- data/lib/better_auth/plugins/one_time_token.rb +1 -4
- data/lib/better_auth/plugins/open_api.rb +77 -9
- data/lib/better_auth/plugins/organization.rb +546 -102
- data/lib/better_auth/plugins/passkey.rb +4 -7
- data/lib/better_auth/plugins/phone_number.rb +25 -27
- 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 +205 -19
- data/lib/better_auth/plugins/username.rb +56 -13
- data/lib/better_auth/plugins.rb +57 -0
- data/lib/better_auth/rate_limiter.rb +300 -133
- data/lib/better_auth/request_ip.rb +123 -13
- data/lib/better_auth/router.rb +3 -3
- data/lib/better_auth/routes/account.rb +44 -8
- data/lib/better_auth/routes/email_verification.rb +93 -6
- data/lib/better_auth/routes/password.rb +6 -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 +197 -54
- data/lib/better_auth/routes/user.rb +17 -10
- data/lib/better_auth/schema/sql.rb +189 -34
- data/lib/better_auth/schema.rb +159 -5
- data/lib/better_auth/session.rb +29 -10
- 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 +45 -23
- 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/github.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 +566 -0
- data/lib/better_auth/version.rb +1 -1
- data/lib/better_auth.rb +14 -41
- metadata +14 -18
- 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 -215
- data/lib/better_auth/plugins/oidc_provider.rb +0 -710
|
@@ -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,188 +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
|
-
return
|
|
125
|
-
|
|
126
|
-
|
|
346
|
+
return {window: 10, max: 3} if path.start_with?("/sign-in", "/sign-up", "/change-password", "/change-email")
|
|
347
|
+
return {window: 60, max: 3} if path == "/request-password-reset" ||
|
|
348
|
+
path == "/send-verification-email" || path.start_with?("/forget-password") ||
|
|
349
|
+
path == "/email-otp/send-verification-otp" || path == "/email-otp/request-password-reset"
|
|
350
|
+
nil
|
|
127
351
|
end
|
|
128
352
|
|
|
129
353
|
def matching_custom_rule(config, path)
|
|
130
354
|
custom_rules = config[:custom_rules] || {}
|
|
131
|
-
custom_rules.find
|
|
132
|
-
path_matches?(pattern.to_s, path)
|
|
133
|
-
end&.last
|
|
355
|
+
custom_rules.find { |pattern, _rule| path_matches?(pattern.to_s, path) }&.last
|
|
134
356
|
end
|
|
135
357
|
|
|
136
358
|
def resolve_custom_rule(rule, request, current)
|
|
137
359
|
return false if rule == false
|
|
138
360
|
return rule.call(request, current) if rule.respond_to?(:call)
|
|
139
|
-
|
|
140
361
|
rule || current
|
|
141
362
|
end
|
|
142
363
|
|
|
143
364
|
def storage_for(context, config)
|
|
144
365
|
return [:custom, config[:custom_storage]] if config[:custom_storage]
|
|
145
366
|
return [:database, context.internal_adapter.adapter] if config[:storage] == "database"
|
|
146
|
-
|
|
147
367
|
if config[:storage] == "secondary-storage" && context.options.secondary_storage
|
|
148
368
|
return [:secondary, context.options.secondary_storage]
|
|
149
369
|
end
|
|
150
|
-
|
|
151
370
|
[:memory, @memory_store]
|
|
152
371
|
end
|
|
153
372
|
|
|
154
|
-
def read_storage((type, storage), key)
|
|
155
|
-
return read_database_storage(storage, key) if type == :database
|
|
156
|
-
|
|
157
|
-
data = storage.get(key)
|
|
158
|
-
data = JSON.parse(data) if type == :secondary && data.is_a?(String)
|
|
159
|
-
normalize_rate_limit_data(symbolize_keys(data))
|
|
160
|
-
rescue JSON::ParserError
|
|
161
|
-
nil
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
def write_storage((type, storage), key, data, ttl:, update:)
|
|
165
|
-
return write_database_storage(storage, key, data) if type == :database
|
|
166
|
-
|
|
167
|
-
value = (type == :secondary) ? JSON.generate(secondary_storage_data(data)) : data
|
|
168
|
-
return call_secondary_storage_set(storage, key, value, ttl: ttl, update: update) if type == :secondary
|
|
169
|
-
|
|
170
|
-
call_storage_set(storage, key, value, ttl: ttl, update: update)
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
def read_database_storage(adapter, key)
|
|
174
|
-
data = adapter.find_one(model: "rateLimit", where: [{field: "key", value: key}])
|
|
175
|
-
normalize_rate_limit_data(symbolize_keys(data))
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
def write_database_storage(adapter, key, data)
|
|
179
|
-
value = secondary_storage_data(data)
|
|
180
|
-
existing = adapter.find_one(model: "rateLimit", where: [{field: "key", value: key}])
|
|
181
|
-
if existing
|
|
182
|
-
adapter.update(model: "rateLimit", where: [{field: "key", value: key}], update: value)
|
|
183
|
-
else
|
|
184
|
-
adapter.create(model: "rateLimit", data: value)
|
|
185
|
-
end
|
|
186
|
-
end
|
|
187
|
-
|
|
188
373
|
def secondary_storage_data(data)
|
|
189
|
-
{
|
|
190
|
-
key: data[:key],
|
|
191
|
-
count: data[:count],
|
|
192
|
-
lastRequest: (data[:last_request].to_f * 1000).to_i
|
|
193
|
-
}
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
def call_secondary_storage_set(storage, key, value, ttl:, update:)
|
|
197
|
-
storage.set(key, value, ttl)
|
|
198
|
-
rescue ArgumentError
|
|
199
|
-
call_storage_set(storage, key, value, ttl: ttl, update: update)
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
def call_storage_set(storage, key, value, ttl:, update:)
|
|
203
|
-
storage.set(key, value, ttl: ttl, update: update)
|
|
204
|
-
rescue ArgumentError
|
|
205
|
-
begin
|
|
206
|
-
storage.set(key, value, ttl, update)
|
|
207
|
-
rescue ArgumentError
|
|
208
|
-
begin
|
|
209
|
-
storage.set(key, value, ttl)
|
|
210
|
-
rescue ArgumentError
|
|
211
|
-
storage.set(key, value)
|
|
212
|
-
end
|
|
213
|
-
end
|
|
374
|
+
{key: data[:key], count: data[:count], lastRequest: (data[:last_request].to_f * 1000).to_i}
|
|
214
375
|
end
|
|
215
376
|
|
|
216
377
|
def symbolize_keys(value)
|
|
@@ -226,7 +387,6 @@ module BetterAuth
|
|
|
226
387
|
|
|
227
388
|
last_request = data[:last_request]
|
|
228
389
|
return data unless last_request.is_a?(Numeric) && last_request > 10_000_000_000
|
|
229
|
-
|
|
230
390
|
data.merge(last_request: last_request / 1000.0)
|
|
231
391
|
end
|
|
232
392
|
|
|
@@ -239,32 +399,39 @@ module BetterAuth
|
|
|
239
399
|
end
|
|
240
400
|
|
|
241
401
|
def warn_missing_client_ip(context)
|
|
242
|
-
|
|
243
|
-
|
|
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)
|
|
244
412
|
|
|
245
|
-
|
|
246
|
-
|
|
413
|
+
instance_variable_set(variable, true)
|
|
414
|
+
yield
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def log(logger, level, message, *arguments)
|
|
247
419
|
if logger.respond_to?(:call)
|
|
248
|
-
logger.call(
|
|
249
|
-
elsif logger.respond_to?(
|
|
250
|
-
logger.
|
|
420
|
+
logger.call(level, message, *arguments)
|
|
421
|
+
elsif logger.respond_to?(level)
|
|
422
|
+
logger.public_send(level, message, *arguments)
|
|
251
423
|
end
|
|
252
424
|
end
|
|
253
425
|
|
|
254
426
|
def matching_plugin_rule(context, path)
|
|
255
|
-
context.options.plugins
|
|
256
|
-
.
|
|
257
|
-
.find do |rule|
|
|
258
|
-
matcher = rule[:path_matcher]
|
|
259
|
-
matcher&.call(path)
|
|
260
|
-
end
|
|
427
|
+
context.options.plugins.flat_map { |plugin| Array(plugin[:rate_limit]) }
|
|
428
|
+
.find { |rule| rule[:path_matcher]&.call(path) }
|
|
261
429
|
end
|
|
262
430
|
|
|
263
431
|
def path_matches?(pattern, path)
|
|
264
432
|
return path == pattern unless pattern.include?("*")
|
|
265
433
|
|
|
266
|
-
|
|
267
|
-
/\A#{regex}\z/.match?(path)
|
|
434
|
+
/\A#{Regexp.escape(pattern).gsub("\\*", ".*")}\z/.match?(path)
|
|
268
435
|
end
|
|
269
436
|
end
|
|
270
437
|
end
|