better_auth 0.1.1 → 0.3.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 +23 -0
- data/README.md +110 -18
- data/lib/better_auth/adapters/base.rb +49 -0
- data/lib/better_auth/adapters/internal_adapter.rb +589 -0
- data/lib/better_auth/adapters/memory.rb +235 -0
- data/lib/better_auth/adapters/mongodb.rb +9 -0
- data/lib/better_auth/adapters/mssql.rb +42 -0
- data/lib/better_auth/adapters/mysql.rb +33 -0
- data/lib/better_auth/adapters/postgres.rb +17 -0
- data/lib/better_auth/adapters/sql.rb +441 -0
- data/lib/better_auth/adapters/sqlite.rb +20 -0
- data/lib/better_auth/api.rb +226 -0
- data/lib/better_auth/api_error.rb +53 -0
- data/lib/better_auth/auth.rb +42 -0
- data/lib/better_auth/configuration.rb +399 -0
- data/lib/better_auth/context.rb +211 -0
- data/lib/better_auth/cookies.rb +278 -0
- data/lib/better_auth/core.rb +37 -1
- data/lib/better_auth/crypto/jwe.rb +76 -0
- data/lib/better_auth/crypto.rb +191 -0
- data/lib/better_auth/database_hooks.rb +114 -0
- data/lib/better_auth/endpoint.rb +326 -0
- data/lib/better_auth/error.rb +52 -0
- data/lib/better_auth/middleware/origin_check.rb +128 -0
- data/lib/better_auth/password.rb +120 -0
- data/lib/better_auth/plugin.rb +142 -0
- data/lib/better_auth/plugin_context.rb +16 -0
- data/lib/better_auth/plugin_registry.rb +67 -0
- data/lib/better_auth/plugins/access.rb +87 -0
- data/lib/better_auth/plugins/additional_fields.rb +29 -0
- data/lib/better_auth/plugins/admin/schema.rb +28 -0
- data/lib/better_auth/plugins/admin.rb +518 -0
- data/lib/better_auth/plugins/anonymous.rb +198 -0
- data/lib/better_auth/plugins/api_key.rb +16 -0
- data/lib/better_auth/plugins/bearer.rb +128 -0
- data/lib/better_auth/plugins/captcha.rb +159 -0
- data/lib/better_auth/plugins/custom_session.rb +84 -0
- data/lib/better_auth/plugins/device_authorization.rb +302 -0
- data/lib/better_auth/plugins/email_otp.rb +536 -0
- data/lib/better_auth/plugins/expo.rb +88 -0
- data/lib/better_auth/plugins/generic_oauth.rb +780 -0
- data/lib/better_auth/plugins/have_i_been_pwned.rb +94 -0
- data/lib/better_auth/plugins/jwt.rb +482 -0
- data/lib/better_auth/plugins/last_login_method.rb +92 -0
- data/lib/better_auth/plugins/magic_link.rb +181 -0
- data/lib/better_auth/plugins/mcp.rb +342 -0
- data/lib/better_auth/plugins/multi_session.rb +173 -0
- data/lib/better_auth/plugins/oauth_protocol.rb +694 -0
- data/lib/better_auth/plugins/oauth_provider.rb +16 -0
- data/lib/better_auth/plugins/oauth_proxy.rb +257 -0
- data/lib/better_auth/plugins/oidc_provider.rb +597 -0
- data/lib/better_auth/plugins/one_tap.rb +154 -0
- data/lib/better_auth/plugins/one_time_token.rb +106 -0
- data/lib/better_auth/plugins/open_api.rb +489 -0
- data/lib/better_auth/plugins/organization/schema.rb +106 -0
- data/lib/better_auth/plugins/organization.rb +995 -0
- data/lib/better_auth/plugins/passkey.rb +16 -0
- data/lib/better_auth/plugins/phone_number.rb +321 -0
- data/lib/better_auth/plugins/scim.rb +16 -0
- data/lib/better_auth/plugins/siwe.rb +242 -0
- data/lib/better_auth/plugins/sso.rb +16 -0
- data/lib/better_auth/plugins/stripe.rb +16 -0
- data/lib/better_auth/plugins/two_factor.rb +514 -0
- data/lib/better_auth/plugins/username.rb +278 -0
- data/lib/better_auth/plugins.rb +46 -0
- data/lib/better_auth/rate_limiter.rb +232 -0
- data/lib/better_auth/request_ip.rb +70 -0
- data/lib/better_auth/router.rb +378 -0
- data/lib/better_auth/routes/account.rb +211 -0
- data/lib/better_auth/routes/email_verification.rb +111 -0
- data/lib/better_auth/routes/error.rb +102 -0
- data/lib/better_auth/routes/ok.rb +15 -0
- data/lib/better_auth/routes/password.rb +183 -0
- data/lib/better_auth/routes/session.rb +160 -0
- data/lib/better_auth/routes/sign_in.rb +90 -0
- data/lib/better_auth/routes/sign_out.rb +15 -0
- data/lib/better_auth/routes/sign_up.rb +196 -0
- data/lib/better_auth/routes/social.rb +367 -0
- data/lib/better_auth/routes/user.rb +205 -0
- data/lib/better_auth/schema/sql.rb +202 -0
- data/lib/better_auth/schema.rb +291 -0
- data/lib/better_auth/session.rb +122 -0
- data/lib/better_auth/session_store.rb +91 -0
- data/lib/better_auth/social_providers/apple.rb +91 -0
- data/lib/better_auth/social_providers/atlassian.rb +32 -0
- data/lib/better_auth/social_providers/base.rb +325 -0
- data/lib/better_auth/social_providers/cognito.rb +32 -0
- data/lib/better_auth/social_providers/discord.rb +81 -0
- data/lib/better_auth/social_providers/dropbox.rb +33 -0
- data/lib/better_auth/social_providers/facebook.rb +35 -0
- data/lib/better_auth/social_providers/figma.rb +31 -0
- data/lib/better_auth/social_providers/github.rb +74 -0
- data/lib/better_auth/social_providers/gitlab.rb +67 -0
- data/lib/better_auth/social_providers/google.rb +90 -0
- data/lib/better_auth/social_providers/huggingface.rb +31 -0
- data/lib/better_auth/social_providers/kakao.rb +32 -0
- data/lib/better_auth/social_providers/kick.rb +32 -0
- data/lib/better_auth/social_providers/line.rb +33 -0
- data/lib/better_auth/social_providers/linear.rb +44 -0
- data/lib/better_auth/social_providers/linkedin.rb +30 -0
- data/lib/better_auth/social_providers/microsoft_entra_id.rb +137 -0
- data/lib/better_auth/social_providers/naver.rb +31 -0
- data/lib/better_auth/social_providers/notion.rb +33 -0
- data/lib/better_auth/social_providers/paybin.rb +31 -0
- data/lib/better_auth/social_providers/paypal.rb +36 -0
- data/lib/better_auth/social_providers/polar.rb +31 -0
- data/lib/better_auth/social_providers/railway.rb +49 -0
- data/lib/better_auth/social_providers/reddit.rb +32 -0
- data/lib/better_auth/social_providers/roblox.rb +31 -0
- data/lib/better_auth/social_providers/salesforce.rb +38 -0
- data/lib/better_auth/social_providers/slack.rb +30 -0
- data/lib/better_auth/social_providers/spotify.rb +31 -0
- data/lib/better_auth/social_providers/tiktok.rb +35 -0
- data/lib/better_auth/social_providers/twitch.rb +39 -0
- data/lib/better_auth/social_providers/twitter.rb +32 -0
- data/lib/better_auth/social_providers/vercel.rb +47 -0
- data/lib/better_auth/social_providers/vk.rb +34 -0
- data/lib/better_auth/social_providers/wechat.rb +104 -0
- data/lib/better_auth/social_providers/zoom.rb +31 -0
- data/lib/better_auth/social_providers.rb +38 -0
- data/lib/better_auth/version.rb +1 -1
- data/lib/better_auth.rb +86 -2
- metadata +233 -21
- data/.ruby-version +0 -1
- data/.standard.yml +0 -12
- data/.vscode/settings.json +0 -22
- data/AGENTS.md +0 -50
- data/CLAUDE.md +0 -1
- data/CODE_OF_CONDUCT.md +0 -173
- data/CONTRIBUTING.md +0 -187
- data/Gemfile +0 -12
- data/Makefile +0 -207
- data/Rakefile +0 -25
- data/SECURITY.md +0 -28
- data/docker-compose.yml +0 -63
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module BetterAuth
|
|
6
|
+
module Plugins
|
|
7
|
+
USERNAME_ERROR_CODES = {
|
|
8
|
+
"INVALID_USERNAME_OR_PASSWORD" => "Invalid username or password",
|
|
9
|
+
"EMAIL_NOT_VERIFIED" => "Email not verified",
|
|
10
|
+
"UNEXPECTED_ERROR" => "Unexpected error",
|
|
11
|
+
"USERNAME_IS_ALREADY_TAKEN" => "Username is already taken. Please try another.",
|
|
12
|
+
"USERNAME_TOO_SHORT" => "Username is too short",
|
|
13
|
+
"USERNAME_TOO_LONG" => "Username is too long",
|
|
14
|
+
"INVALID_USERNAME" => "Username is invalid",
|
|
15
|
+
"INVALID_DISPLAY_USERNAME" => "Display username is invalid"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def username(options = {})
|
|
21
|
+
config = normalize_hash(options)
|
|
22
|
+
|
|
23
|
+
Plugin.new(
|
|
24
|
+
id: "username",
|
|
25
|
+
init: ->(_context) { {options: {database_hooks: username_database_hooks(config)}} },
|
|
26
|
+
endpoints: {
|
|
27
|
+
sign_in_username: sign_in_username_endpoint(config),
|
|
28
|
+
is_username_available: is_username_available_endpoint(config)
|
|
29
|
+
},
|
|
30
|
+
schema: username_schema(config),
|
|
31
|
+
hooks: {
|
|
32
|
+
before: [
|
|
33
|
+
{
|
|
34
|
+
matcher: ->(ctx) { username_mutation_path?(ctx.path) },
|
|
35
|
+
handler: ->(ctx) { validate_username_mutation!(ctx, config) }
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
matcher: ->(ctx) { username_mutation_path?(ctx.path) },
|
|
39
|
+
handler: ->(ctx) { mirror_username_fields!(ctx) }
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
error_codes: USERNAME_ERROR_CODES,
|
|
44
|
+
options: config
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def sign_in_username_endpoint(config)
|
|
49
|
+
Endpoint.new(
|
|
50
|
+
path: "/sign-in/username",
|
|
51
|
+
method: "POST",
|
|
52
|
+
metadata: {
|
|
53
|
+
allowed_media_types: [
|
|
54
|
+
"application/x-www-form-urlencoded",
|
|
55
|
+
"application/json"
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
) do |ctx|
|
|
59
|
+
body = normalize_hash(ctx.body)
|
|
60
|
+
raw_username = body[:username].to_s
|
|
61
|
+
password = body[:password].to_s
|
|
62
|
+
callback_url = body[:callback_url] || body[:callbackURL]
|
|
63
|
+
remember_me = body.key?(:remember_me) ? body[:remember_me] : body[:rememberMe]
|
|
64
|
+
|
|
65
|
+
if raw_username.empty? || password.empty?
|
|
66
|
+
raise APIError.new("UNAUTHORIZED", message: USERNAME_ERROR_CODES["INVALID_USERNAME_OR_PASSWORD"])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
username = username_for_validation(raw_username, config)
|
|
70
|
+
validate_username!(username, config, status: "UNPROCESSABLE_ENTITY")
|
|
71
|
+
|
|
72
|
+
user = ctx.context.adapter.find_one(
|
|
73
|
+
model: "user",
|
|
74
|
+
where: [{field: "username", value: normalize_username(username, config)}]
|
|
75
|
+
)
|
|
76
|
+
unless user
|
|
77
|
+
Routes.hash_password(ctx, password)
|
|
78
|
+
raise APIError.new("UNAUTHORIZED", message: USERNAME_ERROR_CODES["INVALID_USERNAME_OR_PASSWORD"])
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
account = ctx.context.adapter.find_one(
|
|
82
|
+
model: "account",
|
|
83
|
+
where: [
|
|
84
|
+
{field: "userId", value: user["id"]},
|
|
85
|
+
{field: "providerId", value: "credential"}
|
|
86
|
+
]
|
|
87
|
+
)
|
|
88
|
+
current_password = account && account["password"]
|
|
89
|
+
email_config = ctx.context.options.email_and_password
|
|
90
|
+
unless current_password && Routes.verify_password_value(ctx, password, current_password)
|
|
91
|
+
Routes.hash_password(ctx, password) unless current_password
|
|
92
|
+
raise APIError.new("UNAUTHORIZED", message: USERNAME_ERROR_CODES["INVALID_USERNAME_OR_PASSWORD"])
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
if email_config[:require_email_verification] && !user["emailVerified"]
|
|
96
|
+
Routes.send_sign_in_verification_email(ctx, user, callback_url)
|
|
97
|
+
raise APIError.new("FORBIDDEN", message: USERNAME_ERROR_CODES["EMAIL_NOT_VERIFIED"])
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
dont_remember_me = remember_me == false || remember_me.to_s == "false"
|
|
101
|
+
session = ctx.context.internal_adapter.create_session(
|
|
102
|
+
user["id"],
|
|
103
|
+
dont_remember_me,
|
|
104
|
+
Routes.session_overrides(ctx),
|
|
105
|
+
true
|
|
106
|
+
)
|
|
107
|
+
raise APIError.new("INTERNAL_SERVER_ERROR", message: BASE_ERROR_CODES["FAILED_TO_CREATE_SESSION"]) unless session
|
|
108
|
+
|
|
109
|
+
Cookies.set_session_cookie(ctx, {session: session, user: user}, dont_remember_me)
|
|
110
|
+
ctx.json({
|
|
111
|
+
token: session["token"],
|
|
112
|
+
user: Schema.parse_output(ctx.context.options, "user", user)
|
|
113
|
+
})
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def is_username_available_endpoint(config)
|
|
118
|
+
Endpoint.new(path: "/is-username-available", method: "POST") do |ctx|
|
|
119
|
+
body = normalize_hash(ctx.body)
|
|
120
|
+
username = body[:username].to_s
|
|
121
|
+
raise APIError.new("UNPROCESSABLE_ENTITY", message: USERNAME_ERROR_CODES["INVALID_USERNAME"]) if username.empty?
|
|
122
|
+
|
|
123
|
+
validate_username!(username, config, status: "UNPROCESSABLE_ENTITY")
|
|
124
|
+
user = ctx.context.adapter.find_one(
|
|
125
|
+
model: "user",
|
|
126
|
+
where: [{field: "username", value: normalize_username(username, config)}]
|
|
127
|
+
)
|
|
128
|
+
ctx.json({available: user.nil?})
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def username_schema(config)
|
|
133
|
+
{
|
|
134
|
+
user: {
|
|
135
|
+
fields: {
|
|
136
|
+
username: {
|
|
137
|
+
type: "string",
|
|
138
|
+
required: false,
|
|
139
|
+
sortable: true,
|
|
140
|
+
unique: true,
|
|
141
|
+
returned: true,
|
|
142
|
+
field_name: "username"
|
|
143
|
+
},
|
|
144
|
+
displayUsername: {
|
|
145
|
+
type: "string",
|
|
146
|
+
required: false,
|
|
147
|
+
field_name: "display_username"
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def username_database_hooks(config)
|
|
155
|
+
before_hook = lambda do |user, _context|
|
|
156
|
+
data = user.dup
|
|
157
|
+
if data["username"].is_a?(String) && !data["username"].empty?
|
|
158
|
+
data["username"] = normalize_username(data["username"], config)
|
|
159
|
+
end
|
|
160
|
+
if data["displayUsername"].is_a?(String) && !data["displayUsername"].empty?
|
|
161
|
+
data["displayUsername"] = normalize_display_username(data["displayUsername"], config)
|
|
162
|
+
end
|
|
163
|
+
{data: data}
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
{
|
|
167
|
+
user: {
|
|
168
|
+
create: {before: before_hook},
|
|
169
|
+
update: {before: before_hook}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def validate_username_mutation!(ctx, config)
|
|
175
|
+
body = normalize_hash(ctx.body)
|
|
176
|
+
raw_username = body.key?(:username) ? body[:username] : nil
|
|
177
|
+
username = if raw_username.is_a?(String) && validation_order(config, :username) == "post-normalization"
|
|
178
|
+
normalize_username(raw_username, config)
|
|
179
|
+
else
|
|
180
|
+
raw_username
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
if username.is_a?(String)
|
|
184
|
+
validate_username!(username, config, status: "BAD_REQUEST")
|
|
185
|
+
existing = ctx.context.adapter.find_one(model: "user", where: [{field: "username", value: normalize_username(username, config)}])
|
|
186
|
+
current = (ctx.path == "/update-user") ? Routes.current_session(ctx, allow_nil: true) : nil
|
|
187
|
+
same_user = existing && current && existing["id"] == current[:session]["userId"]
|
|
188
|
+
|
|
189
|
+
if existing && ctx.path == "/sign-up/email"
|
|
190
|
+
raise APIError.new("UNPROCESSABLE_ENTITY", message: USERNAME_ERROR_CODES["USERNAME_IS_ALREADY_TAKEN"])
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
if existing && ctx.path == "/update-user" && !same_user
|
|
194
|
+
raise APIError.new("BAD_REQUEST", message: USERNAME_ERROR_CODES["USERNAME_IS_ALREADY_TAKEN"])
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
raw_display_username = body.key?(:display_username) ? body[:display_username] : nil
|
|
199
|
+
display_username = if raw_display_username.is_a?(String) && validation_order(config, :display_username) == "post-normalization"
|
|
200
|
+
normalize_display_username(raw_display_username, config)
|
|
201
|
+
else
|
|
202
|
+
raw_display_username
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
if display_username.is_a?(String)
|
|
206
|
+
validator = config[:display_username_validator]
|
|
207
|
+
unless !validator.respond_to?(:call) || validator.call(display_username)
|
|
208
|
+
raise APIError.new("BAD_REQUEST", message: USERNAME_ERROR_CODES["INVALID_DISPLAY_USERNAME"])
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def mirror_username_fields!(ctx)
|
|
215
|
+
body = normalize_hash(ctx.body)
|
|
216
|
+
body[:display_username] = body[:username] if present?(body[:username]) && !present?(body[:display_username])
|
|
217
|
+
body[:username] = body[:display_username] if present?(body[:display_username]) && !present?(body[:username])
|
|
218
|
+
ctx.body = body
|
|
219
|
+
nil
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def validate_username!(username, config, status:)
|
|
223
|
+
if username.length < min_username_length(config)
|
|
224
|
+
raise APIError.new(status, message: USERNAME_ERROR_CODES["USERNAME_TOO_SHORT"])
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
if username.length > max_username_length(config)
|
|
228
|
+
raise APIError.new(status, message: USERNAME_ERROR_CODES["USERNAME_TOO_LONG"])
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
validator = config[:username_validator]
|
|
232
|
+
valid = validator.respond_to?(:call) ? validator.call(username) : default_username_valid?(username)
|
|
233
|
+
raise APIError.new(status, message: USERNAME_ERROR_CODES["INVALID_USERNAME"]) unless valid
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def username_for_validation(username, config)
|
|
237
|
+
(validation_order(config, :username) == "pre-normalization") ? normalize_username(username, config) : username
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def normalize_username(username, config)
|
|
241
|
+
normalizer = config[:username_normalization]
|
|
242
|
+
return username if normalizer == false
|
|
243
|
+
return normalizer.call(username) if normalizer.respond_to?(:call)
|
|
244
|
+
|
|
245
|
+
username.downcase
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def normalize_display_username(display_username, config)
|
|
249
|
+
normalizer = config[:display_username_normalization]
|
|
250
|
+
normalizer.respond_to?(:call) ? normalizer.call(display_username) : display_username
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def validation_order(config, field)
|
|
254
|
+
order = config[:validation_order] || {}
|
|
255
|
+
order[field] || "pre-normalization"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def username_mutation_path?(path)
|
|
259
|
+
path == "/sign-up/email" || path == "/update-user"
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def min_username_length(config)
|
|
263
|
+
(config[:min_username_length] || 3).to_i
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def max_username_length(config)
|
|
267
|
+
(config[:max_username_length] || 30).to_i
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def default_username_valid?(username)
|
|
271
|
+
username.match?(/\A[a-zA-Z0-9_.]+\z/)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def present?(value)
|
|
275
|
+
!value.nil? && value != false && !value.to_s.empty?
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Plugins
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def normalize_hash(value)
|
|
8
|
+
return {} unless value.is_a?(Hash)
|
|
9
|
+
|
|
10
|
+
value.each_with_object({}) do |(key, object), result|
|
|
11
|
+
result[normalize_key(key)] = object.is_a?(Hash) ? normalize_hash(object) : object
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def normalize_key(key)
|
|
16
|
+
key.to_s
|
|
17
|
+
.gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
|
|
18
|
+
.tr("-", "_")
|
|
19
|
+
.downcase
|
|
20
|
+
.to_sym
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def storage_fields(fields)
|
|
24
|
+
normalize_hash(fields).each_with_object({}) do |(key, value), result|
|
|
25
|
+
result[Schema.storage_key(key)] = normalize_field(value)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def normalize_field(value)
|
|
30
|
+
data = normalize_hash(value || {})
|
|
31
|
+
data[:default_value] = data.delete(:defaultValue) if data.key?(:defaultValue)
|
|
32
|
+
data[:field_name] = data.delete(:fieldName) if data.key?(:fieldName)
|
|
33
|
+
data
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def fetch_value(data, key)
|
|
37
|
+
return nil unless data.respond_to?(:[])
|
|
38
|
+
|
|
39
|
+
data[key] || data[key.to_s] || data[Schema.storage_key(key)] || data[Schema.storage_key(key).to_sym] || data[normalize_key(key)]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def cookie_header_from_set_cookie(set_cookie)
|
|
43
|
+
set_cookie.to_s.lines.map { |line| line.split(";").first }.join("; ")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module BetterAuth
|
|
6
|
+
class RateLimiter
|
|
7
|
+
class MemoryStore
|
|
8
|
+
def initialize
|
|
9
|
+
@entries = {}
|
|
10
|
+
@mutex = Mutex.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def get(key)
|
|
14
|
+
@mutex.synchronize do
|
|
15
|
+
entry = @entries[key]
|
|
16
|
+
return nil unless entry
|
|
17
|
+
|
|
18
|
+
if Time.now.to_f >= entry[:expires_at]
|
|
19
|
+
@entries.delete(key)
|
|
20
|
+
return nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
entry[:data]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def set(key, value, ttl:, update: false)
|
|
28
|
+
@mutex.synchronize do
|
|
29
|
+
@entries[key] = {
|
|
30
|
+
data: value,
|
|
31
|
+
expires_at: Time.now.to_f + ttl.to_f
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize
|
|
38
|
+
@memory_store = MemoryStore.new
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def call(request, context, path)
|
|
42
|
+
config = context.rate_limit_config || {}
|
|
43
|
+
return unless config[:enabled]
|
|
44
|
+
|
|
45
|
+
ip = client_ip(request, context.options)
|
|
46
|
+
return unless ip
|
|
47
|
+
|
|
48
|
+
rule = rate_limit_rule(request, context, config, path)
|
|
49
|
+
return if rule == false
|
|
50
|
+
|
|
51
|
+
window = rule[:window] || 10
|
|
52
|
+
max = rule[:max] || 100
|
|
53
|
+
key = rate_limit_key(ip, path)
|
|
54
|
+
now = Time.now.to_f
|
|
55
|
+
storage = storage_for(context, config)
|
|
56
|
+
data = read_storage(storage, key)
|
|
57
|
+
|
|
58
|
+
unless data
|
|
59
|
+
write_storage(storage, key, rate_limit_data(key, 1, now), ttl: window, update: false)
|
|
60
|
+
return
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
last_request = data.fetch(:last_request).to_f
|
|
64
|
+
count = data.fetch(:count).to_i
|
|
65
|
+
if should_rate_limit?(max.to_i, window.to_f, count, last_request, now)
|
|
66
|
+
return rate_limit_response(retry_after(last_request, window.to_f, now))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
next_data = if now - last_request > window.to_f
|
|
70
|
+
rate_limit_data(key, 1, now)
|
|
71
|
+
else
|
|
72
|
+
rate_limit_data(key, count + 1, now)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
write_storage(storage, key, next_data, ttl: window, update: true)
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def rate_limit_response(retry_after)
|
|
82
|
+
[
|
|
83
|
+
429,
|
|
84
|
+
{"content-type" => "application/json", "x-retry-after" => retry_after.to_s},
|
|
85
|
+
[JSON.generate({message: "Too many requests. Please try again later."})]
|
|
86
|
+
]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def should_rate_limit?(max, window, count, last_request, now)
|
|
90
|
+
now - last_request < window && count >= max
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def retry_after(last_request, window, now)
|
|
94
|
+
[(last_request + window - now).ceil, 0].max
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def rate_limit_data(key, count, last_request)
|
|
98
|
+
{
|
|
99
|
+
key: key,
|
|
100
|
+
count: count,
|
|
101
|
+
last_request: last_request
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def rate_limit_rule(request, context, config, path)
|
|
106
|
+
rule = {
|
|
107
|
+
window: config[:window] || 10,
|
|
108
|
+
max: config[:max] || 100
|
|
109
|
+
}
|
|
110
|
+
rule = default_special_rule(path) || rule
|
|
111
|
+
rule = matching_plugin_rule(context, path) || rule
|
|
112
|
+
custom_rule = matching_custom_rule(config, path)
|
|
113
|
+
return resolve_custom_rule(custom_rule, request, rule) unless custom_rule.nil?
|
|
114
|
+
|
|
115
|
+
rule
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def default_special_rule(path)
|
|
119
|
+
return unless path.start_with?("/sign-in", "/sign-up", "/change-password", "/change-email")
|
|
120
|
+
|
|
121
|
+
{window: 10, max: 3}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def matching_custom_rule(config, path)
|
|
125
|
+
custom_rules = config[:custom_rules] || {}
|
|
126
|
+
custom_rules.find do |pattern, _rule|
|
|
127
|
+
path_matches?(pattern.to_s, path)
|
|
128
|
+
end&.last
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def resolve_custom_rule(rule, request, current)
|
|
132
|
+
return false if rule == false
|
|
133
|
+
return rule.call(request, current) if rule.respond_to?(:call)
|
|
134
|
+
|
|
135
|
+
rule || current
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def storage_for(context, config)
|
|
139
|
+
return [:custom, config[:custom_storage]] if config[:custom_storage]
|
|
140
|
+
|
|
141
|
+
if config[:storage] == "secondary-storage" && context.options.secondary_storage
|
|
142
|
+
return [:secondary, context.options.secondary_storage]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
[:memory, @memory_store]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def read_storage((type, storage), key)
|
|
149
|
+
data = storage.get(key)
|
|
150
|
+
data = JSON.parse(data) if type == :secondary && data.is_a?(String)
|
|
151
|
+
normalize_rate_limit_data(symbolize_keys(data))
|
|
152
|
+
rescue JSON::ParserError
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def write_storage((type, storage), key, data, ttl:, update:)
|
|
157
|
+
value = (type == :secondary) ? JSON.generate(secondary_storage_data(data)) : data
|
|
158
|
+
return call_secondary_storage_set(storage, key, value, ttl: ttl, update: update) if type == :secondary
|
|
159
|
+
|
|
160
|
+
call_storage_set(storage, key, value, ttl: ttl, update: update)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def secondary_storage_data(data)
|
|
164
|
+
{
|
|
165
|
+
key: data[:key],
|
|
166
|
+
count: data[:count],
|
|
167
|
+
lastRequest: (data[:last_request].to_f * 1000).to_i
|
|
168
|
+
}
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def call_secondary_storage_set(storage, key, value, ttl:, update:)
|
|
172
|
+
storage.set(key, value, ttl)
|
|
173
|
+
rescue ArgumentError
|
|
174
|
+
call_storage_set(storage, key, value, ttl: ttl, update: update)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def call_storage_set(storage, key, value, ttl:, update:)
|
|
178
|
+
storage.set(key, value, ttl: ttl, update: update)
|
|
179
|
+
rescue ArgumentError
|
|
180
|
+
begin
|
|
181
|
+
storage.set(key, value, ttl, update)
|
|
182
|
+
rescue ArgumentError
|
|
183
|
+
begin
|
|
184
|
+
storage.set(key, value, ttl)
|
|
185
|
+
rescue ArgumentError
|
|
186
|
+
storage.set(key, value)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def symbolize_keys(value)
|
|
192
|
+
return value unless value.is_a?(Hash)
|
|
193
|
+
|
|
194
|
+
value.each_with_object({}) do |(key, object_value), result|
|
|
195
|
+
result[key.to_s.gsub(/([a-z\d])([A-Z])/, "\\1_\\2").tr("-", "_").downcase.to_sym] = object_value
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def normalize_rate_limit_data(data)
|
|
200
|
+
return data unless data.is_a?(Hash)
|
|
201
|
+
|
|
202
|
+
last_request = data[:last_request]
|
|
203
|
+
return data unless last_request.is_a?(Numeric) && last_request > 10_000_000_000
|
|
204
|
+
|
|
205
|
+
data.merge(last_request: last_request / 1000.0)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def rate_limit_key(ip, path)
|
|
209
|
+
"#{ip}|#{path}"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def client_ip(request, options)
|
|
213
|
+
RequestIP.client_ip(request, options)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def matching_plugin_rule(context, path)
|
|
217
|
+
context.options.plugins
|
|
218
|
+
.flat_map { |plugin| Array(plugin[:rate_limit]) }
|
|
219
|
+
.find do |rule|
|
|
220
|
+
matcher = rule[:path_matcher]
|
|
221
|
+
matcher&.call(path)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def path_matches?(pattern, path)
|
|
226
|
+
return path == pattern unless pattern.include?("*")
|
|
227
|
+
|
|
228
|
+
regex = Regexp.escape(pattern).gsub("\\*", ".*")
|
|
229
|
+
/\A#{regex}\z/.match?(path)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
|
|
5
|
+
module BetterAuth
|
|
6
|
+
module RequestIP
|
|
7
|
+
LOCALHOST_IP = "127.0.0.1"
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def client_ip(request, options)
|
|
12
|
+
ip_options = options.advanced[:ip_address] || {}
|
|
13
|
+
return nil if ip_options[:disable_ip_tracking]
|
|
14
|
+
|
|
15
|
+
Array(ip_options[:ip_address_headers] || ["x-forwarded-for"]).each do |header|
|
|
16
|
+
value = header_value(request, header)
|
|
17
|
+
next unless value.is_a?(String)
|
|
18
|
+
|
|
19
|
+
ip = value.split(",").first.to_s.strip
|
|
20
|
+
return normalize_ip(ip, ipv6_subnet: ip_options[:ipv6_subnet]) if valid_ip?(ip)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
ip = fallback_ip(request)
|
|
24
|
+
return normalize_ip(ip, ipv6_subnet: ip_options[:ipv6_subnet]) if valid_ip?(ip)
|
|
25
|
+
|
|
26
|
+
LOCALHOST_IP if test_or_development?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def header_value(request, header)
|
|
30
|
+
return request.get_header(rack_header_name(header)) if request.respond_to?(:get_header)
|
|
31
|
+
return request.headers[header.to_s.downcase] if request.respond_to?(:headers)
|
|
32
|
+
return request[header.to_s.downcase] || request[header.to_s] || request[header.to_sym] if request.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def fallback_ip(request)
|
|
38
|
+
return request.ip.to_s if request.respond_to?(:ip)
|
|
39
|
+
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def rack_header_name(header)
|
|
44
|
+
"HTTP_#{header.to_s.upcase.tr("-", "_")}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def valid_ip?(ip)
|
|
48
|
+
return false if ip.to_s.empty? || ip.to_s.match?(/\s/)
|
|
49
|
+
|
|
50
|
+
IPAddr.new(ip)
|
|
51
|
+
true
|
|
52
|
+
rescue ArgumentError
|
|
53
|
+
false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def normalize_ip(ip, ipv6_subnet: nil)
|
|
57
|
+
address = IPAddr.new(ip)
|
|
58
|
+
return address.native.to_s if address.respond_to?(:ipv4_mapped?) && address.ipv4_mapped?
|
|
59
|
+
return address.to_s if address.ipv4?
|
|
60
|
+
|
|
61
|
+
address.mask((ipv6_subnet || 64).to_i).to_s
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def test_or_development?
|
|
65
|
+
["test", "development"].include?(ENV["RACK_ENV"]) ||
|
|
66
|
+
["test", "development"].include?(ENV["RAILS_ENV"]) ||
|
|
67
|
+
["test", "development"].include?(ENV["APP_ENV"])
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|