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
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sessions
|
|
4
|
+
# The registry concern — included into the host's session-of-record model
|
|
5
|
+
# (`Session`). On Rails 8 omakase apps the adapter includes it
|
|
6
|
+
# automatically at boot (the generated 2-line model stays untouched); in
|
|
7
|
+
# Devise mode the install generator writes a 3-line shell that includes it
|
|
8
|
+
# explicitly. Either way, ALL gem logic lives here, so the host's model
|
|
9
|
+
# file never goes stale.
|
|
10
|
+
#
|
|
11
|
+
# One mental model: **rows = active sessions; events = history.** A row is
|
|
12
|
+
# destroyed on logout/revocation/expiry (instant remote revocation — the
|
|
13
|
+
# same omakase semantics as Rails 8.1's own password-reset destroy_all),
|
|
14
|
+
# and its tombstone lives in the `sessions_events` trail.
|
|
15
|
+
#
|
|
16
|
+
# The three lifecycle callbacks observe 100% of both adapters' flows:
|
|
17
|
+
#
|
|
18
|
+
# before_create — enrich the row: normalize the IP, parse the
|
|
19
|
+
# device, capture client hints, classify the auth
|
|
20
|
+
# method, geolocate (when free).
|
|
21
|
+
# after_create_commit — write the `login` event, detect new devices,
|
|
22
|
+
# enforce the per-user cap, enqueue async geo.
|
|
23
|
+
# after_destroy_commit — write the `logout`/`revoked`/`expired` event.
|
|
24
|
+
#
|
|
25
|
+
# Every callback body is error-isolated: a parsing/geo/event failure may
|
|
26
|
+
# lose a log row; it may NEVER break a sign-in.
|
|
27
|
+
module Model
|
|
28
|
+
extend ActiveSupport::Concern
|
|
29
|
+
|
|
30
|
+
# device_name / location / country_flag / native predicates /
|
|
31
|
+
# auth_method_label — shared with Sessions::Event, which carries the
|
|
32
|
+
# same parsed device columns.
|
|
33
|
+
include Sessions::DeviceDisplay
|
|
34
|
+
|
|
35
|
+
# How long without activity before a session is grouped as "inactive"
|
|
36
|
+
# on the devices page (UI grouping only — never enforcement; expiry is
|
|
37
|
+
# the opt-in idle_timeout/max_session_lifetime pair).
|
|
38
|
+
INACTIVE_AFTER = 30.days
|
|
39
|
+
|
|
40
|
+
included do
|
|
41
|
+
# The Devise-mode shell model needs the association declared; the
|
|
42
|
+
# omakase host model already has `belongs_to :user` and is left
|
|
43
|
+
# untouched. Polymorphic when the table was generated with
|
|
44
|
+
# --polymorphic (detected by the user_type column).
|
|
45
|
+
unless reflect_on_association(:user)
|
|
46
|
+
if Sessions::Model.polymorphic_table?(self)
|
|
47
|
+
belongs_to :user, polymorphic: true
|
|
48
|
+
else
|
|
49
|
+
belongs_to :user
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Transient revocation context: set by `revoke!` (and the adapters'
|
|
54
|
+
# logout labeling) before the row is destroyed, read by the
|
|
55
|
+
# after_destroy_commit event writer.
|
|
56
|
+
attr_accessor :revocation_reason, :revoked_by
|
|
57
|
+
|
|
58
|
+
# Set on adopted rows (sessions that predate the gem) so adoption
|
|
59
|
+
# doesn't fabricate a `login` event in the trail.
|
|
60
|
+
attr_accessor :sessions_suppress_login_event
|
|
61
|
+
|
|
62
|
+
before_create :sessions_enrich
|
|
63
|
+
after_create_commit :sessions_record_login
|
|
64
|
+
after_destroy_commit :sessions_record_end
|
|
65
|
+
|
|
66
|
+
scope :by_recency, lambda {
|
|
67
|
+
# COALESCE keeps never-touched sessions ordered by creation time,
|
|
68
|
+
# portably across sqlite/postgres/mysql.
|
|
69
|
+
order(Arel.sql("COALESCE(#{table_name}.last_seen_at, #{table_name}.created_at) DESC"))
|
|
70
|
+
}
|
|
71
|
+
scope :active, lambda {
|
|
72
|
+
where("COALESCE(#{table_name}.last_seen_at, #{table_name}.created_at) >= ?", INACTIVE_AFTER.ago)
|
|
73
|
+
}
|
|
74
|
+
scope :inactive, lambda {
|
|
75
|
+
where("COALESCE(#{table_name}.last_seen_at, #{table_name}.created_at) < ?", INACTIVE_AFTER.ago)
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
class_methods do
|
|
80
|
+
# The user-facing trail rows linked to this registry (`session_id` is
|
|
81
|
+
# a plain column, no FK — history must survive row destruction).
|
|
82
|
+
def sessions_events_for(session_id)
|
|
83
|
+
Sessions::Event.where(session_id: session_id)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.polymorphic_table?(klass)
|
|
88
|
+
klass.table_exists? && klass.column_names.include?("user_type")
|
|
89
|
+
rescue StandardError
|
|
90
|
+
false
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# --- The candy ---------------------------------------------------------------
|
|
94
|
+
#
|
|
95
|
+
# device_name / location / country_flag / hotwire_native? / web? /
|
|
96
|
+
# via_oauth? / auth_method_label … live in Sessions::DeviceDisplay.
|
|
97
|
+
|
|
98
|
+
# The moment of last activity: the throttled touch when present, else
|
|
99
|
+
# sign-in time.
|
|
100
|
+
def last_active_at
|
|
101
|
+
try(:last_seen_at) || created_at
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# "Active now" — activity within the touch window. The window IS
|
|
105
|
+
# config.touch_every (last_seen_at lags by up to one throttle window by
|
|
106
|
+
# design), so the devices-page badge stays truthful whatever the host
|
|
107
|
+
# configured.
|
|
108
|
+
def active_now?(window = Sessions.config.touch_every || 5.minutes)
|
|
109
|
+
activity = last_active_at
|
|
110
|
+
activity.present? && activity > window.ago
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Whether this row is the one serving +request+ — powers the
|
|
114
|
+
# "This device" badge (which is also the row the devices page refuses
|
|
115
|
+
# to revoke).
|
|
116
|
+
def current?(request = Sessions::Current.request)
|
|
117
|
+
return false unless request
|
|
118
|
+
|
|
119
|
+
Sessions.current(request) == self
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Stamp the second factor onto an ALREADY-LIVE session — the affordance
|
|
123
|
+
# for step-up flows where the row exists before the challenge completes
|
|
124
|
+
# (a post-login TOTP gate, a WebAuthn step-up before sensitive areas):
|
|
125
|
+
#
|
|
126
|
+
# Sessions.current(request)&.second_factor!("totp")
|
|
127
|
+
#
|
|
128
|
+
# Flows that verify the second factor BEFORE the session exists
|
|
129
|
+
# (devise-two-factor, authentication-zero's challenge controllers,
|
|
130
|
+
# devise-otp) don't need this — they classify at login via the strategy
|
|
131
|
+
# map or a `Sessions.tag` call (see the README's two-factor recipes).
|
|
132
|
+
# Reading happens through `second_factor` / `second_factor?`.
|
|
133
|
+
def second_factor!(kind)
|
|
134
|
+
detail = (try(:auth_detail) || {}).to_h
|
|
135
|
+
update!(auth_detail: detail.merge("second_factor" => kind.to_s))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# --- Revocation -----------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
# Destroy this session — remote logout, effective on that device's very
|
|
141
|
+
# next request (both adapters validate liveness per request). Writes a
|
|
142
|
+
# `revoked` (or `expired`) event with the reason and actor, rotates the
|
|
143
|
+
# user's remember-me credentials in Devise mode (config.revoke_remember_me),
|
|
144
|
+
# and fires the on_session_revoked hook.
|
|
145
|
+
def revoke!(reason: :user_revoked, by: nil)
|
|
146
|
+
self.revocation_reason = reason
|
|
147
|
+
self.revoked_by = by
|
|
148
|
+
destroy!
|
|
149
|
+
|
|
150
|
+
Sessions.safely("revoke_remember_me") { sessions_forget_remember_me! } if Sessions.config.revoke_remember_me
|
|
151
|
+
Sessions.safely("on_session_revoked hook") do
|
|
152
|
+
Sessions.config.on_session_revoked.call(session: self, by: by, reason: reason)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
self
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# --- Lifecycle plumbing (used by the adapters) -------------------------------
|
|
159
|
+
|
|
160
|
+
# Opt-in expiry — false unless the host configured timeouts.
|
|
161
|
+
def sessions_expired?(now = Time.current)
|
|
162
|
+
config = Sessions.config
|
|
163
|
+
activity = last_active_at
|
|
164
|
+
return true if config.idle_timeout && activity && activity < now - config.idle_timeout
|
|
165
|
+
return true if config.max_session_lifetime && created_at && created_at < now - config.max_session_lifetime
|
|
166
|
+
|
|
167
|
+
false
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The throttled last-seen touch: at most one write per
|
|
171
|
+
# config.touch_every per session, issued as a single conditional UPDATE
|
|
172
|
+
# (hot-row-safe under concurrent requests, callback-free, and it also
|
|
173
|
+
# moves updated_at — which finally makes the Rails security guide's own
|
|
174
|
+
# `Session.sweep` recommendation implementable).
|
|
175
|
+
def touch_last_seen!(request = nil)
|
|
176
|
+
every = Sessions.config.touch_every
|
|
177
|
+
return false unless every
|
|
178
|
+
return false unless sessions_column?("last_seen_at")
|
|
179
|
+
|
|
180
|
+
now = Time.current
|
|
181
|
+
threshold = now - every
|
|
182
|
+
return false if last_seen_at && last_seen_at > threshold
|
|
183
|
+
|
|
184
|
+
updates = { last_seen_at: now, updated_at: now }
|
|
185
|
+
if sessions_column?("last_seen_ip") && request && (ip = Sessions::IpAddress.resolve(request))
|
|
186
|
+
updates[:last_seen_ip] = ip
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
updated = self.class.where(id: id)
|
|
190
|
+
.where("last_seen_at IS NULL OR last_seen_at <= ?", threshold)
|
|
191
|
+
.update_all(updates)
|
|
192
|
+
|
|
193
|
+
if updated.positive?
|
|
194
|
+
updates.each { |column, value| self[column] = value }
|
|
195
|
+
clear_attribute_changes(updates.keys)
|
|
196
|
+
true
|
|
197
|
+
else
|
|
198
|
+
# Another request won the race — refresh our throttle window so this
|
|
199
|
+
# instance doesn't retry.
|
|
200
|
+
self[:last_seen_at] = now
|
|
201
|
+
clear_attribute_changes([:last_seen_at])
|
|
202
|
+
false
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Constant-time token check (Devise mode). Omakase rows store no token
|
|
207
|
+
# (the signed cookie is the credential) and never match.
|
|
208
|
+
def sessions_token_matches?(token)
|
|
209
|
+
digest = try(:token_digest)
|
|
210
|
+
return false if digest.blank? || token.blank?
|
|
211
|
+
|
|
212
|
+
ActiveSupport::SecurityUtils.secure_compare(digest, Sessions.token_digest(token))
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
private
|
|
216
|
+
|
|
217
|
+
# --- before_create: enrichment ------------------------------------------------
|
|
218
|
+
|
|
219
|
+
def sessions_enrich
|
|
220
|
+
Sessions.safely("enrich") do
|
|
221
|
+
request = Sessions::Current.request
|
|
222
|
+
|
|
223
|
+
sessions_enrich_ip(request)
|
|
224
|
+
sessions_enrich_device(request)
|
|
225
|
+
sessions_enrich_device_id(request)
|
|
226
|
+
sessions_enrich_auth(request)
|
|
227
|
+
sessions_enrich_geo(request)
|
|
228
|
+
sessions_clamp_oversized_strings
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
true # never halt the host's save chain
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Rails 8's authentication generator creates `user_agent` as a plain
|
|
235
|
+
# string — VARCHAR(255) on MySQL — and real native UAs (app prefix +
|
|
236
|
+
# WebView UA + Hotwire markers) routinely overflow it, turning a login
|
|
237
|
+
# into ActiveRecord::ValueTooLong under MySQL's strict mode. The gem's
|
|
238
|
+
# own tables use text, but on ADOPTED tables we clamp every string
|
|
239
|
+
# column to its limit AFTER parsing (the parsers saw the full value;
|
|
240
|
+
# only storage is bounded). Tracking never breaks login — and here,
|
|
241
|
+
# login itself would have broken without us.
|
|
242
|
+
def sessions_clamp_oversized_strings
|
|
243
|
+
self.class.columns_hash.each do |name, column|
|
|
244
|
+
next unless column.type == :string && column.limit
|
|
245
|
+
next unless (value = self[name]).is_a?(String) && value.length > column.limit
|
|
246
|
+
|
|
247
|
+
self[name] = value[0, column.limit]
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def sessions_enrich_ip(request)
|
|
252
|
+
return unless sessions_column?("ip_address")
|
|
253
|
+
|
|
254
|
+
# Normalize (and truncate, per config.ip_mode) whatever the host
|
|
255
|
+
# captured; resolve from the request when nothing was set (Devise
|
|
256
|
+
# mode sets it explicitly; omakase's start_new_session_for already
|
|
257
|
+
# did). Garbage that doesn't parse as an IP is dropped.
|
|
258
|
+
self.ip_address = Sessions::IpAddress.normalize(ip_address) if ip_address.present?
|
|
259
|
+
self.ip_address ||= Sessions::IpAddress.resolve(request) if request
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def sessions_enrich_device(request)
|
|
263
|
+
ua = (user_agent.presence if sessions_column?("user_agent")) || request&.user_agent
|
|
264
|
+
headers = Sessions::Device.headers_from(request)
|
|
265
|
+
|
|
266
|
+
device = Sessions::Device.parse(ua, headers: headers)
|
|
267
|
+
sessions_assign(device.to_h)
|
|
268
|
+
sessions_assign(client_hints: headers) if headers.any?
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Browser continuity: a signed, long-lived, random cookie identifying
|
|
272
|
+
# the BROWSER INSTALL (never the user — it carries no identity and is
|
|
273
|
+
# worthless as a credential; it only lets two logins from the same
|
|
274
|
+
# browser collapse into one device row). Minted ONLY at login — no
|
|
275
|
+
# pre-login tracking cookie ever. Cookie unavailable (bare rack stacks,
|
|
276
|
+
# tests without key material)? The row simply has no device_id and
|
|
277
|
+
# nothing dedupes — degraded, never broken.
|
|
278
|
+
def sessions_enrich_device_id(request)
|
|
279
|
+
return unless sessions_column?("device_id")
|
|
280
|
+
return if device_id.present?
|
|
281
|
+
return unless request.respond_to?(:cookie_jar)
|
|
282
|
+
|
|
283
|
+
jar = request.cookie_jar.signed
|
|
284
|
+
continuity = jar[Sessions::DEVICE_COOKIE]
|
|
285
|
+
if continuity.blank?
|
|
286
|
+
continuity = SecureRandom.uuid
|
|
287
|
+
jar[Sessions::DEVICE_COOKIE] = {
|
|
288
|
+
value: continuity,
|
|
289
|
+
expires: 5.years,
|
|
290
|
+
httponly: true,
|
|
291
|
+
same_site: :lax
|
|
292
|
+
}
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
self.device_id = continuity.to_s[0, 36]
|
|
296
|
+
rescue StandardError => e
|
|
297
|
+
Sessions.warn("device continuity cookie unavailable: #{e.class}: #{e.message}")
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def sessions_enrich_auth(request)
|
|
301
|
+
auth = Sessions::Classifier.classify(request)
|
|
302
|
+
sessions_assign(auth_method: auth[:method], auth_provider: auth[:provider])
|
|
303
|
+
sessions_assign(auth_detail: auth[:detail]) if auth[:detail].present?
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def sessions_enrich_geo(request)
|
|
307
|
+
return unless sessions_column?("country_code")
|
|
308
|
+
return if country_code.present? || ip_address.blank?
|
|
309
|
+
# Synchronous only when it's free (Cloudflare already answered in
|
|
310
|
+
# request headers); otherwise the GeolocateJob enriches after commit.
|
|
311
|
+
return unless Sessions::Geolocation.cloudflare_headers?(request)
|
|
312
|
+
|
|
313
|
+
sessions_assign(Sessions::Geolocation.locate(ip_address, request: request))
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Tolerant assignment: only columns that exist, never overwriting
|
|
317
|
+
# host-set values — hosts can drop columns without gem releases.
|
|
318
|
+
def sessions_assign(attributes)
|
|
319
|
+
attributes.each do |column, value|
|
|
320
|
+
name = column.to_s
|
|
321
|
+
next unless sessions_column?(name)
|
|
322
|
+
next if self[name].present?
|
|
323
|
+
|
|
324
|
+
self[name] = value
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def sessions_column?(name)
|
|
329
|
+
self.class.column_names.include?(name.to_s)
|
|
330
|
+
rescue StandardError
|
|
331
|
+
false
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# --- after_create_commit: the login event ------------------------------------
|
|
335
|
+
|
|
336
|
+
def sessions_record_login
|
|
337
|
+
Sessions.safely("record_login") do
|
|
338
|
+
next if sessions_suppress_login_event
|
|
339
|
+
|
|
340
|
+
# Same browser signing in again (abandoned session, expired
|
|
341
|
+
# remember-me, browser update — anything) replaces its old row
|
|
342
|
+
# instead of stacking a duplicate device. Runs BEFORE new-device
|
|
343
|
+
# detection on purpose: the trail (which survives the superseded
|
|
344
|
+
# row) is what remembers known devices, so dedup never causes
|
|
345
|
+
# false "new device" alerts.
|
|
346
|
+
Sessions.safely("supersede") { sessions_supersede_previous_rows! }
|
|
347
|
+
|
|
348
|
+
new_device = Sessions.safely("new_device") { sessions_new_device? } || false
|
|
349
|
+
|
|
350
|
+
event = Sessions::Event.record!(
|
|
351
|
+
sessions_event_identity_attributes.merge(
|
|
352
|
+
event: "login",
|
|
353
|
+
auth_method: try(:auth_method),
|
|
354
|
+
auth_provider: try(:auth_provider),
|
|
355
|
+
auth_detail: try(:auth_detail).presence,
|
|
356
|
+
# The browser-continuity id rides the trail too: it's what lets
|
|
357
|
+
# Sessions.last_login answer "how did this browser last sign
|
|
358
|
+
# in" AFTER logout destroys the row (the "Last used" badge).
|
|
359
|
+
device_id: try(:device_id).presence,
|
|
360
|
+
metadata: new_device ? { "new_device" => true } : nil
|
|
361
|
+
)
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
Sessions::Geolocation.enqueue(self) if try(:country_code).blank?
|
|
365
|
+
sessions_enforce_cap!
|
|
366
|
+
|
|
367
|
+
if new_device && event
|
|
368
|
+
Sessions.safely("on_new_device hook") do
|
|
369
|
+
Sessions.config.on_new_device.call(user: user, session: self, event: event)
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# A login is a NEW DEVICE when no prior session or login event for this
|
|
376
|
+
# user matches on (device_type, os_name, browser/app identity) —
|
|
377
|
+
# deliberately coarse, server-observed-only matching; never
|
|
378
|
+
# fingerprinting. A user's very first login is NOT a new device (nobody
|
|
379
|
+
# wants a "was this you?" email on signup).
|
|
380
|
+
def sessions_new_device?
|
|
381
|
+
return false unless user
|
|
382
|
+
return false if try(:device_type).blank? || try(:device_type) == "unknown"
|
|
383
|
+
|
|
384
|
+
match = { device_type: try(:device_type), os_name: try(:os_name),
|
|
385
|
+
browser_name: try(:browser_name), app_name: try(:app_name) }
|
|
386
|
+
|
|
387
|
+
prior_sessions = self.class.where(user: user).where.not(id: id)
|
|
388
|
+
prior_events = Sessions::Event.logins.where(authenticatable: user)
|
|
389
|
+
return false unless prior_sessions.exists? || prior_events.exists?
|
|
390
|
+
|
|
391
|
+
!prior_sessions.where(match).exists? && !prior_events.where(match).exists?
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# The dedup half of browser continuity: prior live rows for the SAME
|
|
395
|
+
# user on the SAME browser install are superseded by this login. A
|
|
396
|
+
# quiet destroy on purpose — no on_session_revoked hook, no
|
|
397
|
+
# remember-me rotation (this is housekeeping, not a security event;
|
|
398
|
+
# the trail records it as revoked/:superseded). Scoped to the user:
|
|
399
|
+
# a shared computer with two accounts keeps both rows.
|
|
400
|
+
def sessions_supersede_previous_rows!
|
|
401
|
+
return if try(:device_id).blank?
|
|
402
|
+
|
|
403
|
+
self.class.where(user: user, device_id: device_id).where.not(id: id).each do |row|
|
|
404
|
+
row.revocation_reason = :superseded
|
|
405
|
+
row.destroy
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# GitLab-style per-user cap: evict the OLDEST sessions beyond
|
|
410
|
+
# config.max_sessions_per_user (the freshly created row is the newest,
|
|
411
|
+
# so it always survives).
|
|
412
|
+
def sessions_enforce_cap!
|
|
413
|
+
cap = Sessions.config.max_sessions_per_user
|
|
414
|
+
return unless cap
|
|
415
|
+
|
|
416
|
+
siblings = self.class.where(user: user)
|
|
417
|
+
overflow = siblings.count - cap
|
|
418
|
+
return unless overflow.positive?
|
|
419
|
+
|
|
420
|
+
siblings.where.not(id: id).order(created_at: :asc).limit(overflow).each do |session|
|
|
421
|
+
session.revoke!(reason: :pruned)
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# --- after_destroy_commit: the end-of-session event ---------------------------
|
|
426
|
+
|
|
427
|
+
def sessions_record_end
|
|
428
|
+
Sessions.safely("record_end") do
|
|
429
|
+
# Account deletion: dependent-destroyed rows of a destroyed owner
|
|
430
|
+
# write no events (their trail is erased with them — GDPR default).
|
|
431
|
+
next if user.nil? || user.destroyed?
|
|
432
|
+
|
|
433
|
+
reason = revocation_reason&.to_sym
|
|
434
|
+
event_name = case reason
|
|
435
|
+
when :logout then "logout"
|
|
436
|
+
when :expired then "expired"
|
|
437
|
+
else "revoked"
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
Sessions::Event.record!(
|
|
441
|
+
sessions_event_identity_attributes.merge(
|
|
442
|
+
event: event_name,
|
|
443
|
+
revoked_reason: (event_name == "revoked" ? (reason || :unknown) : nil),
|
|
444
|
+
metadata: sessions_revoked_by_metadata
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def sessions_revoked_by_metadata
|
|
451
|
+
return nil unless revoked_by
|
|
452
|
+
|
|
453
|
+
label = if revoked_by.respond_to?(:id) && revoked_by.class.respond_to?(:name)
|
|
454
|
+
"#{revoked_by.class.name}##{revoked_by.id}"
|
|
455
|
+
else
|
|
456
|
+
revoked_by.to_s
|
|
457
|
+
end
|
|
458
|
+
{ "revoked_by" => label }
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# The row's identity, copied onto every event it produces — the trail
|
|
462
|
+
# must describe the device even after the row is gone.
|
|
463
|
+
def sessions_event_identity_attributes
|
|
464
|
+
{
|
|
465
|
+
session_id: id,
|
|
466
|
+
authenticatable: user,
|
|
467
|
+
scope: try(:scope),
|
|
468
|
+
ip_address: (ip_address if sessions_column?("ip_address")),
|
|
469
|
+
user_agent: (user_agent if sessions_column?("user_agent")),
|
|
470
|
+
client_hints: try(:client_hints).presence,
|
|
471
|
+
browser_name: try(:browser_name),
|
|
472
|
+
browser_version: try(:browser_version),
|
|
473
|
+
os_name: try(:os_name),
|
|
474
|
+
os_version: try(:os_version),
|
|
475
|
+
device_type: try(:device_type),
|
|
476
|
+
device_model: try(:device_model),
|
|
477
|
+
app_name: try(:app_name),
|
|
478
|
+
app_version: try(:app_version),
|
|
479
|
+
country_code: try(:country_code),
|
|
480
|
+
country_name: try(:country_name),
|
|
481
|
+
city: try(:city),
|
|
482
|
+
region: try(:region),
|
|
483
|
+
request_id: sessions_request_id,
|
|
484
|
+
context: sessions_request_context
|
|
485
|
+
}
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
# Plain-Warden stacks hand us a Rack::Request (Devise upgrades it to
|
|
489
|
+
# ActionDispatch); neither request_id nor path_parameters can be assumed.
|
|
490
|
+
def sessions_request_id
|
|
491
|
+
request = Sessions::Current.request
|
|
492
|
+
request.request_id if request.respond_to?(:request_id)
|
|
493
|
+
rescue StandardError
|
|
494
|
+
nil
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def sessions_request_context
|
|
498
|
+
request = Sessions::Current.request
|
|
499
|
+
return nil unless request.respond_to?(:path_parameters)
|
|
500
|
+
|
|
501
|
+
params = request.path_parameters
|
|
502
|
+
return nil unless params && params[:controller]
|
|
503
|
+
|
|
504
|
+
"#{params[:controller]}##{params[:action]}"
|
|
505
|
+
rescue StandardError
|
|
506
|
+
nil
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def sessions_forget_remember_me!
|
|
510
|
+
user.forget_me! if user.respond_to?(:forget_me!)
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
end
|