sessions 0.1.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 +7 -0
- data/.rubocop.yml +61 -0
- data/.simplecov +54 -0
- data/AGENTS.md +5 -0
- data/Appraisals +26 -0
- data/CHANGELOG.md +26 -0
- data/CLAUDE.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +454 -0
- data/Rakefile +32 -0
- data/app/assets/stylesheets/sessions.css +50 -0
- data/app/controllers/sessions/application_controller.rb +159 -0
- data/app/controllers/sessions/devices_controller.rb +48 -0
- data/app/helpers/sessions/engine_helper.rb +126 -0
- data/app/views/sessions/_device.html.erb +40 -0
- data/app/views/sessions/_devices.html.erb +34 -0
- data/app/views/sessions/_event.html.erb +13 -0
- data/app/views/sessions/_history.html.erb +20 -0
- data/app/views/sessions/devices/history.html.erb +5 -0
- data/app/views/sessions/devices/index.html.erb +15 -0
- data/config/locales/en.yml +59 -0
- data/config/locales/es.yml +59 -0
- data/config/routes.rb +17 -0
- data/docs/PRD.md +743 -0
- data/docs/research/01-carhey.md +250 -0
- data/docs/research/02-ecosystem.md +261 -0
- data/docs/research/03-rails-core.md +220 -0
- data/docs/research/04-devise-warden.md +249 -0
- data/docs/research/05-oauth.md +193 -0
- data/docs/research/06-prior-art.md +312 -0
- data/docs/research/07-device-detection.md +250 -0
- data/docs/research/08-rails8-landscape.md +216 -0
- data/docs/research/09-market-security.md +450 -0
- data/gemfiles/rails_7.1.gemfile +34 -0
- data/gemfiles/rails_7.2.gemfile +34 -0
- data/gemfiles/rails_8.0.gemfile +34 -0
- data/gemfiles/rails_8.1.gemfile +34 -0
- data/lib/generators/sessions/install_generator.rb +230 -0
- data/lib/generators/sessions/madmin_generator.rb +95 -0
- data/lib/generators/sessions/templates/add_sessions_columns.rb.erb +81 -0
- data/lib/generators/sessions/templates/create_sessions.rb.erb +101 -0
- data/lib/generators/sessions/templates/create_sessions_events.rb.erb +108 -0
- data/lib/generators/sessions/templates/initializer.rb +201 -0
- data/lib/generators/sessions/templates/madmin/event_resource.rb +77 -0
- data/lib/generators/sessions/templates/madmin/session_events_controller.rb +21 -0
- data/lib/generators/sessions/templates/madmin/session_resource.rb +65 -0
- data/lib/generators/sessions/templates/madmin/sessions_controller.rb +30 -0
- data/lib/generators/sessions/templates/session.rb.erb +14 -0
- data/lib/generators/sessions/templates/sessions_sweep_job.rb +20 -0
- data/lib/generators/sessions/views_generator.rb +33 -0
- data/lib/sessions/adapters/omakase.rb +195 -0
- data/lib/sessions/adapters/omniauth.rb +64 -0
- data/lib/sessions/adapters/warden.rb +293 -0
- data/lib/sessions/classifier.rb +208 -0
- data/lib/sessions/configuration.rb +441 -0
- data/lib/sessions/current.rb +20 -0
- data/lib/sessions/device.rb +411 -0
- data/lib/sessions/engine.rb +120 -0
- data/lib/sessions/errors.rb +24 -0
- data/lib/sessions/geolocation.rb +111 -0
- data/lib/sessions/ip_address.rb +56 -0
- data/lib/sessions/jobs/geolocate_job.rb +58 -0
- data/lib/sessions/macros.rb +26 -0
- data/lib/sessions/middleware.rb +41 -0
- data/lib/sessions/models/concerns/device_display.rb +134 -0
- data/lib/sessions/models/concerns/has_sessions.rb +116 -0
- data/lib/sessions/models/concerns/model.rb +513 -0
- data/lib/sessions/models/event.rb +293 -0
- data/lib/sessions/version.rb +5 -0
- data/lib/sessions.rb +423 -0
- metadata +225 -0
data/lib/sessions.rb
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
require "active_support"
|
|
7
|
+
require "active_support/core_ext/integer/time"
|
|
8
|
+
require "active_support/core_ext/object/blank"
|
|
9
|
+
require "active_support/core_ext/object/try"
|
|
10
|
+
require "active_support/core_ext/enumerable"
|
|
11
|
+
require "active_support/security_utils"
|
|
12
|
+
|
|
13
|
+
require_relative "sessions/version"
|
|
14
|
+
require_relative "sessions/errors"
|
|
15
|
+
require_relative "sessions/configuration"
|
|
16
|
+
require_relative "sessions/current"
|
|
17
|
+
require_relative "sessions/ip_address"
|
|
18
|
+
require_relative "sessions/device"
|
|
19
|
+
require_relative "sessions/classifier"
|
|
20
|
+
require_relative "sessions/geolocation"
|
|
21
|
+
require_relative "sessions/middleware"
|
|
22
|
+
require_relative "sessions/macros"
|
|
23
|
+
require_relative "sessions/adapters/omakase"
|
|
24
|
+
require_relative "sessions/adapters/warden"
|
|
25
|
+
require_relative "sessions/adapters/omniauth"
|
|
26
|
+
|
|
27
|
+
require_relative "sessions/engine" if defined?(::Rails::Engine)
|
|
28
|
+
|
|
29
|
+
# == Sessions
|
|
30
|
+
#
|
|
31
|
+
# Every session, every device, every login — tracked, revocable, visible.
|
|
32
|
+
# The missing session layer for Rails.
|
|
33
|
+
#
|
|
34
|
+
# The public surface is intentionally tiny:
|
|
35
|
+
#
|
|
36
|
+
# Sessions.configure { |config| ... } # one block, in an initializer
|
|
37
|
+
# has_sessions # on your auth model
|
|
38
|
+
#
|
|
39
|
+
# current_user.sessions.active # live devices
|
|
40
|
+
# session.device_name # => "Chrome on macOS"
|
|
41
|
+
# session.revoke! # remote logout, effective next request
|
|
42
|
+
# current_user.revoke_other_sessions! # GitHub's "sign out everywhere else"
|
|
43
|
+
# current_user.session_history.failed_logins # the trail, identity-matched failures included
|
|
44
|
+
#
|
|
45
|
+
# Plus a handful of request-side seams for flows that can't self-identify:
|
|
46
|
+
#
|
|
47
|
+
# Sessions.tag(request, method: :passkey) # label the upcoming login
|
|
48
|
+
# Sessions.skip!(request) # "neither a login nor a failure" (2FA handoffs)
|
|
49
|
+
# Sessions.current(request) # this request's session row
|
|
50
|
+
# Sessions.last_login(request) # how this browser last signed in ("Last used" badge)
|
|
51
|
+
# Sessions.record_failed_attempt(request, identity: params[:email], reason: :invalid_password)
|
|
52
|
+
# Sessions.track_login(user, request, method: :sso)
|
|
53
|
+
#
|
|
54
|
+
# Everything else (adapters, the devices page, the sweep) ships with the
|
|
55
|
+
# engine and stays out of your way. One rule above all: tracking NEVER
|
|
56
|
+
# breaks authentication — every recording path in this gem is
|
|
57
|
+
# error-isolated (see Sessions.safely).
|
|
58
|
+
module Sessions
|
|
59
|
+
# The signed browser-continuity cookie (see Sessions::Model — minted at
|
|
60
|
+
# login, identifies the browser install so repeat logins replace their
|
|
61
|
+
# old device row instead of stacking duplicates).
|
|
62
|
+
DEVICE_COOKIE = :sessions_device_id
|
|
63
|
+
|
|
64
|
+
# The rack env flag `Sessions.skip!` sets — every recording seam checks
|
|
65
|
+
# it before writing anything for the request.
|
|
66
|
+
SKIP_ENV_KEY = "sessions.skip"
|
|
67
|
+
|
|
68
|
+
class << self
|
|
69
|
+
# --- Configuration --------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def config
|
|
72
|
+
@config ||= Configuration.new
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
alias configuration config
|
|
76
|
+
|
|
77
|
+
def configure
|
|
78
|
+
yield config if block_given?
|
|
79
|
+
config.validate!
|
|
80
|
+
config
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Reset all global state. Used by the test suite to keep examples
|
|
84
|
+
# isolated; also handy in a console when experimenting.
|
|
85
|
+
def reset!
|
|
86
|
+
@config = Configuration.new
|
|
87
|
+
self
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The host's session-of-record model (`Session` on both supported
|
|
91
|
+
# stacks; `config.session_class` is the escape hatch).
|
|
92
|
+
def session_model
|
|
93
|
+
config.session_model
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# --- Request-side API -----------------------------------------------------
|
|
97
|
+
|
|
98
|
+
# Label the login that's about to happen on this request — for flows
|
|
99
|
+
# that can't self-identify at the session-row level (Google One Tap,
|
|
100
|
+
# passkeys, magic links, custom SSO). Call it BEFORE signing the user
|
|
101
|
+
# in; the classification pipeline gives explicit tags top priority.
|
|
102
|
+
#
|
|
103
|
+
# Sessions.tag(request, method: :google_one_tap, detail: { select_by: params[:select_by] })
|
|
104
|
+
# Sessions.tag(request, method: :passkey, detail: { user_verified: true })
|
|
105
|
+
def tag(request, method:, provider: nil, detail: {})
|
|
106
|
+
return unless request
|
|
107
|
+
|
|
108
|
+
request.env[Classifier::TAG_ENV_KEY] = { method: method, provider: provider, detail: detail }
|
|
109
|
+
request
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Silence tracking for THIS request — the escape hatch for flows that
|
|
113
|
+
# intentionally end with neither a session nor a failure. The canonical
|
|
114
|
+
# case is the password phase of a two-phase 2FA challenge
|
|
115
|
+
# (authentication-zero's --two-factor, hand-rolled TOTP gates): the
|
|
116
|
+
# password was RIGHT, the controller redirects to the challenge, and
|
|
117
|
+
# recording a failed_login there would be a lie:
|
|
118
|
+
#
|
|
119
|
+
# if user.otp_required_for_sign_in?
|
|
120
|
+
# Sessions.skip!(request)
|
|
121
|
+
# session[:challenge_token] = user.signed_id(...)
|
|
122
|
+
# redirect_to new_two_factor_authentication_challenge_totp_path
|
|
123
|
+
# end
|
|
124
|
+
#
|
|
125
|
+
# Honored by every recording seam (both adapters, the failed-login
|
|
126
|
+
# heuristics). One request only — the challenge completion records
|
|
127
|
+
# normally.
|
|
128
|
+
def skip!(request)
|
|
129
|
+
return unless request
|
|
130
|
+
|
|
131
|
+
request.env[SKIP_ENV_KEY] = true
|
|
132
|
+
request
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The registry row for this request — works on both adapters:
|
|
136
|
+
# omakase (Current.session / the signed session cookie) and
|
|
137
|
+
# Devise/Warden (the per-scope token stashed in the warden session).
|
|
138
|
+
# Returns nil when the request carries no live tracked session.
|
|
139
|
+
#
|
|
140
|
+
# Multi-scope Devise apps (user + admin signed in on one rack session)
|
|
141
|
+
# carry one tracked row per scope; pass `scope:` to pick — without it
|
|
142
|
+
# you get the first live row found, which is unambiguous for the
|
|
143
|
+
# single-scope majority:
|
|
144
|
+
#
|
|
145
|
+
# Sessions.current(request, scope: :admin_user)
|
|
146
|
+
def current(request = Sessions::Current.request, scope: nil)
|
|
147
|
+
return nil unless request
|
|
148
|
+
|
|
149
|
+
safely("current") do
|
|
150
|
+
if scope
|
|
151
|
+
# An explicit scope is a Warden concept — answering it from
|
|
152
|
+
# Current.session (the omakase shortcut) would ignore it.
|
|
153
|
+
warden_current(request, scope: scope)
|
|
154
|
+
else
|
|
155
|
+
omakase_current(request) || warden_current(request) || cookie_current(request)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# The most recent login EVENT from THIS BROWSER — works on the login
|
|
161
|
+
# page, signed out, because the browser-continuity cookie (the same one
|
|
162
|
+
# that deduplicates devices) survives logout by design. This is the
|
|
163
|
+
# one-lookup answer behind the "Last used" badge next to your sign-in
|
|
164
|
+
# buttons:
|
|
165
|
+
#
|
|
166
|
+
# <% if (last = Sessions.last_login(request))&.auth_provider == "google" %>
|
|
167
|
+
# <span class="badge">Last used</span>
|
|
168
|
+
# <% end %>
|
|
169
|
+
#
|
|
170
|
+
# The event carries auth_method / auth_provider / auth_method_label /
|
|
171
|
+
# occurred_at ("last used 2 days ago"). Device-scoped, not
|
|
172
|
+
# account-scoped: it reflects whoever last signed in from this browser
|
|
173
|
+
# — exactly what a login page can honestly know. Returns nil for
|
|
174
|
+
# browsers that never signed in, cleared cookies, or tampered values
|
|
175
|
+
# (the cookie is signed). Read-only: never mints the cookie.
|
|
176
|
+
def last_login(request)
|
|
177
|
+
return nil unless request.respond_to?(:cookie_jar)
|
|
178
|
+
|
|
179
|
+
safely("last_login") do
|
|
180
|
+
device_id = request.cookie_jar.signed[DEVICE_COOKIE]
|
|
181
|
+
next nil if device_id.blank?
|
|
182
|
+
|
|
183
|
+
Sessions::Event.logins.where(device_id: device_id.to_s[0, 36])
|
|
184
|
+
.order(occurred_at: :desc).first
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Record a failed login attempt from a custom controller — the manual
|
|
189
|
+
# seam for flows outside Warden's failure app and the omakase
|
|
190
|
+
# SessionsController (a native-app sign-in branch, passkey
|
|
191
|
+
# verification rescues, One Tap token errors…).
|
|
192
|
+
#
|
|
193
|
+
# Sessions.record_failed_attempt(request, scope: :user,
|
|
194
|
+
# identity: params[:email],
|
|
195
|
+
# reason: :invalid_password)
|
|
196
|
+
#
|
|
197
|
+
# Never raises; returns the Sessions::Event or nil.
|
|
198
|
+
def record_failed_attempt(request, scope: nil, identity: nil, reason: nil,
|
|
199
|
+
method: nil, provider: nil, detail: {}, metadata: {})
|
|
200
|
+
return nil unless config.track_failed_logins
|
|
201
|
+
|
|
202
|
+
safely("record_failed_attempt") do
|
|
203
|
+
tag(request, method: method, provider: provider, detail: detail) if method
|
|
204
|
+
|
|
205
|
+
Sessions::Event.record_failure(
|
|
206
|
+
request,
|
|
207
|
+
scope: scope,
|
|
208
|
+
identity: identity,
|
|
209
|
+
reason: reason,
|
|
210
|
+
metadata: metadata
|
|
211
|
+
)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Fully manual integration: create (and classify, parse, geolocate) a
|
|
216
|
+
# registry row + login event for +user+ outside any adapter. The host
|
|
217
|
+
# owns linking the returned row to its own session mechanism and
|
|
218
|
+
# enforcing revocation. Never raises; returns the session row or nil.
|
|
219
|
+
def track_login(user, request, method: nil, provider: nil, detail: {})
|
|
220
|
+
safely("track_login") do
|
|
221
|
+
tag(request, method: method, provider: provider, detail: detail) if method
|
|
222
|
+
|
|
223
|
+
with_request(request) do
|
|
224
|
+
session_model.create!(
|
|
225
|
+
user: user,
|
|
226
|
+
ip_address: IpAddress.resolve(request),
|
|
227
|
+
user_agent: request&.user_agent
|
|
228
|
+
)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# --- Lifecycle ------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
# The maintenance pass the generated SessionsSweepJob runs on a
|
|
236
|
+
# schedule: expire idle/over-age sessions (only when timeouts are
|
|
237
|
+
# configured), evict per-user overflow beyond the session cap, and
|
|
238
|
+
# purge trail rows past retention. Each part is independently
|
|
239
|
+
# error-isolated. Returns a Hash of counts.
|
|
240
|
+
def sweep!
|
|
241
|
+
{
|
|
242
|
+
expired: safely("sweep.expired") { sweep_expired_sessions! } || 0,
|
|
243
|
+
pruned: safely("sweep.pruned") { sweep_session_overflow! } || 0,
|
|
244
|
+
purged_events: safely("sweep.events") { sweep_stale_events! } || 0
|
|
245
|
+
}
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Right-to-erasure helper: destroy every live session, delete the trail,
|
|
249
|
+
# and null the typed identity on any retained failure rows that match
|
|
250
|
+
# +user+'s email — so honoring a GDPR deletion request is one call.
|
|
251
|
+
def forget(user, identity: nil)
|
|
252
|
+
safely("forget") do
|
|
253
|
+
session_model.where(user: user).destroy_all if session_model_table?
|
|
254
|
+
Sessions::Event.where(authenticatable: user).delete_all
|
|
255
|
+
|
|
256
|
+
typed = identity || user.try(:email_address) || user.try(:email)
|
|
257
|
+
Sessions::Event.where(identity: Sessions::Event.normalize_identity(typed)).update_all(identity: nil) if typed
|
|
258
|
+
|
|
259
|
+
true
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# --- Internals (used by the adapters; stable but undocumented) -------------
|
|
264
|
+
|
|
265
|
+
# The error-isolation chokepoint: this gem sits on the authentication
|
|
266
|
+
# hot path, where a tracking bug may lose a log row but must NEVER 500 a
|
|
267
|
+
# sign-in (authtrail's `safely` pattern, ecosystem rule). Everything the
|
|
268
|
+
# adapters and model callbacks do goes through here.
|
|
269
|
+
def safely(context = nil)
|
|
270
|
+
yield
|
|
271
|
+
rescue StandardError => e
|
|
272
|
+
warn("#{context}: #{e.class}: #{e.message}")
|
|
273
|
+
nil
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def warn(message)
|
|
277
|
+
logger&.warn("[sessions] #{message}")
|
|
278
|
+
nil
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def logger
|
|
282
|
+
defined?(::Rails) && ::Rails.respond_to?(:logger) ? ::Rails.logger : nil
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# SHA-256 of a session token. High-entropy random input ⇒ a plain
|
|
286
|
+
# digest suffices (no pepper KDF theater); the raw token only ever
|
|
287
|
+
# lives in the user's own Rack session (OWASP: never persist raw
|
|
288
|
+
# session identifiers).
|
|
289
|
+
def token_digest(token)
|
|
290
|
+
OpenSSL::Digest::SHA256.hexdigest(token.to_s)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def generate_token
|
|
294
|
+
SecureRandom.hex(32)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Run a block with Sessions::Current.request temporarily set — lets
|
|
298
|
+
# explicit APIs reuse the same model-callback pipeline the adapters use.
|
|
299
|
+
def with_request(request)
|
|
300
|
+
previous = Sessions::Current.request
|
|
301
|
+
Sessions::Current.request = request
|
|
302
|
+
yield
|
|
303
|
+
ensure
|
|
304
|
+
Sessions::Current.request = previous
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# The catch-all `config.events` tee, error-isolated.
|
|
308
|
+
def notify_event(event)
|
|
309
|
+
safely("events hook") { config.events.call(event) }
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
private
|
|
313
|
+
|
|
314
|
+
def omakase_current(_request)
|
|
315
|
+
return nil unless defined?(::Current) && ::Current.respond_to?(:session)
|
|
316
|
+
|
|
317
|
+
session = ::Current.session
|
|
318
|
+
session if session.is_a?(session_model)
|
|
319
|
+
rescue StandardError
|
|
320
|
+
nil
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def warden_current(request, scope: nil)
|
|
324
|
+
return nil unless request.env["warden"]
|
|
325
|
+
|
|
326
|
+
pattern = scope ? /\Awarden\.user\.#{Regexp.escape(scope.to_s)}\.session\z/ : /\Awarden\.user\..+\.session\z/
|
|
327
|
+
request.session.to_hash.each do |key, value|
|
|
328
|
+
next unless key.to_s.match?(pattern) && value.is_a?(Hash)
|
|
329
|
+
|
|
330
|
+
id, token = value[Adapters::Warden::SESSION_KEY]
|
|
331
|
+
next unless id && token
|
|
332
|
+
|
|
333
|
+
row = session_model.find_by(id: id)
|
|
334
|
+
return row if row.respond_to?(:sessions_token_matches?) && row&.sessions_token_matches?(token)
|
|
335
|
+
end
|
|
336
|
+
nil
|
|
337
|
+
rescue StandardError
|
|
338
|
+
nil
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def cookie_current(request)
|
|
342
|
+
return nil unless request.respond_to?(:cookie_jar)
|
|
343
|
+
|
|
344
|
+
id = request.cookie_jar.signed[:session_id]
|
|
345
|
+
session_model.find_by(id: id) if id
|
|
346
|
+
rescue StandardError
|
|
347
|
+
nil
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def session_model_table?
|
|
351
|
+
session_model.table_exists?
|
|
352
|
+
rescue StandardError
|
|
353
|
+
false
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# --- Sweep internals --------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
def sweep_expired_sessions!
|
|
359
|
+
return 0 unless config.idle_timeout || config.max_session_lifetime
|
|
360
|
+
return 0 unless session_model_table?
|
|
361
|
+
|
|
362
|
+
count = 0
|
|
363
|
+
expired_sessions_scope.find_each do |session|
|
|
364
|
+
session.revoke!(reason: :expired)
|
|
365
|
+
count += 1
|
|
366
|
+
end
|
|
367
|
+
count
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def expired_sessions_scope
|
|
371
|
+
scopes = []
|
|
372
|
+
|
|
373
|
+
if (idle = config.idle_timeout)
|
|
374
|
+
threshold = idle.ago
|
|
375
|
+
# A session's last activity is its throttled touch when present,
|
|
376
|
+
# else its creation.
|
|
377
|
+
scopes << session_model.where(last_seen_at: ...threshold)
|
|
378
|
+
scopes << session_model.where(last_seen_at: nil).where(created_at: ...threshold)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
if (lifetime = config.max_session_lifetime)
|
|
382
|
+
scopes << session_model.where(created_at: ...lifetime.ago)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# NOTE: reduce(:or), never `none.or(...)` — a NullRelation stays null
|
|
386
|
+
# through #or and would silently sweep nothing.
|
|
387
|
+
scopes.empty? ? session_model.none : scopes.reduce(:or)
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def sweep_session_overflow!
|
|
391
|
+
cap = config.max_sessions_per_user
|
|
392
|
+
return 0 unless cap
|
|
393
|
+
return 0 unless session_model_table?
|
|
394
|
+
|
|
395
|
+
# Polymorphic installs (--polymorphic) key the owner by TYPE AND id —
|
|
396
|
+
# grouping by user_id alone would treat User#42 and Organization#42
|
|
397
|
+
# as one owner and evict across them.
|
|
398
|
+
owner_keys = session_model.column_names.include?("user_type") ? %i[user_type user_id] : %i[user_id]
|
|
399
|
+
|
|
400
|
+
count = 0
|
|
401
|
+
session_model.group(*owner_keys).count.each do |owner_key, sessions_count|
|
|
402
|
+
next if sessions_count <= cap
|
|
403
|
+
|
|
404
|
+
session_model.where(owner_keys.zip(Array(owner_key)).to_h)
|
|
405
|
+
.order(created_at: :asc)
|
|
406
|
+
.limit(sessions_count - cap)
|
|
407
|
+
.each do |session|
|
|
408
|
+
session.revoke!(reason: :pruned)
|
|
409
|
+
count += 1
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
count
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def sweep_stale_events!
|
|
416
|
+
retention = config.events_retention
|
|
417
|
+
return 0 unless retention
|
|
418
|
+
return 0 unless Sessions::Event.table_exists?
|
|
419
|
+
|
|
420
|
+
Sessions::Event.where(occurred_at: ...retention.ago).delete_all
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: sessions
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- rameerez
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-06-12 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: actionpack
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 7.1.0
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '9.0'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: 7.1.0
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '9.0'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: activerecord
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: 7.1.0
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '9.0'
|
|
42
|
+
type: :runtime
|
|
43
|
+
prerelease: false
|
|
44
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: 7.1.0
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '9.0'
|
|
52
|
+
- !ruby/object:Gem::Dependency
|
|
53
|
+
name: activesupport
|
|
54
|
+
requirement: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: 7.1.0
|
|
59
|
+
- - "<"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '9.0'
|
|
62
|
+
type: :runtime
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: 7.1.0
|
|
69
|
+
- - "<"
|
|
70
|
+
- !ruby/object:Gem::Version
|
|
71
|
+
version: '9.0'
|
|
72
|
+
- !ruby/object:Gem::Dependency
|
|
73
|
+
name: browser
|
|
74
|
+
requirement: !ruby/object:Gem::Requirement
|
|
75
|
+
requirements:
|
|
76
|
+
- - ">="
|
|
77
|
+
- !ruby/object:Gem::Version
|
|
78
|
+
version: '6.0'
|
|
79
|
+
type: :runtime
|
|
80
|
+
prerelease: false
|
|
81
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
82
|
+
requirements:
|
|
83
|
+
- - ">="
|
|
84
|
+
- !ruby/object:Gem::Version
|
|
85
|
+
version: '6.0'
|
|
86
|
+
- !ruby/object:Gem::Dependency
|
|
87
|
+
name: railties
|
|
88
|
+
requirement: !ruby/object:Gem::Requirement
|
|
89
|
+
requirements:
|
|
90
|
+
- - ">="
|
|
91
|
+
- !ruby/object:Gem::Version
|
|
92
|
+
version: 7.1.0
|
|
93
|
+
- - "<"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '9.0'
|
|
96
|
+
type: :runtime
|
|
97
|
+
prerelease: false
|
|
98
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - ">="
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: 7.1.0
|
|
103
|
+
- - "<"
|
|
104
|
+
- !ruby/object:Gem::Version
|
|
105
|
+
version: '9.0'
|
|
106
|
+
description: 'sessions gives any Rails 8+ app a GitHub-style "your devices" page (list
|
|
107
|
+
every active session, log out of one device, sign out everywhere else) plus an admin-grade,
|
|
108
|
+
append-only trail of every login attempt — successful and failed — with parsed device
|
|
109
|
+
intelligence ("Chrome on macOS", "MyApp 2.4.1 on Pixel 8 (Android 16)"), IP geolocation
|
|
110
|
+
(via the trackdown gem, soft dependency), and the auth method that started each
|
|
111
|
+
session (password, OAuth provider, passkey, magic link…). It decorates the session
|
|
112
|
+
storage your app already has instead of replacing it: on Rails 8 omakase auth (`rails
|
|
113
|
+
generate authentication`) it enriches the generated sessions table with zero app-code
|
|
114
|
+
changes, and on Devise it generalizes the proven session_limitable mechanism into
|
|
115
|
+
true per-device remote revocation via Warden hooks. It detects Hotwire Native apps
|
|
116
|
+
(platform, OS version, app version, device model), never breaks login (every tracking
|
|
117
|
+
path is error-isolated), ships privacy-first defaults (bounded retention with a
|
|
118
|
+
sweep job, optional IP truncation, no browser fingerprinting or invasive client-side
|
|
119
|
+
probing — device continuity is one signed first-party cookie, minted only at login),
|
|
120
|
+
and includes a mountable, i18n''d devices page you can restyle or eject view-by-view
|
|
121
|
+
like Devise.'
|
|
122
|
+
email:
|
|
123
|
+
- rubygems@rameerez.com
|
|
124
|
+
executables: []
|
|
125
|
+
extensions: []
|
|
126
|
+
extra_rdoc_files: []
|
|
127
|
+
files:
|
|
128
|
+
- ".rubocop.yml"
|
|
129
|
+
- ".simplecov"
|
|
130
|
+
- AGENTS.md
|
|
131
|
+
- Appraisals
|
|
132
|
+
- CHANGELOG.md
|
|
133
|
+
- CLAUDE.md
|
|
134
|
+
- LICENSE.txt
|
|
135
|
+
- README.md
|
|
136
|
+
- Rakefile
|
|
137
|
+
- app/assets/stylesheets/sessions.css
|
|
138
|
+
- app/controllers/sessions/application_controller.rb
|
|
139
|
+
- app/controllers/sessions/devices_controller.rb
|
|
140
|
+
- app/helpers/sessions/engine_helper.rb
|
|
141
|
+
- app/views/sessions/_device.html.erb
|
|
142
|
+
- app/views/sessions/_devices.html.erb
|
|
143
|
+
- app/views/sessions/_event.html.erb
|
|
144
|
+
- app/views/sessions/_history.html.erb
|
|
145
|
+
- app/views/sessions/devices/history.html.erb
|
|
146
|
+
- app/views/sessions/devices/index.html.erb
|
|
147
|
+
- config/locales/en.yml
|
|
148
|
+
- config/locales/es.yml
|
|
149
|
+
- config/routes.rb
|
|
150
|
+
- docs/PRD.md
|
|
151
|
+
- docs/research/01-carhey.md
|
|
152
|
+
- docs/research/02-ecosystem.md
|
|
153
|
+
- docs/research/03-rails-core.md
|
|
154
|
+
- docs/research/04-devise-warden.md
|
|
155
|
+
- docs/research/05-oauth.md
|
|
156
|
+
- docs/research/06-prior-art.md
|
|
157
|
+
- docs/research/07-device-detection.md
|
|
158
|
+
- docs/research/08-rails8-landscape.md
|
|
159
|
+
- docs/research/09-market-security.md
|
|
160
|
+
- gemfiles/rails_7.1.gemfile
|
|
161
|
+
- gemfiles/rails_7.2.gemfile
|
|
162
|
+
- gemfiles/rails_8.0.gemfile
|
|
163
|
+
- gemfiles/rails_8.1.gemfile
|
|
164
|
+
- lib/generators/sessions/install_generator.rb
|
|
165
|
+
- lib/generators/sessions/madmin_generator.rb
|
|
166
|
+
- lib/generators/sessions/templates/add_sessions_columns.rb.erb
|
|
167
|
+
- lib/generators/sessions/templates/create_sessions.rb.erb
|
|
168
|
+
- lib/generators/sessions/templates/create_sessions_events.rb.erb
|
|
169
|
+
- lib/generators/sessions/templates/initializer.rb
|
|
170
|
+
- lib/generators/sessions/templates/madmin/event_resource.rb
|
|
171
|
+
- lib/generators/sessions/templates/madmin/session_events_controller.rb
|
|
172
|
+
- lib/generators/sessions/templates/madmin/session_resource.rb
|
|
173
|
+
- lib/generators/sessions/templates/madmin/sessions_controller.rb
|
|
174
|
+
- lib/generators/sessions/templates/session.rb.erb
|
|
175
|
+
- lib/generators/sessions/templates/sessions_sweep_job.rb
|
|
176
|
+
- lib/generators/sessions/views_generator.rb
|
|
177
|
+
- lib/sessions.rb
|
|
178
|
+
- lib/sessions/adapters/omakase.rb
|
|
179
|
+
- lib/sessions/adapters/omniauth.rb
|
|
180
|
+
- lib/sessions/adapters/warden.rb
|
|
181
|
+
- lib/sessions/classifier.rb
|
|
182
|
+
- lib/sessions/configuration.rb
|
|
183
|
+
- lib/sessions/current.rb
|
|
184
|
+
- lib/sessions/device.rb
|
|
185
|
+
- lib/sessions/engine.rb
|
|
186
|
+
- lib/sessions/errors.rb
|
|
187
|
+
- lib/sessions/geolocation.rb
|
|
188
|
+
- lib/sessions/ip_address.rb
|
|
189
|
+
- lib/sessions/jobs/geolocate_job.rb
|
|
190
|
+
- lib/sessions/macros.rb
|
|
191
|
+
- lib/sessions/middleware.rb
|
|
192
|
+
- lib/sessions/models/concerns/device_display.rb
|
|
193
|
+
- lib/sessions/models/concerns/has_sessions.rb
|
|
194
|
+
- lib/sessions/models/concerns/model.rb
|
|
195
|
+
- lib/sessions/models/event.rb
|
|
196
|
+
- lib/sessions/version.rb
|
|
197
|
+
homepage: https://github.com/rameerez/sessions
|
|
198
|
+
licenses:
|
|
199
|
+
- MIT
|
|
200
|
+
metadata:
|
|
201
|
+
allowed_push_host: https://rubygems.org
|
|
202
|
+
source_code_uri: https://github.com/rameerez/sessions
|
|
203
|
+
changelog_uri: https://github.com/rameerez/sessions/blob/main/CHANGELOG.md
|
|
204
|
+
bug_tracker_uri: https://github.com/rameerez/sessions/issues
|
|
205
|
+
documentation_uri: https://github.com/rameerez/sessions#readme
|
|
206
|
+
rubygems_mfa_required: 'true'
|
|
207
|
+
rdoc_options: []
|
|
208
|
+
require_paths:
|
|
209
|
+
- lib
|
|
210
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
211
|
+
requirements:
|
|
212
|
+
- - ">="
|
|
213
|
+
- !ruby/object:Gem::Version
|
|
214
|
+
version: 3.2.0
|
|
215
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
216
|
+
requirements:
|
|
217
|
+
- - ">="
|
|
218
|
+
- !ruby/object:Gem::Version
|
|
219
|
+
version: '0'
|
|
220
|
+
requirements: []
|
|
221
|
+
rubygems_version: 3.6.2
|
|
222
|
+
specification_version: 4
|
|
223
|
+
summary: Session & login-activity tracking, device management, and remote revocation
|
|
224
|
+
for Rails
|
|
225
|
+
test_files: []
|