api_authentication_gem 0.1.3 → 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.
@@ -0,0 +1,432 @@
1
+ require "jwt"
2
+ require "openssl"
3
+ require "securerandom"
4
+
5
+ module ApiAuthenticationGem
6
+ class Auth
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.
338
+ def self.decode(token)
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
368
+ end
369
+
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
424
+
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
430
+ end
431
+ end
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
@@ -0,0 +1,114 @@
1
+ module ApiAuthenticationGem
2
+ class Configuration
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
36
+
37
+ def initialize
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
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,5 @@
1
+ module ApiAuthenticationGem
2
+ class Error < StandardError; end
3
+ class ConfigurationError < Error; end
4
+ class AuthenticationError < Error; end
5
+ end
@@ -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
@@ -0,0 +1,21 @@
1
+ module ApiAuthenticationGem
2
+ class Lockout < ::ActiveRecord::Base
3
+ self.table_name = "api_auth_lockouts"
4
+
5
+ validates :identifier, presence: true, uniqueness: true
6
+
7
+ def locked?
8
+ locked_until.present? && locked_until > Time.now
9
+ end
10
+
11
+ def minutes_remaining
12
+ return 0 unless locked?
13
+
14
+ ((locked_until - Time.now) / 60).ceil
15
+ end
16
+
17
+ def reset!
18
+ update!(failed_attempts: 0, locked_until: nil)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,22 @@
1
+ module ApiAuthenticationGem
2
+ class MagicLink < ::ActiveRecord::Base
3
+ self.table_name = "api_auth_magic_links"
4
+
5
+ validates :identifier, :token, :expires_at, presence: true
6
+ validates :token, uniqueness: true
7
+
8
+ scope :active, -> { where(used_at: nil).where("expires_at > ?", Time.now) }
9
+
10
+ def expired?
11
+ expires_at <= Time.now
12
+ end
13
+
14
+ def used?
15
+ !used_at.nil?
16
+ end
17
+
18
+ def consume!
19
+ update!(used_at: Time.now)
20
+ end
21
+ end
22
+ end