sessions 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -1
- data/README.md +10 -10
- data/app/controllers/sessions/application_controller.rb +2 -2
- data/app/views/sessions/_devices.html.erb +1 -1
- data/docs/PRD.md +52 -52
- data/docs/research/{01-carhey.md → 01-host-app.md} +23 -23
- data/docs/research/02-ecosystem.md +1 -1
- data/docs/research/07-device-detection.md +13 -13
- data/lib/generators/sessions/install_generator.rb +1 -1
- data/lib/generators/sessions/madmin_generator.rb +5 -5
- data/lib/generators/sessions/templates/add_app_build_to_sessions_events.rb.erb +9 -0
- data/lib/generators/sessions/templates/add_lifecycle_to_sessions.rb.erb +38 -0
- data/lib/generators/sessions/templates/add_sessions_columns.rb.erb +26 -0
- data/lib/generators/sessions/templates/create_sessions.rb.erb +17 -5
- data/lib/generators/sessions/templates/create_sessions_events.rb.erb +5 -4
- data/lib/generators/sessions/templates/initializer.rb +2 -2
- data/lib/generators/sessions/templates/madmin/session_resource.rb +16 -8
- data/lib/generators/sessions/templates/madmin/sessions_controller.rb +4 -4
- data/lib/generators/sessions/upgrade_generator.rb +6 -1
- data/lib/sessions/adapters/omakase.rb +62 -18
- data/lib/sessions/adapters/warden.rb +323 -47
- data/lib/sessions/classifier.rb +18 -5
- data/lib/sessions/configuration.rb +4 -3
- data/lib/sessions/current.rb +4 -4
- data/lib/sessions/end_reason.rb +67 -0
- data/lib/sessions/geolocation.rb +18 -8
- data/lib/sessions/models/concerns/has_sessions.rb +3 -3
- data/lib/sessions/models/concerns/model.rb +136 -68
- data/lib/sessions/models/event.rb +24 -17
- data/lib/sessions/version.rb +1 -1
- data/lib/sessions.rb +13 -12
- metadata +6 -3
data/lib/sessions/current.rb
CHANGED
|
@@ -9,10 +9,10 @@ module Sessions
|
|
|
9
9
|
#
|
|
10
10
|
# Why it exists: the omakase adapter records logins from MODEL callbacks
|
|
11
11
|
# (Session#after_create_commit — the only seam that captures 100% of the
|
|
12
|
-
# generated lifecycle,
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
12
|
+
# generated login lifecycle), and model callbacks have no request. This
|
|
13
|
+
# carries the request reference across that gap. Background jobs and
|
|
14
|
+
# console code simply see nil and the pipeline degrades gracefully (rows
|
|
15
|
+
# parse from their own stored columns).
|
|
16
16
|
class Current < ActiveSupport::CurrentAttributes
|
|
17
17
|
# The ActionDispatch::Request being served, if any.
|
|
18
18
|
attribute :request
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sessions
|
|
4
|
+
# Canonical lifecycle vocabulary for registry rows.
|
|
5
|
+
#
|
|
6
|
+
# v0.1.x used "row missing + event tombstone" as the revocation signal. That
|
|
7
|
+
# made Warden infer security intent from absence, which is exactly how quiet
|
|
8
|
+
# housekeeping ended up looking too much like a real logout. v0.2 moves the
|
|
9
|
+
# source of truth onto the session row itself: events are audit trail, not
|
|
10
|
+
# liveness state.
|
|
11
|
+
#
|
|
12
|
+
# External precedents:
|
|
13
|
+
# - Rails 8 generated auth resolves a Session row on every request:
|
|
14
|
+
# https://github.com/rails/rails/blob/main/railties/lib/rails/generators/rails/authentication/templates/app/controllers/concerns/authentication.rb.tt
|
|
15
|
+
# - Rodauth's active_sessions feature keeps explicit active-session state
|
|
16
|
+
# separate from audit logging:
|
|
17
|
+
# https://github.com/jeremyevans/rodauth/blob/master/lib/rodauth/features/active_sessions.rb
|
|
18
|
+
module EndReason
|
|
19
|
+
LOGOUT = "logout"
|
|
20
|
+
EXPIRED = "expired"
|
|
21
|
+
SUPERSEDED = "superseded"
|
|
22
|
+
|
|
23
|
+
REVOKED = %w[
|
|
24
|
+
user_revoked
|
|
25
|
+
admin_revoked
|
|
26
|
+
password_change
|
|
27
|
+
logout_everywhere
|
|
28
|
+
pruned
|
|
29
|
+
unknown
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
INTERNAL = [SUPERSEDED].freeze
|
|
33
|
+
EVENTS = {
|
|
34
|
+
LOGOUT => "logout",
|
|
35
|
+
EXPIRED => "expired"
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
KICKING = ([LOGOUT, EXPIRED] + REVOKED).freeze
|
|
39
|
+
|
|
40
|
+
module_function
|
|
41
|
+
|
|
42
|
+
def normalize(reason)
|
|
43
|
+
value = reason.to_s.presence || "unknown"
|
|
44
|
+
value == "revoked" ? "user_revoked" : value
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def internal?(reason)
|
|
48
|
+
INTERNAL.include?(normalize(reason))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def kicks_on_resume?(reason)
|
|
52
|
+
KICKING.include?(normalize(reason))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def event_for(reason)
|
|
56
|
+
normalized = normalize(reason)
|
|
57
|
+
return nil if internal?(normalized)
|
|
58
|
+
|
|
59
|
+
EVENTS.fetch(normalized, "revoked")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def revoked_reason_for(reason)
|
|
63
|
+
normalized = normalize(reason)
|
|
64
|
+
event_for(normalized) == "revoked" ? normalized : nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/sessions/geolocation.rb
CHANGED
|
@@ -79,13 +79,15 @@ module Sessions
|
|
|
79
79
|
|
|
80
80
|
def columns_from(result)
|
|
81
81
|
return {} unless result
|
|
82
|
-
|
|
82
|
+
|
|
83
|
+
country_code = location_value(result, :country_code)
|
|
84
|
+
return {} if country_code.to_s.empty?
|
|
83
85
|
|
|
84
86
|
{
|
|
85
|
-
country_code:
|
|
86
|
-
country_name: presence(result
|
|
87
|
-
city: presence(result
|
|
88
|
-
region: presence(result
|
|
87
|
+
country_code: country_code,
|
|
88
|
+
country_name: presence(location_value(result, :country_name)),
|
|
89
|
+
city: presence(location_value(result, :city)),
|
|
90
|
+
region: presence(location_value(result, :region))
|
|
89
91
|
}.compact
|
|
90
92
|
end
|
|
91
93
|
|
|
@@ -93,17 +95,25 @@ module Sessions
|
|
|
93
95
|
# config.geo_precision (2 decimals ≈ 1km — privacy now,
|
|
94
96
|
# impossible-travel math later).
|
|
95
97
|
def coordinates_from(result)
|
|
96
|
-
|
|
98
|
+
latitude = location_value(result, :latitude)
|
|
99
|
+
longitude = location_value(result, :longitude)
|
|
100
|
+
return {} if latitude.nil? || longitude.nil?
|
|
97
101
|
|
|
98
102
|
precision = Sessions.config.geo_precision
|
|
99
103
|
{
|
|
100
|
-
latitude:
|
|
101
|
-
longitude:
|
|
104
|
+
latitude: latitude.to_f.round(precision),
|
|
105
|
+
longitude: longitude.to_f.round(precision)
|
|
102
106
|
}
|
|
103
107
|
rescue StandardError
|
|
104
108
|
{}
|
|
105
109
|
end
|
|
106
110
|
|
|
111
|
+
def location_value(result, attribute)
|
|
112
|
+
result.public_send(attribute) if result.respond_to?(attribute)
|
|
113
|
+
rescue StandardError
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
107
117
|
def presence(value)
|
|
108
118
|
value.nil? || value.to_s.empty? || value.to_s == "Unknown" ? nil : value
|
|
109
119
|
end
|
|
@@ -7,7 +7,7 @@ module Sessions
|
|
|
7
7
|
# here. Either way the model gains the events trail and the revocation
|
|
8
8
|
# verbs:
|
|
9
9
|
#
|
|
10
|
-
# current_user.sessions.
|
|
10
|
+
# current_user.sessions.live
|
|
11
11
|
# current_user.session_events.failed_logins.last_24_hours
|
|
12
12
|
# current_user.revoke_other_sessions! # "sign out everywhere else"
|
|
13
13
|
# current_user.revoke_all_sessions! # the account-takeover hammer
|
|
@@ -75,7 +75,7 @@ module Sessions
|
|
|
75
75
|
def revoke_other_sessions!(current: nil, by: nil, reason: :logout_everywhere)
|
|
76
76
|
current = Sessions.current if current.nil?
|
|
77
77
|
|
|
78
|
-
scope = sessions
|
|
78
|
+
scope = sessions.live
|
|
79
79
|
scope = scope.where.not(id: current.id) if current.respond_to?(:id)
|
|
80
80
|
scope.each { |session| session.revoke!(reason: reason, by: by || self) }
|
|
81
81
|
true
|
|
@@ -84,7 +84,7 @@ module Sessions
|
|
|
84
84
|
# The admin hammer — the account-takeover response. Revokes EVERYTHING,
|
|
85
85
|
# including the session serving this request if it belongs to this user.
|
|
86
86
|
def revoke_all_sessions!(by: nil, reason: :admin_revoked)
|
|
87
|
-
sessions.each { |session| session.revoke!(reason: reason, by: by) }
|
|
87
|
+
sessions.live.each { |session| session.revoke!(reason: reason, by: by) }
|
|
88
88
|
true
|
|
89
89
|
end
|
|
90
90
|
|
|
@@ -8,10 +8,12 @@ module Sessions
|
|
|
8
8
|
# explicitly. Either way, ALL gem logic lives here, so the host's model
|
|
9
9
|
# file never goes stale.
|
|
10
10
|
#
|
|
11
|
-
# One mental model: **rows =
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
11
|
+
# One mental model: **rows = session lifecycle state; events = audit
|
|
12
|
+
# history.** `ended_at: nil` means the device is live. Ending a row is an
|
|
13
|
+
# explicit state transition (`ended_reason`) and never relies on deleting a
|
|
14
|
+
# row plus later interpreting the absence. That distinction matters in
|
|
15
|
+
# Devise/Warden mode, where the gem decorates the host session and must
|
|
16
|
+
# never kick a user unless a durable explicit ending says so.
|
|
15
17
|
#
|
|
16
18
|
# The three lifecycle callbacks observe 100% of both adapters' flows:
|
|
17
19
|
#
|
|
@@ -20,7 +22,8 @@ module Sessions
|
|
|
20
22
|
# method, geolocate (when free).
|
|
21
23
|
# after_create_commit — write the `login` event, detect new devices,
|
|
22
24
|
# enforce the per-user cap, enqueue async geo.
|
|
23
|
-
# after_destroy_commit —
|
|
25
|
+
# after_destroy_commit — best-effort compatibility for host-side deletes
|
|
26
|
+
# (account erasure, Rails generator destroy_all).
|
|
24
27
|
#
|
|
25
28
|
# Every callback body is error-isolated: a parsing/geo/event failure may
|
|
26
29
|
# lose a log row; it may NEVER break a sign-in.
|
|
@@ -50,35 +53,41 @@ module Sessions
|
|
|
50
53
|
end
|
|
51
54
|
end
|
|
52
55
|
|
|
53
|
-
# Transient
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
+
# Transient direct-delete context: legacy/host destroy paths can still
|
|
57
|
+
# label their compatibility event. Normal gem APIs call `end!` and keep
|
|
58
|
+
# the row as durable lifecycle state.
|
|
56
59
|
attr_accessor :revocation_reason, :revoked_by
|
|
57
60
|
|
|
58
61
|
# Set on adopted rows (sessions that predate the gem) so adoption
|
|
59
62
|
# doesn't fabricate a `login` event in the trail.
|
|
60
|
-
attr_accessor :sessions_suppress_login_event
|
|
63
|
+
attr_accessor :sessions_suppress_login_event, :sessions_skip_supersede
|
|
61
64
|
|
|
62
65
|
before_create :sessions_enrich
|
|
63
66
|
after_create_commit :sessions_record_login
|
|
64
|
-
after_destroy_commit :
|
|
67
|
+
after_destroy_commit :sessions_record_destroyed_end
|
|
65
68
|
|
|
66
69
|
scope :by_recency, lambda {
|
|
67
70
|
# COALESCE keeps never-touched sessions ordered by creation time,
|
|
68
71
|
# portably across sqlite/postgres/mysql.
|
|
69
72
|
order(Arel.sql("COALESCE(#{table_name}.last_seen_at, #{table_name}.created_at) DESC"))
|
|
70
73
|
}
|
|
74
|
+
scope :live, lambda {
|
|
75
|
+
column_names.include?("ended_at") ? where(ended_at: nil) : all
|
|
76
|
+
}
|
|
77
|
+
scope :ended, lambda {
|
|
78
|
+
column_names.include?("ended_at") ? where.not(ended_at: nil) : none
|
|
79
|
+
}
|
|
71
80
|
scope :active, lambda {
|
|
72
|
-
where("COALESCE(#{table_name}.last_seen_at, #{table_name}.created_at) >= ?", INACTIVE_AFTER.ago)
|
|
81
|
+
live.where("COALESCE(#{table_name}.last_seen_at, #{table_name}.created_at) >= ?", INACTIVE_AFTER.ago)
|
|
73
82
|
}
|
|
74
83
|
scope :inactive, lambda {
|
|
75
|
-
where("COALESCE(#{table_name}.last_seen_at, #{table_name}.created_at) < ?", INACTIVE_AFTER.ago)
|
|
84
|
+
live.where("COALESCE(#{table_name}.last_seen_at, #{table_name}.created_at) < ?", INACTIVE_AFTER.ago)
|
|
76
85
|
}
|
|
77
86
|
end
|
|
78
87
|
|
|
79
88
|
class_methods do
|
|
80
89
|
# The user-facing trail rows linked to this registry (`session_id` is
|
|
81
|
-
# a plain column, no FK — history must survive row
|
|
90
|
+
# a plain column, no FK — history must survive lifecycle-row cleanup).
|
|
82
91
|
def sessions_events_for(session_id)
|
|
83
92
|
Sessions::Event.where(session_id: session_id)
|
|
84
93
|
end
|
|
@@ -137,15 +146,35 @@ module Sessions
|
|
|
137
146
|
|
|
138
147
|
# --- Revocation -----------------------------------------------------------
|
|
139
148
|
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
# `
|
|
143
|
-
|
|
144
|
-
|
|
149
|
+
# End this registry row without deleting it. This is the core v0.2
|
|
150
|
+
# invariant: the row itself is the durable liveness state, and
|
|
151
|
+
# `sessions_events` remains a read-only audit trail.
|
|
152
|
+
def end!(reason:, by: nil, at: Time.current, metadata: nil, notify: true)
|
|
153
|
+
reason = Sessions::EndReason.normalize(reason)
|
|
154
|
+
event = nil
|
|
155
|
+
ended_now = false
|
|
156
|
+
|
|
157
|
+
with_lock do
|
|
158
|
+
unless ended?
|
|
159
|
+
sessions_assign_end_state(reason: reason, by: by, at: at, metadata: metadata)
|
|
160
|
+
save!
|
|
161
|
+
ended_now = true
|
|
162
|
+
event = sessions_record_end_event!(reason: reason, by: by, notify: false)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
Sessions.notify_event(event) if notify && event
|
|
167
|
+
@sessions_ended_now = ended_now
|
|
168
|
+
self
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# End this session as an explicit revocation/expiry action. In Devise mode
|
|
172
|
+
# the next request still proves possession of the per-row token before the
|
|
173
|
+
# adapter kicks; in Rails-8 auth mode the row id in the signed cookie now
|
|
174
|
+
# resolves to an ended row and the controller hook refuses it.
|
|
145
175
|
def revoke!(reason: :user_revoked, by: nil)
|
|
146
|
-
|
|
147
|
-
self
|
|
148
|
-
destroy!
|
|
176
|
+
end!(reason: reason, by: by)
|
|
177
|
+
return self unless @sessions_ended_now
|
|
149
178
|
|
|
150
179
|
Sessions.safely("revoke_remember_me") { sessions_forget_remember_me! } if Sessions.config.revoke_remember_me
|
|
151
180
|
Sessions.safely("on_session_revoked hook") do
|
|
@@ -159,6 +188,8 @@ module Sessions
|
|
|
159
188
|
|
|
160
189
|
# Opt-in expiry — false unless the host configured timeouts.
|
|
161
190
|
def sessions_expired?(now = Time.current)
|
|
191
|
+
return false if ended?
|
|
192
|
+
|
|
162
193
|
config = Sessions.config
|
|
163
194
|
activity = last_active_at
|
|
164
195
|
return true if config.idle_timeout && activity && activity < now - config.idle_timeout
|
|
@@ -167,6 +198,18 @@ module Sessions
|
|
|
167
198
|
false
|
|
168
199
|
end
|
|
169
200
|
|
|
201
|
+
def live?
|
|
202
|
+
!sessions_column?("ended_at") || try(:ended_at).blank?
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def ended?
|
|
206
|
+
!live?
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def sessions_kicks_on_resume?
|
|
210
|
+
ended? && Sessions::EndReason.kicks_on_resume?(try(:ended_reason))
|
|
211
|
+
end
|
|
212
|
+
|
|
170
213
|
# The throttled last-seen touch: at most one write per
|
|
171
214
|
# config.touch_every per session, issued as a single conditional UPDATE
|
|
172
215
|
# (hot-row-safe under concurrent requests, callback-free, and it also
|
|
@@ -176,6 +219,7 @@ module Sessions
|
|
|
176
219
|
every = Sessions.config.touch_every
|
|
177
220
|
return false unless every
|
|
178
221
|
return false unless sessions_column?("last_seen_at")
|
|
222
|
+
return false if ended?
|
|
179
223
|
|
|
180
224
|
now = Time.current
|
|
181
225
|
threshold = now - every
|
|
@@ -331,26 +375,44 @@ module Sessions
|
|
|
331
375
|
false
|
|
332
376
|
end
|
|
333
377
|
|
|
378
|
+
def sessions_assign_end_state(reason:, by:, at:, metadata:)
|
|
379
|
+
sessions_assign_force(ended_at: at, ended_reason: reason)
|
|
380
|
+
sessions_assign_force(ended_by_type: by.class.name) if by && sessions_column?("ended_by_type")
|
|
381
|
+
sessions_assign_force(ended_by_id: by.id) if by.respond_to?(:id) && sessions_column?("ended_by_id")
|
|
382
|
+
|
|
383
|
+
payload = metadata.presence || sessions_revoked_by_metadata(by)
|
|
384
|
+
sessions_assign_force(ended_metadata: payload) if payload && sessions_column?("ended_metadata")
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def sessions_assign_force(attributes)
|
|
388
|
+
attributes.each do |column, value|
|
|
389
|
+
name = column.to_s
|
|
390
|
+
next unless sessions_column?(name)
|
|
391
|
+
|
|
392
|
+
self[name] = value
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
334
396
|
# --- after_create_commit: the login event ------------------------------------
|
|
335
397
|
|
|
336
398
|
def sessions_record_login
|
|
337
399
|
Sessions.safely("record_login") do
|
|
338
|
-
if sessions_suppress_login_event
|
|
339
|
-
# Suppressed writes (adoption) skip the trail event, dedup and
|
|
340
|
-
# the new-device hook — but never the cap: it's the hard limit on
|
|
341
|
-
# LIVE rows, and a misbehaving client looping through adoption
|
|
342
|
-
# must hit it like everyone else.
|
|
343
|
-
sessions_enforce_cap!
|
|
344
|
-
next
|
|
345
|
-
end
|
|
346
|
-
|
|
347
400
|
# Same browser signing in again (abandoned session, expired
|
|
348
401
|
# remember-me, browser update — anything) replaces its old row
|
|
349
402
|
# instead of stacking a duplicate device. Runs BEFORE new-device
|
|
350
403
|
# detection on purpose: the trail (which survives the superseded
|
|
351
404
|
# row) is what remembers known devices, so dedup never causes
|
|
352
405
|
# false "new device" alerts.
|
|
353
|
-
Sessions.safely("supersede") { sessions_supersede_previous_rows! }
|
|
406
|
+
Sessions.safely("supersede") { sessions_supersede_previous_rows! } unless sessions_skip_supersede
|
|
407
|
+
|
|
408
|
+
if sessions_suppress_login_event
|
|
409
|
+
# Suppressed writes skip the trail event and the new-device hook
|
|
410
|
+
# — but never the cap: it's the hard limit on LIVE rows, and a
|
|
411
|
+
# misbehaving client looping through adoption/remembered refreshes
|
|
412
|
+
# must hit it like everyone else.
|
|
413
|
+
sessions_enforce_cap!
|
|
414
|
+
next
|
|
415
|
+
end
|
|
354
416
|
|
|
355
417
|
new_device = Sessions.safely("new_device") { sessions_new_device? } || false
|
|
356
418
|
|
|
@@ -361,8 +423,8 @@ module Sessions
|
|
|
361
423
|
auth_provider: try(:auth_provider),
|
|
362
424
|
auth_detail: try(:auth_detail).presence,
|
|
363
425
|
# The browser-continuity id rides the trail too: it's what lets
|
|
364
|
-
# Sessions.last_login answer "how did this browser last sign
|
|
365
|
-
#
|
|
426
|
+
# Sessions.last_login answer "how did this browser last sign in"
|
|
427
|
+
# after logout ends the row (the "Last used" badge).
|
|
366
428
|
device_id: try(:device_id).presence,
|
|
367
429
|
metadata: new_device ? { "new_device" => true } : nil
|
|
368
430
|
)
|
|
@@ -399,17 +461,16 @@ module Sessions
|
|
|
399
461
|
end
|
|
400
462
|
|
|
401
463
|
# The dedup half of browser continuity: prior live rows for the SAME
|
|
402
|
-
# user on the SAME browser install are superseded by this login.
|
|
403
|
-
# quiet
|
|
404
|
-
# remember-me rotation
|
|
405
|
-
#
|
|
406
|
-
#
|
|
464
|
+
# user on the SAME browser install are superseded by this login. This is
|
|
465
|
+
# a quiet lifecycle end on purpose — no on_session_revoked hook, no
|
|
466
|
+
# remember-me rotation, no trail event (housekeeping, not a security
|
|
467
|
+
# event). Scoped to the user: a shared computer with two accounts keeps
|
|
468
|
+
# both rows.
|
|
407
469
|
def sessions_supersede_previous_rows!
|
|
408
470
|
return if try(:device_id).blank?
|
|
409
471
|
|
|
410
|
-
self.class.where(user: user, device_id: device_id).where.not(id: id).each do |row|
|
|
411
|
-
row.
|
|
412
|
-
row.destroy
|
|
472
|
+
self.class.live.where(user: user, device_id: device_id).where.not(id: id).each do |row|
|
|
473
|
+
row.end!(reason: :superseded, notify: false)
|
|
413
474
|
end
|
|
414
475
|
end
|
|
415
476
|
|
|
@@ -420,7 +481,7 @@ module Sessions
|
|
|
420
481
|
cap = Sessions.config.max_sessions_per_user
|
|
421
482
|
return unless cap
|
|
422
483
|
|
|
423
|
-
siblings = self.class.where(user: user)
|
|
484
|
+
siblings = self.class.live.where(user: user)
|
|
424
485
|
overflow = siblings.count - cap
|
|
425
486
|
return unless overflow.positive?
|
|
426
487
|
|
|
@@ -429,44 +490,50 @@ module Sessions
|
|
|
429
490
|
end
|
|
430
491
|
end
|
|
431
492
|
|
|
432
|
-
# --- after_destroy_commit:
|
|
493
|
+
# --- after_destroy_commit: legacy/direct-delete compatibility -----------------
|
|
494
|
+
|
|
495
|
+
def sessions_record_destroyed_end
|
|
496
|
+
return if ended?
|
|
433
497
|
|
|
434
|
-
def sessions_record_end
|
|
435
498
|
Sessions.safely("record_end") do
|
|
436
|
-
# Account deletion: dependent-destroyed rows of a destroyed owner
|
|
437
|
-
#
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
reason
|
|
441
|
-
event_name = case reason
|
|
442
|
-
when :logout then "logout"
|
|
443
|
-
when :expired then "expired"
|
|
444
|
-
else "revoked"
|
|
445
|
-
end
|
|
446
|
-
|
|
447
|
-
Sessions::Event.record!(
|
|
448
|
-
sessions_event_identity_attributes.merge(
|
|
449
|
-
event: event_name,
|
|
450
|
-
revoked_reason: (event_name == "revoked" ? (reason || :unknown) : nil),
|
|
451
|
-
metadata: sessions_revoked_by_metadata
|
|
452
|
-
)
|
|
453
|
-
)
|
|
499
|
+
# Account deletion: dependent-destroyed rows of a destroyed owner write
|
|
500
|
+
# no events (their trail is erased with them — GDPR default). Direct
|
|
501
|
+
# host deletes are otherwise treated as legacy explicit ends; v0.2's
|
|
502
|
+
# public APIs call `end!`/`revoke!` and preserve the row.
|
|
503
|
+
sessions_record_end_event!(reason: revocation_reason || :unknown, by: revoked_by)
|
|
454
504
|
end
|
|
455
505
|
end
|
|
456
506
|
|
|
457
|
-
def
|
|
458
|
-
return nil
|
|
507
|
+
def sessions_record_end_event!(reason: nil, by: nil, notify: true)
|
|
508
|
+
return if user.nil? || user.destroyed?
|
|
509
|
+
|
|
510
|
+
reason = Sessions::EndReason.normalize(reason || revocation_reason)
|
|
511
|
+
event_name = Sessions::EndReason.event_for(reason)
|
|
512
|
+
return unless event_name
|
|
513
|
+
|
|
514
|
+
Sessions::Event.record_strict!(
|
|
515
|
+
sessions_event_identity_attributes.merge(
|
|
516
|
+
event: event_name,
|
|
517
|
+
revoked_reason: Sessions::EndReason.revoked_reason_for(reason),
|
|
518
|
+
metadata: sessions_revoked_by_metadata(by || revoked_by)
|
|
519
|
+
),
|
|
520
|
+
notify: notify
|
|
521
|
+
)
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def sessions_revoked_by_metadata(actor = revoked_by)
|
|
525
|
+
return nil unless actor
|
|
459
526
|
|
|
460
|
-
label = if
|
|
461
|
-
"#{
|
|
527
|
+
label = if actor.respond_to?(:id) && actor.class.respond_to?(:name)
|
|
528
|
+
"#{actor.class.name}##{actor.id}"
|
|
462
529
|
else
|
|
463
|
-
|
|
530
|
+
actor.to_s
|
|
464
531
|
end
|
|
465
532
|
{ "revoked_by" => label }
|
|
466
533
|
end
|
|
467
534
|
|
|
468
535
|
# The row's identity, copied onto every event it produces — the trail
|
|
469
|
-
# must describe the device even after the row is
|
|
536
|
+
# must describe the device even after the row is ended or later erased.
|
|
470
537
|
def sessions_event_identity_attributes
|
|
471
538
|
{
|
|
472
539
|
session_id: id,
|
|
@@ -483,6 +550,7 @@ module Sessions
|
|
|
483
550
|
device_model: try(:device_model),
|
|
484
551
|
app_name: try(:app_name),
|
|
485
552
|
app_version: try(:app_version),
|
|
553
|
+
app_build: try(:app_build),
|
|
486
554
|
country_code: try(:country_code),
|
|
487
555
|
country_name: try(:country_name),
|
|
488
556
|
city: try(:city),
|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
module Sessions
|
|
4
4
|
# The append-only login-activity trail: every successful AND failed login,
|
|
5
5
|
# logout, revocation and expiry — with attempted identity, device, geo,
|
|
6
|
-
# and the linkage no prior art has: `session_id` points at the
|
|
6
|
+
# and the linkage no prior art has: `session_id` points at the lifecycle
|
|
7
7
|
# registry row the event created (or ended), so a suspicious login in the
|
|
8
8
|
# trail is one click away from revoking the session it started.
|
|
9
9
|
#
|
|
10
|
-
# `session_id` is a plain column with NO foreign key on purpose:
|
|
11
|
-
#
|
|
12
|
-
# must survive
|
|
10
|
+
# `session_id` is a plain column with NO foreign key on purpose: hosts may
|
|
11
|
+
# still hard-delete rows for account erasure or legacy Rails destroy_all
|
|
12
|
+
# flows, and history must survive that. Normal v0.2 logout/revocation ends
|
|
13
|
+
# the row in place and records an event as audit, not liveness state.
|
|
13
14
|
#
|
|
14
15
|
# Rows are written through one tolerant pipeline (`.record!`): unknown
|
|
15
16
|
# attributes are dropped instead of raising, so hosts can add or remove
|
|
@@ -85,20 +86,26 @@ module Sessions
|
|
|
85
86
|
# tees the event into `config.events`. Returns the Event or nil —
|
|
86
87
|
# never raises into a login.
|
|
87
88
|
def record!(attributes)
|
|
88
|
-
Sessions.safely("event")
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
event
|
|
89
|
+
Sessions.safely("event") { record_strict!(attributes) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Same tolerant attribute pipeline as `record!`, but persistence errors
|
|
93
|
+
# bubble. `Session#end!` writes lifecycle state and its matching audit
|
|
94
|
+
# event in one transaction; if the event cannot be written, the row must
|
|
95
|
+
# stay live rather than silently losing the audit trail.
|
|
96
|
+
def record_strict!(attributes, notify: true)
|
|
97
|
+
event = new
|
|
98
|
+
attributes.each do |name, value|
|
|
99
|
+
next if value.nil?
|
|
100
|
+
|
|
101
|
+
event.try(:"#{name}=", value)
|
|
101
102
|
end
|
|
103
|
+
event.identity = normalize_identity(event.try(:identity))
|
|
104
|
+
clamp_string_columns!(event)
|
|
105
|
+
event.save!
|
|
106
|
+
|
|
107
|
+
Sessions.notify_event(event) if notify
|
|
108
|
+
event
|
|
102
109
|
end
|
|
103
110
|
|
|
104
111
|
# Clamp string columns to their limits BEFORE the insert: the
|
data/lib/sessions/version.rb
CHANGED
data/lib/sessions.rb
CHANGED
|
@@ -14,6 +14,7 @@ require_relative "sessions/version"
|
|
|
14
14
|
require_relative "sessions/errors"
|
|
15
15
|
require_relative "sessions/configuration"
|
|
16
16
|
require_relative "sessions/current"
|
|
17
|
+
require_relative "sessions/end_reason"
|
|
17
18
|
require_relative "sessions/ip_address"
|
|
18
19
|
require_relative "sessions/device"
|
|
19
20
|
require_relative "sessions/classifier"
|
|
@@ -36,7 +37,7 @@ require_relative "sessions/engine" if defined?(::Rails::Engine)
|
|
|
36
37
|
# Sessions.configure { |config| ... } # one block, in an initializer
|
|
37
38
|
# has_sessions # on your auth model
|
|
38
39
|
#
|
|
39
|
-
# current_user.sessions.
|
|
40
|
+
# current_user.sessions.live # live devices
|
|
40
41
|
# session.device_name # => "Chrome on macOS"
|
|
41
42
|
# session.revoke! # remote logout, effective next request
|
|
42
43
|
# current_user.revoke_other_sessions! # GitHub's "sign out everywhere else"
|
|
@@ -245,7 +246,7 @@ module Sessions
|
|
|
245
246
|
}
|
|
246
247
|
end
|
|
247
248
|
|
|
248
|
-
# Right-to-erasure helper: destroy every
|
|
249
|
+
# Right-to-erasure helper: destroy every session row, delete the trail,
|
|
249
250
|
# and null the typed identity on any retained failure rows that match
|
|
250
251
|
# +user+'s email — so honoring a GDPR deletion request is one call.
|
|
251
252
|
def forget(user, identity: nil)
|
|
@@ -327,11 +328,11 @@ module Sessions
|
|
|
327
328
|
request.session.to_hash.each do |key, value|
|
|
328
329
|
next unless key.to_s.match?(pattern) && value.is_a?(Hash)
|
|
329
330
|
|
|
330
|
-
|
|
331
|
-
next unless
|
|
331
|
+
tracking = Adapters::Warden.parse_tracking_state(value[Adapters::Warden::SESSION_KEY])
|
|
332
|
+
next unless tracking && tracking[:mode] == "credential"
|
|
332
333
|
|
|
333
|
-
row = session_model.find_by(id: id)
|
|
334
|
-
return row if row.respond_to?(:sessions_token_matches?) && row&.sessions_token_matches?(token)
|
|
334
|
+
row = session_model.live.find_by(id: tracking[:id])
|
|
335
|
+
return row if row.respond_to?(:sessions_token_matches?) && row&.sessions_token_matches?(tracking[:token])
|
|
335
336
|
end
|
|
336
337
|
nil
|
|
337
338
|
rescue StandardError
|
|
@@ -342,7 +343,7 @@ module Sessions
|
|
|
342
343
|
return nil unless request.respond_to?(:cookie_jar)
|
|
343
344
|
|
|
344
345
|
id = request.cookie_jar.signed[:session_id]
|
|
345
|
-
session_model.find_by(id: id) if id
|
|
346
|
+
session_model.live.find_by(id: id) if id
|
|
346
347
|
rescue StandardError
|
|
347
348
|
nil
|
|
348
349
|
end
|
|
@@ -374,12 +375,12 @@ module Sessions
|
|
|
374
375
|
threshold = idle.ago
|
|
375
376
|
# A session's last activity is its throttled touch when present,
|
|
376
377
|
# 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)
|
|
378
|
+
scopes << session_model.live.where(last_seen_at: ...threshold)
|
|
379
|
+
scopes << session_model.live.where(last_seen_at: nil).where(created_at: ...threshold)
|
|
379
380
|
end
|
|
380
381
|
|
|
381
382
|
if (lifetime = config.max_session_lifetime)
|
|
382
|
-
scopes << session_model.where(created_at: ...lifetime.ago)
|
|
383
|
+
scopes << session_model.live.where(created_at: ...lifetime.ago)
|
|
383
384
|
end
|
|
384
385
|
|
|
385
386
|
# NOTE: reduce(:or), never `none.or(...)` — a NullRelation stays null
|
|
@@ -398,10 +399,10 @@ module Sessions
|
|
|
398
399
|
owner_keys = session_model.column_names.include?("user_type") ? %i[user_type user_id] : %i[user_id]
|
|
399
400
|
|
|
400
401
|
count = 0
|
|
401
|
-
session_model.group(*owner_keys).count.each do |owner_key, sessions_count|
|
|
402
|
+
session_model.live.group(*owner_keys).count.each do |owner_key, sessions_count|
|
|
402
403
|
next if sessions_count <= cap
|
|
403
404
|
|
|
404
|
-
session_model.where(owner_keys.zip(Array(owner_key)).to_h)
|
|
405
|
+
session_model.live.where(owner_keys.zip(Array(owner_key)).to_h)
|
|
405
406
|
.order(created_at: :asc)
|
|
406
407
|
.limit(sessions_count - cap)
|
|
407
408
|
.each do |session|
|