sessions 0.1.3 → 0.2.2
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 +35 -2
- data/README.md +26 -16
- data/app/controllers/sessions/application_controller.rb +4 -3
- data/app/helpers/sessions/engine_helper.rb +23 -0
- data/app/views/sessions/_devices.html.erb +2 -2
- data/app/views/sessions/_history.html.erb +2 -2
- 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 +23 -7
- data/lib/generators/sessions/templates/add_lifecycle_to_sessions.rb.erb +37 -0
- data/lib/generators/sessions/templates/add_sessions_columns.rb.erb +28 -0
- data/lib/generators/sessions/templates/create_sessions.rb.erb +20 -5
- data/lib/generators/sessions/templates/create_sessions_events.rb.erb +4 -4
- data/lib/generators/sessions/templates/initializer.rb +9 -8
- data/lib/generators/sessions/templates/madmin/session_resource.rb +24 -13
- data/lib/generators/sessions/templates/madmin/sessions_controller.rb +11 -7
- data/lib/generators/sessions/templates/madmin/user_panel/madmin_user_panel.rb +43 -0
- data/lib/generators/sessions/templates/madmin/user_panel/sessions.html.erb +133 -0
- data/lib/generators/sessions/upgrade_generator.rb +4 -1
- data/lib/sessions/adapters/omakase.rb +62 -18
- data/lib/sessions/adapters/warden.rb +172 -48
- data/lib/sessions/configuration.rb +14 -4
- data/lib/sessions/current.rb +4 -4
- data/lib/sessions/end_reason.rb +67 -0
- data/lib/sessions/models/concerns/has_sessions.rb +3 -3
- data/lib/sessions/models/concerns/model.rb +124 -59
- data/lib/sessions/models/event.rb +56 -21
- data/lib/sessions/version.rb +1 -1
- data/lib/sessions.rb +68 -14
- metadata +9 -5
|
@@ -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,9 +53,9 @@ 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
|
|
@@ -61,24 +64,30 @@ module Sessions
|
|
|
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,6 +375,24 @@ 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
|
|
@@ -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, no trail event (
|
|
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,46 +490,50 @@ module Sessions
|
|
|
429
490
|
end
|
|
430
491
|
end
|
|
431
492
|
|
|
432
|
-
# --- after_destroy_commit:
|
|
493
|
+
# --- after_destroy_commit: legacy/direct-delete compatibility -----------------
|
|
433
494
|
|
|
434
|
-
def
|
|
435
|
-
|
|
436
|
-
# Account deletion: dependent-destroyed rows of a destroyed owner
|
|
437
|
-
# write no events (their trail is erased with them — GDPR default).
|
|
438
|
-
next if user.nil? || user.destroyed?
|
|
495
|
+
def sessions_record_destroyed_end
|
|
496
|
+
return if ended?
|
|
439
497
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
else "revoked"
|
|
447
|
-
end
|
|
448
|
-
|
|
449
|
-
Sessions::Event.record!(
|
|
450
|
-
sessions_event_identity_attributes.merge(
|
|
451
|
-
event: event_name,
|
|
452
|
-
revoked_reason: (event_name == "revoked" ? (reason || :unknown) : nil),
|
|
453
|
-
metadata: sessions_revoked_by_metadata
|
|
454
|
-
)
|
|
455
|
-
)
|
|
498
|
+
Sessions.safely("record_end") do
|
|
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)
|
|
456
504
|
end
|
|
457
505
|
end
|
|
458
506
|
|
|
459
|
-
def
|
|
460
|
-
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
|
|
461
526
|
|
|
462
|
-
label = if
|
|
463
|
-
"#{
|
|
527
|
+
label = if actor.respond_to?(:id) && actor.class.respond_to?(:name)
|
|
528
|
+
"#{actor.class.name}##{actor.id}"
|
|
464
529
|
else
|
|
465
|
-
|
|
530
|
+
actor.to_s
|
|
466
531
|
end
|
|
467
532
|
{ "revoked_by" => label }
|
|
468
533
|
end
|
|
469
534
|
|
|
470
535
|
# The row's identity, copied onto every event it produces — the trail
|
|
471
|
-
# must describe the device even after the row is
|
|
536
|
+
# must describe the device even after the row is ended or later erased.
|
|
472
537
|
def sessions_event_identity_attributes
|
|
473
538
|
{
|
|
474
539
|
session_id: id,
|
|
@@ -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
|
|
@@ -172,11 +179,14 @@ module Sessions
|
|
|
172
179
|
.count
|
|
173
180
|
return unless count == config[:threshold]
|
|
174
181
|
|
|
182
|
+
user = Sessions.user_for_identity(event.identity, scope: event.scope)
|
|
183
|
+
|
|
175
184
|
Sessions.safely("on_repeated_failed_logins hook") do
|
|
176
|
-
|
|
185
|
+
call_repeated_failed_logins_hook(
|
|
177
186
|
identity: event.identity,
|
|
178
187
|
count: count,
|
|
179
|
-
event: event
|
|
188
|
+
event: event,
|
|
189
|
+
user: user
|
|
180
190
|
)
|
|
181
191
|
end
|
|
182
192
|
end
|
|
@@ -192,6 +202,30 @@ module Sessions
|
|
|
192
202
|
normalized.empty? ? nil : normalized
|
|
193
203
|
end
|
|
194
204
|
|
|
205
|
+
def call_repeated_failed_logins_hook(**kwargs)
|
|
206
|
+
hook = Sessions.config.on_repeated_failed_logins
|
|
207
|
+
params = hook_keyword_parameters(hook)
|
|
208
|
+
return hook.call(**kwargs) if params.nil?
|
|
209
|
+
return hook.call(**kwargs) if params.any? { |type, _name| type == :keyrest }
|
|
210
|
+
|
|
211
|
+
accepted = params.select { |type, _name| %i[key keyreq].include?(type) }
|
|
212
|
+
.filter_map { |_type, name| name }
|
|
213
|
+
hook.call(**kwargs.slice(*accepted))
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Procs/lambdas/Method objects expose #parameters directly; a plain
|
|
217
|
+
# callable object (a class with #call) only exposes them through
|
|
218
|
+
# #method(:call). nil means "cannot introspect" — dispatch everything
|
|
219
|
+
# and let the hook decide.
|
|
220
|
+
def hook_keyword_parameters(hook)
|
|
221
|
+
return hook.parameters if hook.respond_to?(:parameters)
|
|
222
|
+
return hook.method(:call).parameters if hook.respond_to?(:call)
|
|
223
|
+
|
|
224
|
+
nil
|
|
225
|
+
rescue StandardError
|
|
226
|
+
nil
|
|
227
|
+
end
|
|
228
|
+
|
|
195
229
|
def context_for(request)
|
|
196
230
|
params = request.respond_to?(:path_parameters) ? request.path_parameters : nil
|
|
197
231
|
return nil unless params && params[:controller]
|
|
@@ -214,8 +248,9 @@ module Sessions
|
|
|
214
248
|
authenticatable
|
|
215
249
|
end
|
|
216
250
|
|
|
217
|
-
# The
|
|
218
|
-
#
|
|
251
|
+
# The lifecycle registry row this event points at. In v0.2 an ended row
|
|
252
|
+
# remains available as explicit revocation state; nil means the host later
|
|
253
|
+
# hard-deleted it (for example during account erasure).
|
|
219
254
|
def session
|
|
220
255
|
return nil if session_id.nil?
|
|
221
256
|
|
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"
|
|
@@ -93,6 +94,27 @@ module Sessions
|
|
|
93
94
|
config.session_model
|
|
94
95
|
end
|
|
95
96
|
|
|
97
|
+
# Resolve a typed login identity to an account for host hooks. Failed
|
|
98
|
+
# login events still do NOT persist an account link (enumeration safety);
|
|
99
|
+
# this is only an in-process convenience for notification hooks that are
|
|
100
|
+
# already allowed to know whether the account exists.
|
|
101
|
+
def user_for_identity(identity, scope: nil)
|
|
102
|
+
normalized = Sessions::Event.normalize_identity(identity)
|
|
103
|
+
return nil if normalized.blank?
|
|
104
|
+
|
|
105
|
+
safely("user_for_identity") do
|
|
106
|
+
model = user_model_for_identity(scope)
|
|
107
|
+
next nil unless model.respond_to?(:column_names)
|
|
108
|
+
|
|
109
|
+
column = %w[email email_address login username phone].find { |name| model.column_names.include?(name) }
|
|
110
|
+
next nil unless column
|
|
111
|
+
|
|
112
|
+
table = model.quoted_table_name
|
|
113
|
+
quoted_column = model.connection.quote_column_name(column)
|
|
114
|
+
model.where("LOWER(#{table}.#{quoted_column}) = ?", normalized).first
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
96
118
|
# --- Request-side API -----------------------------------------------------
|
|
97
119
|
|
|
98
120
|
# Label the login that's about to happen on this request — for flows
|
|
@@ -245,7 +267,7 @@ module Sessions
|
|
|
245
267
|
}
|
|
246
268
|
end
|
|
247
269
|
|
|
248
|
-
# Right-to-erasure helper: destroy every
|
|
270
|
+
# Right-to-erasure helper: destroy every session row, delete the trail,
|
|
249
271
|
# and null the typed identity on any retained failure rows that match
|
|
250
272
|
# +user+'s email — so honoring a GDPR deletion request is one call.
|
|
251
273
|
def forget(user, identity: nil)
|
|
@@ -311,6 +333,20 @@ module Sessions
|
|
|
311
333
|
|
|
312
334
|
private
|
|
313
335
|
|
|
336
|
+
def user_model_for_identity(scope)
|
|
337
|
+
if scope && defined?(::Devise) && ::Devise.respond_to?(:mappings)
|
|
338
|
+
mapping = ::Devise.mappings[scope.to_sym] || ::Devise.mappings[scope.to_s]
|
|
339
|
+
return mapping.to if mapping.respond_to?(:to)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
reflection = session_model.reflect_on_association(:user)
|
|
343
|
+
return nil if reflection&.polymorphic?
|
|
344
|
+
|
|
345
|
+
reflection&.klass
|
|
346
|
+
rescue StandardError
|
|
347
|
+
nil
|
|
348
|
+
end
|
|
349
|
+
|
|
314
350
|
def omakase_current(_request)
|
|
315
351
|
return nil unless defined?(::Current) && ::Current.respond_to?(:session)
|
|
316
352
|
|
|
@@ -327,11 +363,23 @@ module Sessions
|
|
|
327
363
|
request.session.to_hash.each do |key, value|
|
|
328
364
|
next unless key.to_s.match?(pattern) && value.is_a?(Hash)
|
|
329
365
|
|
|
330
|
-
|
|
331
|
-
next unless
|
|
332
|
-
|
|
333
|
-
row = session_model.find_by(id: id)
|
|
334
|
-
|
|
366
|
+
tracking = Adapters::Warden.parse_tracking_state(value[Adapters::Warden::SESSION_KEY])
|
|
367
|
+
next unless tracking
|
|
368
|
+
|
|
369
|
+
row = session_model.live.find_by(id: tracking[:id])
|
|
370
|
+
next unless row
|
|
371
|
+
|
|
372
|
+
case tracking[:mode]
|
|
373
|
+
when "credential"
|
|
374
|
+
return row if row.respond_to?(:sessions_token_matches?) && row.sessions_token_matches?(tracking[:token])
|
|
375
|
+
when "hint"
|
|
376
|
+
# Tokenless remembered reattach: the tracking state is written
|
|
377
|
+
# server-side, but corroborate with the signed browser-continuity
|
|
378
|
+
# cookie before answering "this is the current device" — the same
|
|
379
|
+
# proof the adapter demands before touching or kicking a hint row.
|
|
380
|
+
device_id = Adapters::Warden.device_id_from_request(request)
|
|
381
|
+
return row if device_id.present? && device_id == row.try(:device_id)
|
|
382
|
+
end
|
|
335
383
|
end
|
|
336
384
|
nil
|
|
337
385
|
rescue StandardError
|
|
@@ -342,7 +390,7 @@ module Sessions
|
|
|
342
390
|
return nil unless request.respond_to?(:cookie_jar)
|
|
343
391
|
|
|
344
392
|
id = request.cookie_jar.signed[:session_id]
|
|
345
|
-
session_model.find_by(id: id) if id
|
|
393
|
+
session_model.live.find_by(id: id) if id
|
|
346
394
|
rescue StandardError
|
|
347
395
|
nil
|
|
348
396
|
end
|
|
@@ -361,7 +409,13 @@ module Sessions
|
|
|
361
409
|
|
|
362
410
|
count = 0
|
|
363
411
|
expired_sessions_scope.find_each do |session|
|
|
364
|
-
|
|
412
|
+
# Expiry is timeout POLICY, not a revocation: end the row (writes the
|
|
413
|
+
# `expired` audit event; `expired` kicks on resume) but never rotate
|
|
414
|
+
# the account-wide remember-me credential or fire on_session_revoked.
|
|
415
|
+
# This matches the adapter's inline expiry and Devise's timeoutable,
|
|
416
|
+
# where remember-me revival after a timeout is the host's own
|
|
417
|
+
# rememberable policy — not ours to nuke for every other device.
|
|
418
|
+
session.end!(reason: :expired)
|
|
365
419
|
count += 1
|
|
366
420
|
end
|
|
367
421
|
count
|
|
@@ -374,12 +428,12 @@ module Sessions
|
|
|
374
428
|
threshold = idle.ago
|
|
375
429
|
# A session's last activity is its throttled touch when present,
|
|
376
430
|
# 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)
|
|
431
|
+
scopes << session_model.live.where(last_seen_at: ...threshold)
|
|
432
|
+
scopes << session_model.live.where(last_seen_at: nil).where(created_at: ...threshold)
|
|
379
433
|
end
|
|
380
434
|
|
|
381
435
|
if (lifetime = config.max_session_lifetime)
|
|
382
|
-
scopes << session_model.where(created_at: ...lifetime.ago)
|
|
436
|
+
scopes << session_model.live.where(created_at: ...lifetime.ago)
|
|
383
437
|
end
|
|
384
438
|
|
|
385
439
|
# NOTE: reduce(:or), never `none.or(...)` — a NullRelation stays null
|
|
@@ -398,10 +452,10 @@ module Sessions
|
|
|
398
452
|
owner_keys = session_model.column_names.include?("user_type") ? %i[user_type user_id] : %i[user_id]
|
|
399
453
|
|
|
400
454
|
count = 0
|
|
401
|
-
session_model.group(*owner_keys).count.each do |owner_key, sessions_count|
|
|
455
|
+
session_model.live.group(*owner_keys).count.each do |owner_key, sessions_count|
|
|
402
456
|
next if sessions_count <= cap
|
|
403
457
|
|
|
404
|
-
session_model.where(owner_keys.zip(Array(owner_key)).to_h)
|
|
458
|
+
session_model.live.where(owner_keys.zip(Array(owner_key)).to_h)
|
|
405
459
|
.order(created_at: :asc)
|
|
406
460
|
.limit(sessions_count - cap)
|
|
407
461
|
.each do |session|
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: sessions
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-06
|
|
10
|
+
date: 2026-07-06 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: actionpack
|
|
@@ -73,14 +73,14 @@ dependencies:
|
|
|
73
73
|
name: browser
|
|
74
74
|
requirement: !ruby/object:Gem::Requirement
|
|
75
75
|
requirements:
|
|
76
|
-
- - "
|
|
76
|
+
- - "~>"
|
|
77
77
|
- !ruby/object:Gem::Version
|
|
78
78
|
version: '6.0'
|
|
79
79
|
type: :runtime
|
|
80
80
|
prerelease: false
|
|
81
81
|
version_requirements: !ruby/object:Gem::Requirement
|
|
82
82
|
requirements:
|
|
83
|
-
- - "
|
|
83
|
+
- - "~>"
|
|
84
84
|
- !ruby/object:Gem::Version
|
|
85
85
|
version: '6.0'
|
|
86
86
|
- !ruby/object:Gem::Dependency
|
|
@@ -148,7 +148,7 @@ files:
|
|
|
148
148
|
- config/locales/es.yml
|
|
149
149
|
- config/routes.rb
|
|
150
150
|
- docs/PRD.md
|
|
151
|
-
- docs/research/01-
|
|
151
|
+
- docs/research/01-host-app.md
|
|
152
152
|
- docs/research/02-ecosystem.md
|
|
153
153
|
- docs/research/03-rails-core.md
|
|
154
154
|
- docs/research/04-devise-warden.md
|
|
@@ -165,6 +165,7 @@ files:
|
|
|
165
165
|
- lib/generators/sessions/madmin_generator.rb
|
|
166
166
|
- lib/generators/sessions/templates/add_adoption_key_to_sessions.rb.erb
|
|
167
167
|
- lib/generators/sessions/templates/add_app_build_to_sessions_events.rb.erb
|
|
168
|
+
- lib/generators/sessions/templates/add_lifecycle_to_sessions.rb.erb
|
|
168
169
|
- lib/generators/sessions/templates/add_sessions_columns.rb.erb
|
|
169
170
|
- lib/generators/sessions/templates/create_sessions.rb.erb
|
|
170
171
|
- lib/generators/sessions/templates/create_sessions_events.rb.erb
|
|
@@ -173,6 +174,8 @@ files:
|
|
|
173
174
|
- lib/generators/sessions/templates/madmin/session_events_controller.rb
|
|
174
175
|
- lib/generators/sessions/templates/madmin/session_resource.rb
|
|
175
176
|
- lib/generators/sessions/templates/madmin/sessions_controller.rb
|
|
177
|
+
- lib/generators/sessions/templates/madmin/user_panel/madmin_user_panel.rb
|
|
178
|
+
- lib/generators/sessions/templates/madmin/user_panel/sessions.html.erb
|
|
176
179
|
- lib/generators/sessions/templates/session.rb.erb
|
|
177
180
|
- lib/generators/sessions/templates/sessions_sweep_job.rb
|
|
178
181
|
- lib/generators/sessions/upgrade_generator.rb
|
|
@@ -185,6 +188,7 @@ files:
|
|
|
185
188
|
- lib/sessions/configuration.rb
|
|
186
189
|
- lib/sessions/current.rb
|
|
187
190
|
- lib/sessions/device.rb
|
|
191
|
+
- lib/sessions/end_reason.rb
|
|
188
192
|
- lib/sessions/engine.rb
|
|
189
193
|
- lib/sessions/errors.rb
|
|
190
194
|
- lib/sessions/geolocation.rb
|