api_authentication_gem 0.1.4 → 0.4.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/LICENSE.txt +10 -0
- data/README.md +420 -73
- data/lib/api_authentication_gem/auth.rb +422 -26
- data/lib/api_authentication_gem/concerns/authenticatable.rb +79 -0
- data/lib/api_authentication_gem/configuration.rb +107 -4
- data/lib/api_authentication_gem/errors.rb +5 -0
- data/lib/api_authentication_gem/models/auth_session.rb +18 -0
- data/lib/api_authentication_gem/models/lockout.rb +21 -0
- data/lib/api_authentication_gem/models/magic_link.rb +22 -0
- data/lib/api_authentication_gem/models/password_reset.rb +23 -0
- data/lib/api_authentication_gem/models/refresh_token.rb +18 -0
- data/lib/api_authentication_gem/models/revoked_token.rb +19 -0
- data/lib/api_authentication_gem/version.rb +1 -1
- data/lib/api_authentication_gem.rb +14 -0
- metadata +106 -12
|
@@ -1,36 +1,432 @@
|
|
|
1
1
|
require "jwt"
|
|
2
|
+
require "openssl"
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
2
5
|
module ApiAuthenticationGem
|
|
3
6
|
class Auth
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
7
|
+
# =========================================================================
|
|
8
|
+
# signup
|
|
9
|
+
# =========================================================================
|
|
10
|
+
# Success: { message:, user:, token:, refresh_token: (if enabled) }
|
|
11
|
+
# Failure: { error: }
|
|
12
|
+
def self.signup(email:, password:, ip_address: nil, user_agent: nil)
|
|
13
|
+
config = ApiAuthenticationGem.configuration
|
|
14
|
+
|
|
15
|
+
return { error: "Email is required" } if email.to_s.strip.empty?
|
|
16
|
+
return { error: "Password must be at least #{config.min_password_length} characters" } \
|
|
17
|
+
if password.to_s.length < config.min_password_length
|
|
18
|
+
|
|
19
|
+
user_class = config.user_class.constantize
|
|
20
|
+
user = user_class.new(
|
|
21
|
+
config.email_attribute => email.to_s.strip.downcase,
|
|
22
|
+
config.password_attribute => password
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if user.save
|
|
26
|
+
access_token = generate_access_token(user.id, ip_address: ip_address, user_agent: user_agent)
|
|
27
|
+
result = { message: "User created successfully", user: user, token: access_token }
|
|
28
|
+
result[:refresh_token] = generate_refresh_token_record(user.id).token if config.refresh_tokens_enabled
|
|
29
|
+
call_hook(:on_signup, user)
|
|
30
|
+
result
|
|
31
|
+
else
|
|
32
|
+
{ error: user.errors.full_messages.join(", ") }
|
|
33
|
+
end
|
|
34
|
+
rescue ConfigurationError
|
|
35
|
+
raise
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
{ error: e.message }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# =========================================================================
|
|
41
|
+
# login
|
|
42
|
+
# =========================================================================
|
|
43
|
+
# Success: { token:, user:, refresh_token: (if enabled) }
|
|
44
|
+
# Failure: { error: }
|
|
45
|
+
def self.login(email:, password:, ip_address: nil, user_agent: nil)
|
|
46
|
+
return { error: "Email and password are required" } \
|
|
47
|
+
if email.to_s.strip.empty? || password.to_s.strip.empty?
|
|
48
|
+
|
|
49
|
+
config = ApiAuthenticationGem.configuration
|
|
50
|
+
normalized = email.to_s.strip.downcase
|
|
51
|
+
|
|
52
|
+
if config.lockout_enabled
|
|
53
|
+
lockout = Lockout.find_or_initialize_by(identifier: normalized)
|
|
54
|
+
return { error: "Account locked. Try again in #{lockout.minutes_remaining} minute(s)." } if lockout.locked?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
user_class = config.user_class.constantize
|
|
58
|
+
user = user_class.find_by(config.identifier => normalized)
|
|
59
|
+
|
|
60
|
+
if user.nil?
|
|
61
|
+
record_failed_attempt(normalized) if config.lockout_enabled
|
|
62
|
+
call_hook(:on_login_failure, normalized)
|
|
63
|
+
return { error: "Invalid email or password" }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
unless user.respond_to?(config.password_verify_method)
|
|
67
|
+
raise ConfigurationError, "User model must respond to `#{config.password_verify_method}`"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
unless user.public_send(config.password_verify_method, password)
|
|
71
|
+
record_failed_attempt(normalized) if config.lockout_enabled
|
|
72
|
+
call_hook(:on_login_failure, normalized)
|
|
73
|
+
return { error: "Invalid email or password" }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
clear_lockout(normalized) if config.lockout_enabled
|
|
77
|
+
|
|
78
|
+
access_token = generate_access_token(user.id, ip_address: ip_address, user_agent: user_agent)
|
|
79
|
+
result = { token: access_token, user: user }
|
|
80
|
+
result[:refresh_token] = generate_refresh_token_record(user.id).token if config.refresh_tokens_enabled
|
|
81
|
+
call_hook(:on_login, user)
|
|
82
|
+
result
|
|
83
|
+
rescue ConfigurationError
|
|
84
|
+
raise
|
|
85
|
+
rescue StandardError => e
|
|
86
|
+
{ error: e.message }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# =========================================================================
|
|
90
|
+
# refresh
|
|
91
|
+
# =========================================================================
|
|
92
|
+
# Rotates tokens: revokes old refresh token, issues new access + refresh pair.
|
|
93
|
+
# Success: { token:, refresh_token:, user: }
|
|
94
|
+
# Failure: { error: }
|
|
95
|
+
def self.refresh(refresh_token:, ip_address: nil, user_agent: nil)
|
|
96
|
+
return { error: "Refresh token is required" } if refresh_token.to_s.strip.empty?
|
|
97
|
+
|
|
98
|
+
config = ApiAuthenticationGem.configuration
|
|
99
|
+
return { error: "Refresh tokens are not enabled" } unless config.refresh_tokens_enabled
|
|
100
|
+
|
|
101
|
+
record = RefreshToken.find_by(token: refresh_token)
|
|
102
|
+
return { error: "Invalid refresh token" } unless record
|
|
103
|
+
return { error: "Refresh token has been revoked" } if record.revoked_at
|
|
104
|
+
return { error: "Refresh token has expired" } if record.expires_at <= Time.now
|
|
105
|
+
|
|
106
|
+
record.revoke!
|
|
107
|
+
|
|
108
|
+
user_class = config.user_class.constantize
|
|
109
|
+
user = user_class.find_by(id: record.user_id)
|
|
110
|
+
return { error: "User not found" } unless user
|
|
111
|
+
|
|
112
|
+
new_access = generate_access_token(user.id, ip_address: ip_address, user_agent: user_agent)
|
|
113
|
+
new_refresh = generate_refresh_token_record(user.id)
|
|
114
|
+
|
|
115
|
+
{ token: new_access, refresh_token: new_refresh.token, user: user }
|
|
116
|
+
rescue StandardError => e
|
|
117
|
+
{ error: e.message }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# =========================================================================
|
|
121
|
+
# revoke
|
|
122
|
+
# =========================================================================
|
|
123
|
+
# Success: { message: }
|
|
124
|
+
# Failure: { error: }
|
|
125
|
+
def self.revoke(token:)
|
|
126
|
+
return { error: "Token is required" } if token.to_s.strip.empty?
|
|
127
|
+
|
|
128
|
+
config = ApiAuthenticationGem.configuration
|
|
129
|
+
return { error: "Token revocation is not enabled" } unless config.revocation_enabled
|
|
130
|
+
|
|
131
|
+
decoded = JWT.decode(token, verification_key, true, { algorithm: config.algorithm }).first
|
|
132
|
+
|
|
133
|
+
RevokedToken.find_or_create_by!(jti: decoded["jti"]) do |r|
|
|
134
|
+
r.expires_at = Time.at(decoded["exp"])
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Mirror into session tracker if enabled
|
|
138
|
+
AuthSession.find_by(jti: decoded["jti"])&.revoke! if config.session_tracking_enabled
|
|
139
|
+
|
|
140
|
+
call_hook(:on_logout, decoded["jti"])
|
|
141
|
+
{ message: "Token revoked successfully" }
|
|
142
|
+
rescue JWT::ExpiredSignature
|
|
143
|
+
{ error: "Cannot revoke an already-expired token" }
|
|
144
|
+
rescue JWT::DecodeError
|
|
145
|
+
{ error: "Invalid token" }
|
|
146
|
+
rescue StandardError => e
|
|
147
|
+
{ error: e.message }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# =========================================================================
|
|
151
|
+
# request_password_reset
|
|
152
|
+
# =========================================================================
|
|
153
|
+
def self.request_password_reset(email:)
|
|
154
|
+
return { error: "Email is required" } if email.to_s.strip.empty?
|
|
155
|
+
|
|
156
|
+
config = ApiAuthenticationGem.configuration
|
|
157
|
+
normalized = email.to_s.strip.downcase
|
|
158
|
+
base_msg = { message: "If that email is registered, a reset token has been sent." }
|
|
159
|
+
|
|
160
|
+
user_class = config.user_class.constantize
|
|
161
|
+
user = user_class.find_by(config.identifier => normalized)
|
|
162
|
+
return base_msg unless user
|
|
163
|
+
|
|
164
|
+
PasswordReset.where(identifier: normalized, used_at: nil).update_all(used_at: Time.now)
|
|
165
|
+
|
|
166
|
+
reset = PasswordReset.create!(
|
|
167
|
+
identifier: normalized,
|
|
168
|
+
token: SecureRandom.urlsafe_base64(32),
|
|
169
|
+
expires_at: Time.now + config.password_reset_expiry
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
base_msg.merge(reset_token: reset.token)
|
|
173
|
+
rescue StandardError => e
|
|
174
|
+
{ error: e.message }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# =========================================================================
|
|
178
|
+
# reset_password
|
|
179
|
+
# =========================================================================
|
|
180
|
+
def self.reset_password(reset_token:, new_password:)
|
|
181
|
+
return { error: "Reset token is required" } if reset_token.to_s.strip.empty?
|
|
182
|
+
|
|
183
|
+
config = ApiAuthenticationGem.configuration
|
|
184
|
+
|
|
185
|
+
if new_password.to_s.length < config.min_password_length
|
|
186
|
+
return { error: "Password must be at least #{config.min_password_length} characters" }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
record = PasswordReset.active.find_by(token: reset_token)
|
|
190
|
+
return { error: "Invalid or expired reset token" } unless record
|
|
191
|
+
|
|
192
|
+
user_class = config.user_class.constantize
|
|
193
|
+
user = user_class.find_by(config.identifier => record.identifier)
|
|
194
|
+
return { error: "User not found" } unless user
|
|
195
|
+
|
|
196
|
+
if user.update(config.password_attribute => new_password)
|
|
197
|
+
record.consume!
|
|
198
|
+
call_hook(:on_password_reset, user)
|
|
199
|
+
{ message: "Password has been reset successfully" }
|
|
200
|
+
else
|
|
201
|
+
{ error: user.errors.full_messages.join(", ") }
|
|
202
|
+
end
|
|
203
|
+
rescue StandardError => e
|
|
204
|
+
{ error: e.message }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# =========================================================================
|
|
208
|
+
# send_magic_link (Tier 2)
|
|
209
|
+
# =========================================================================
|
|
210
|
+
# Generates a one-time login token the Rails app can email to the user.
|
|
211
|
+
# DO NOT expose magic_token directly in API responses.
|
|
212
|
+
# Success (known email): { message:, magic_token: }
|
|
213
|
+
# Success (unknown email): { message: }
|
|
214
|
+
# Failure: { error: }
|
|
215
|
+
def self.send_magic_link(email:)
|
|
216
|
+
return { error: "Email is required" } if email.to_s.strip.empty?
|
|
217
|
+
|
|
218
|
+
config = ApiAuthenticationGem.configuration
|
|
219
|
+
return { error: "Magic link login is not enabled" } unless config.magic_link_enabled
|
|
220
|
+
|
|
221
|
+
normalized = email.to_s.strip.downcase
|
|
222
|
+
base_msg = { message: "If that email is registered, a magic link has been sent." }
|
|
223
|
+
|
|
224
|
+
user_class = config.user_class.constantize
|
|
225
|
+
user = user_class.find_by(config.identifier => normalized)
|
|
226
|
+
return base_msg unless user
|
|
227
|
+
|
|
228
|
+
# Invalidate any existing unused magic links for this email
|
|
229
|
+
MagicLink.where(identifier: normalized, used_at: nil).update_all(used_at: Time.now)
|
|
230
|
+
|
|
231
|
+
link = MagicLink.create!(
|
|
232
|
+
identifier: normalized,
|
|
233
|
+
token: SecureRandom.urlsafe_base64(32),
|
|
234
|
+
expires_at: Time.now + config.magic_link_expiry
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
base_msg.merge(magic_token: link.token)
|
|
238
|
+
rescue StandardError => e
|
|
239
|
+
{ error: e.message }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# =========================================================================
|
|
243
|
+
# verify_magic_link (Tier 2)
|
|
244
|
+
# =========================================================================
|
|
245
|
+
# Validates the one-time token and returns a JWT — no password required.
|
|
246
|
+
# Success: { token:, user:, refresh_token: (if enabled) }
|
|
247
|
+
# Failure: { error: }
|
|
248
|
+
def self.verify_magic_link(token:, ip_address: nil, user_agent: nil)
|
|
249
|
+
return { error: "Magic link token is required" } if token.to_s.strip.empty?
|
|
250
|
+
|
|
251
|
+
config = ApiAuthenticationGem.configuration
|
|
252
|
+
return { error: "Magic link login is not enabled" } unless config.magic_link_enabled
|
|
253
|
+
|
|
254
|
+
link = MagicLink.active.find_by(token: token)
|
|
255
|
+
return { error: "Invalid or expired magic link" } unless link
|
|
256
|
+
|
|
257
|
+
user_class = config.user_class.constantize
|
|
258
|
+
user = user_class.find_by(config.identifier => link.identifier)
|
|
259
|
+
return { error: "User not found" } unless user
|
|
260
|
+
|
|
261
|
+
link.consume!
|
|
262
|
+
|
|
263
|
+
access_token = generate_access_token(user.id, ip_address: ip_address, user_agent: user_agent)
|
|
264
|
+
result = { token: access_token, user: user }
|
|
265
|
+
result[:refresh_token] = generate_refresh_token_record(user.id).token if config.refresh_tokens_enabled
|
|
266
|
+
call_hook(:on_login, user)
|
|
267
|
+
result
|
|
268
|
+
rescue StandardError => e
|
|
269
|
+
{ error: e.message }
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# =========================================================================
|
|
273
|
+
# active_sessions (Tier 2)
|
|
274
|
+
# =========================================================================
|
|
275
|
+
# Returns all non-expired, non-revoked sessions for a user.
|
|
276
|
+
# Success: { sessions: [AuthSession, ...] }
|
|
277
|
+
# Failure: { error: }
|
|
278
|
+
def self.active_sessions(user_id:)
|
|
279
|
+
return { error: "Session tracking is not enabled" } \
|
|
280
|
+
unless ApiAuthenticationGem.configuration.session_tracking_enabled
|
|
281
|
+
|
|
282
|
+
{ sessions: AuthSession.where(user_id: user_id).active.order(created_at: :desc) }
|
|
283
|
+
rescue StandardError => e
|
|
284
|
+
{ error: e.message }
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# =========================================================================
|
|
288
|
+
# revoke_session (Tier 2)
|
|
289
|
+
# =========================================================================
|
|
290
|
+
# Revokes a specific session by jti; also adds to the revocation blocklist.
|
|
291
|
+
# Success: { message: }
|
|
292
|
+
# Failure: { error: }
|
|
293
|
+
def self.revoke_session(jti:)
|
|
294
|
+
config = ApiAuthenticationGem.configuration
|
|
295
|
+
return { error: "Session tracking is not enabled" } unless config.session_tracking_enabled
|
|
296
|
+
return { error: "JTI is required" } if jti.to_s.strip.empty?
|
|
297
|
+
|
|
298
|
+
session = AuthSession.find_by(jti: jti)
|
|
299
|
+
return { error: "Session not found" } unless session
|
|
300
|
+
|
|
301
|
+
session.revoke!
|
|
302
|
+
|
|
303
|
+
RevokedToken.find_or_create_by!(jti: jti) { |r| r.expires_at = session.expires_at } if config.revocation_enabled
|
|
304
|
+
|
|
305
|
+
call_hook(:on_logout, jti)
|
|
306
|
+
{ message: "Session revoked successfully" }
|
|
307
|
+
rescue StandardError => e
|
|
308
|
+
{ error: e.message }
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# =========================================================================
|
|
312
|
+
# revoke_all_sessions (Tier 2)
|
|
313
|
+
# =========================================================================
|
|
314
|
+
# Revokes every active session for a user — useful after password change or
|
|
315
|
+
# account compromise.
|
|
316
|
+
# Success: { message: "N session(s) revoked" }
|
|
317
|
+
# Failure: { error: }
|
|
318
|
+
def self.revoke_all_sessions(user_id:)
|
|
319
|
+
config = ApiAuthenticationGem.configuration
|
|
320
|
+
return { error: "Session tracking is not enabled" } unless config.session_tracking_enabled
|
|
321
|
+
|
|
322
|
+
sessions = AuthSession.where(user_id: user_id).active.to_a
|
|
323
|
+
sessions.each do |s|
|
|
324
|
+
s.revoke!
|
|
325
|
+
RevokedToken.find_or_create_by!(jti: s.jti) { |r| r.expires_at = s.expires_at } if config.revocation_enabled
|
|
326
|
+
call_hook(:on_logout, s.jti)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
{ message: "#{sessions.count} session(s) revoked" }
|
|
330
|
+
rescue StandardError => e
|
|
331
|
+
{ error: e.message }
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# =========================================================================
|
|
335
|
+
# decode
|
|
336
|
+
# =========================================================================
|
|
337
|
+
# Returns payload hash, or nil if invalid / expired / revoked.
|
|
22
338
|
def self.decode(token)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
339
|
+
return nil if token.to_s.strip.empty?
|
|
340
|
+
|
|
341
|
+
config = ApiAuthenticationGem.configuration
|
|
342
|
+
decoded = JWT.decode(
|
|
343
|
+
token,
|
|
344
|
+
verification_key,
|
|
345
|
+
true,
|
|
346
|
+
{ algorithm: config.algorithm }
|
|
347
|
+
).first
|
|
348
|
+
|
|
349
|
+
return nil if token_revoked?(decoded["jti"])
|
|
350
|
+
|
|
351
|
+
decoded
|
|
352
|
+
rescue JWT::ExpiredSignature, JWT::DecodeError
|
|
353
|
+
nil
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# =========================================================================
|
|
357
|
+
|
|
358
|
+
# =========================================================================
|
|
359
|
+
|
|
360
|
+
def self.signing_key
|
|
361
|
+
config = ApiAuthenticationGem.configuration
|
|
362
|
+
config.algorithm == "RS256" ? config.private_key : config.secret_key
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def self.verification_key
|
|
366
|
+
config = ApiAuthenticationGem.configuration
|
|
367
|
+
config.algorithm == "RS256" ? config.public_key : config.secret_key
|
|
27
368
|
end
|
|
28
369
|
|
|
29
|
-
|
|
370
|
+
def self.generate_access_token(user_id, ip_address: nil, user_agent: nil)
|
|
371
|
+
config = ApiAuthenticationGem.configuration
|
|
372
|
+
now = Time.now.to_i
|
|
373
|
+
jti = SecureRandom.uuid
|
|
374
|
+
|
|
375
|
+
payload = {
|
|
376
|
+
sub: user_id,
|
|
377
|
+
iat: now,
|
|
378
|
+
exp: now + config.token_expiry.to_i,
|
|
379
|
+
jti: jti
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if config.session_tracking_enabled
|
|
383
|
+
AuthSession.create!(
|
|
384
|
+
user_id: user_id,
|
|
385
|
+
jti: jti,
|
|
386
|
+
ip_address: ip_address,
|
|
387
|
+
user_agent: user_agent,
|
|
388
|
+
expires_at: Time.at(payload[:exp])
|
|
389
|
+
)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
JWT.encode(payload, signing_key, config.algorithm)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def self.generate_refresh_token_record(user_id)
|
|
396
|
+
config = ApiAuthenticationGem.configuration
|
|
397
|
+
RefreshToken.create!(
|
|
398
|
+
user_id: user_id,
|
|
399
|
+
token: SecureRandom.urlsafe_base64(32),
|
|
400
|
+
expires_at: Time.now + config.refresh_token_expiry.to_i
|
|
401
|
+
)
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def self.token_revoked?(jti)
|
|
405
|
+
return false unless ApiAuthenticationGem.configuration.revocation_enabled
|
|
406
|
+
|
|
407
|
+
RevokedToken.revoked?(jti)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def self.record_failed_attempt(identifier)
|
|
411
|
+
config = ApiAuthenticationGem.configuration
|
|
412
|
+
lockout = Lockout.find_or_initialize_by(identifier: identifier)
|
|
413
|
+
lockout.failed_attempts = (lockout.failed_attempts || 0) + 1
|
|
414
|
+
if lockout.failed_attempts >= config.max_login_attempts
|
|
415
|
+
lockout.locked_until = Time.now + config.lockout_duration
|
|
416
|
+
lockout.failed_attempts = 0
|
|
417
|
+
end
|
|
418
|
+
lockout.save!
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def self.clear_lockout(identifier)
|
|
422
|
+
Lockout.find_by(identifier: identifier)&.reset!
|
|
423
|
+
end
|
|
30
424
|
|
|
31
|
-
def self.
|
|
32
|
-
|
|
33
|
-
|
|
425
|
+
def self.call_hook(hook_name, *args)
|
|
426
|
+
hook = ApiAuthenticationGem.configuration.public_send(hook_name)
|
|
427
|
+
hook.call(*args) if hook.respond_to?(:call)
|
|
428
|
+
rescue StandardError => _e
|
|
429
|
+
# Hook errors must never interrupt the auth flow
|
|
34
430
|
end
|
|
35
431
|
end
|
|
36
432
|
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
module ApiAuthenticationGem
|
|
2
|
+
module Concerns
|
|
3
|
+
module Authenticatable
|
|
4
|
+
def self.included(base)
|
|
5
|
+
base.helper_method :current_user if base.respond_to?(:helper_method)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
# Decodes the Authorization header, sets @current_user.
|
|
9
|
+
# Renders 401 and returns false on any failure — the action never runs.
|
|
10
|
+
def authenticate_user!
|
|
11
|
+
token = extract_bearer_token
|
|
12
|
+
unless token
|
|
13
|
+
render json: { error: "Authorization token missing" }, status: :unauthorized
|
|
14
|
+
return false
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
decoded = ApiAuthenticationGem::Auth.decode(token)
|
|
18
|
+
unless decoded
|
|
19
|
+
render json: { error: "Invalid or expired token" }, status: :unauthorized
|
|
20
|
+
return false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
user_id = decoded["sub"] || decoded["user_id"]
|
|
24
|
+
user_class = ApiAuthenticationGem.configuration.user_class.constantize
|
|
25
|
+
@current_user = user_class.find_by(id: user_id)
|
|
26
|
+
|
|
27
|
+
unless @current_user
|
|
28
|
+
render json: { error: "User not found" }, status: :unauthorized
|
|
29
|
+
return false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Role-based access control guard.
|
|
36
|
+
# Call after authenticate_user! has already set @current_user.
|
|
37
|
+
#
|
|
38
|
+
# Usage:
|
|
39
|
+
# before_action -> { authorize_role!(:admin) }
|
|
40
|
+
# before_action -> { authorize_role!(:admin, :moderator) }
|
|
41
|
+
#
|
|
42
|
+
# Returns true when allowed; renders 403 and returns false otherwise.
|
|
43
|
+
def authorize_role!(*allowed_roles)
|
|
44
|
+
unless current_user
|
|
45
|
+
render json: { error: "Not authenticated" }, status: :unauthorized
|
|
46
|
+
return false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
config = ApiAuthenticationGem.configuration
|
|
50
|
+
user_role = current_user.public_send(config.role_attribute).to_s
|
|
51
|
+
allowed = allowed_roles.map(&:to_s)
|
|
52
|
+
|
|
53
|
+
unless allowed.include?(user_role)
|
|
54
|
+
render json: {
|
|
55
|
+
error: "Forbidden: insufficient permissions",
|
|
56
|
+
required: allowed,
|
|
57
|
+
current: user_role
|
|
58
|
+
}, status: :forbidden
|
|
59
|
+
return false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
true
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def current_user
|
|
66
|
+
@current_user
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def extract_bearer_token
|
|
72
|
+
header = request.headers["Authorization"]
|
|
73
|
+
return nil unless header.present?
|
|
74
|
+
|
|
75
|
+
header.start_with?("Bearer ") ? header.split(" ", 2).last.strip : header.strip
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -1,11 +1,114 @@
|
|
|
1
1
|
module ApiAuthenticationGem
|
|
2
2
|
class Configuration
|
|
3
|
-
attr_accessor :secret_key,
|
|
3
|
+
attr_accessor :secret_key,
|
|
4
|
+
:user_class,
|
|
5
|
+
:email_attribute,
|
|
6
|
+
:password_attribute,
|
|
7
|
+
:identifier,
|
|
8
|
+
:password_verify_method,
|
|
9
|
+
:token_expiry,
|
|
10
|
+
:min_password_length,
|
|
11
|
+
# --- Tier 1 ---
|
|
12
|
+
:refresh_tokens_enabled,
|
|
13
|
+
:refresh_token_expiry,
|
|
14
|
+
:revocation_enabled,
|
|
15
|
+
:password_reset_expiry,
|
|
16
|
+
:lockout_enabled,
|
|
17
|
+
:max_login_attempts,
|
|
18
|
+
:lockout_duration,
|
|
19
|
+
# --- Tier 2: RBAC ---
|
|
20
|
+
:role_attribute,
|
|
21
|
+
# --- Tier 2: Magic Link ---
|
|
22
|
+
:magic_link_enabled,
|
|
23
|
+
:magic_link_expiry,
|
|
24
|
+
# --- Tier 2: Session Tracking ---
|
|
25
|
+
:session_tracking_enabled,
|
|
26
|
+
# --- Tier 2: Hooks ---
|
|
27
|
+
:on_signup,
|
|
28
|
+
:on_login,
|
|
29
|
+
:on_login_failure,
|
|
30
|
+
:on_logout,
|
|
31
|
+
:on_password_reset,
|
|
32
|
+
# --- Tier 2: Algorithm (HS256 or RS256) ---
|
|
33
|
+
:algorithm,
|
|
34
|
+
:private_key,
|
|
35
|
+
:public_key
|
|
4
36
|
|
|
5
37
|
def initialize
|
|
6
|
-
|
|
7
|
-
@
|
|
38
|
+
# Core
|
|
39
|
+
@secret_key = ENV.fetch("API_AUTH_SECRET_KEY", nil)
|
|
40
|
+
@user_class = "User"
|
|
41
|
+
@email_attribute = :email
|
|
42
|
+
@password_attribute = :password
|
|
43
|
+
@identifier = :email
|
|
44
|
+
@password_verify_method = :authenticate
|
|
45
|
+
@token_expiry = 24 * 60 * 60
|
|
46
|
+
@min_password_length = 6
|
|
47
|
+
|
|
48
|
+
# Tier 1
|
|
49
|
+
@refresh_tokens_enabled = false
|
|
50
|
+
@refresh_token_expiry = 30 * 24 * 60 * 60
|
|
51
|
+
@revocation_enabled = false
|
|
52
|
+
@password_reset_expiry = 15 * 60
|
|
53
|
+
@lockout_enabled = false
|
|
54
|
+
@max_login_attempts = 5
|
|
55
|
+
@lockout_duration = 15 * 60
|
|
56
|
+
|
|
57
|
+
# Tier 2: RBAC
|
|
58
|
+
@role_attribute = :role
|
|
59
|
+
|
|
60
|
+
# Tier 2: Magic Link
|
|
61
|
+
@magic_link_enabled = false
|
|
62
|
+
@magic_link_expiry = 15 * 60
|
|
63
|
+
|
|
64
|
+
# Tier 2: Session Tracking
|
|
65
|
+
@session_tracking_enabled = false
|
|
66
|
+
|
|
67
|
+
# Tier 2: Hooks (nil = disabled)
|
|
68
|
+
@on_signup = nil
|
|
69
|
+
@on_login = nil
|
|
70
|
+
@on_login_failure = nil
|
|
71
|
+
@on_logout = nil
|
|
72
|
+
@on_password_reset = nil
|
|
73
|
+
|
|
74
|
+
# Tier 2: Algorithm
|
|
75
|
+
@algorithm = "HS256"
|
|
76
|
+
@private_key = nil
|
|
77
|
+
@public_key = nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def validate!
|
|
81
|
+
if algorithm == "HS256"
|
|
82
|
+
if secret_key.nil? || secret_key.to_s.strip.empty?
|
|
83
|
+
raise ConfigurationError,
|
|
84
|
+
"secret_key must be set. Use ENV['API_AUTH_SECRET_KEY'] or set it explicitly."
|
|
85
|
+
end
|
|
86
|
+
elsif algorithm == "RS256"
|
|
87
|
+
raise ConfigurationError, "private_key must be an OpenSSL::PKey::RSA instance for RS256" \
|
|
88
|
+
unless private_key.is_a?(OpenSSL::PKey::RSA)
|
|
89
|
+
raise ConfigurationError, "public_key must be an OpenSSL::PKey::RSA instance for RS256" \
|
|
90
|
+
unless public_key.is_a?(OpenSSL::PKey::RSA)
|
|
91
|
+
else
|
|
92
|
+
raise ConfigurationError, "algorithm must be 'HS256' or 'RS256' (got '#{algorithm}')."
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
raise ConfigurationError, "token_expiry must be a positive integer (seconds)." if token_expiry.to_i <= 0
|
|
96
|
+
raise ConfigurationError, "min_password_length must be a positive integer." if min_password_length.to_i <= 0
|
|
97
|
+
if refresh_tokens_enabled && refresh_token_expiry.to_i <= 0
|
|
98
|
+
raise ConfigurationError, "refresh_token_expiry must be a positive integer (seconds)."
|
|
99
|
+
end
|
|
100
|
+
if lockout_enabled && max_login_attempts.to_i <= 0
|
|
101
|
+
raise ConfigurationError, "max_login_attempts must be a positive integer."
|
|
102
|
+
end
|
|
103
|
+
if lockout_enabled && lockout_duration.to_i <= 0
|
|
104
|
+
raise ConfigurationError, "lockout_duration must be a positive integer (seconds)."
|
|
105
|
+
end
|
|
106
|
+
if password_reset_expiry.to_i <= 0
|
|
107
|
+
raise ConfigurationError, "password_reset_expiry must be a positive integer (seconds)."
|
|
108
|
+
end
|
|
109
|
+
if magic_link_enabled && magic_link_expiry.to_i <= 0
|
|
110
|
+
raise ConfigurationError, "magic_link_expiry must be a positive integer (seconds)."
|
|
111
|
+
end
|
|
8
112
|
end
|
|
9
113
|
end
|
|
10
114
|
end
|
|
11
|
-
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module ApiAuthenticationGem
|
|
2
|
+
class AuthSession < ::ActiveRecord::Base
|
|
3
|
+
self.table_name = "api_auth_sessions"
|
|
4
|
+
|
|
5
|
+
validates :user_id, :jti, :expires_at, presence: true
|
|
6
|
+
validates :jti, uniqueness: true
|
|
7
|
+
|
|
8
|
+
scope :active, -> { where(revoked_at: nil).where("expires_at > ?", Time.now) }
|
|
9
|
+
|
|
10
|
+
def active?
|
|
11
|
+
revoked_at.nil? && expires_at > Time.now
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def revoke!
|
|
15
|
+
update!(revoked_at: Time.now)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|