sessions 0.1.3 → 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 +13 -2
- data/README.md +9 -9
- 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_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 +4 -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 +4 -1
- data/lib/sessions/adapters/omakase.rb +62 -18
- data/lib/sessions/adapters/warden.rb +163 -48
- 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/models/concerns/has_sessions.rb +3 -3
- data/lib/sessions/models/concerns/model.rb +124 -59
- data/lib/sessions/models/event.rb +24 -17
- data/lib/sessions/version.rb +1 -1
- data/lib/sessions.rb +13 -12
- metadata +4 -2
|
@@ -12,17 +12,16 @@ module Sessions
|
|
|
12
12
|
# through 8.1.3 to main — → docs/research/03-rails-core.md):
|
|
13
13
|
#
|
|
14
14
|
# 1. The Session MODEL gets Sessions::Model included. Its callbacks
|
|
15
|
-
# observe
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
# controller coupling.
|
|
15
|
+
# observe `start_new_session_for` (create!) for logins. Normal
|
|
16
|
+
# logout is intercepted by ControllerHooks and ends the row in
|
|
17
|
+
# place; host-side destroy_all (Rails password reset/account erasure)
|
|
18
|
+
# is still tolerated by the model's destroy compatibility hook.
|
|
20
19
|
#
|
|
21
20
|
# 2. ApplicationController gets ControllerHooks PREPENDED — the
|
|
22
21
|
# prepend sits in front of the included Authentication concern in
|
|
23
22
|
# the ancestor chain, so `super`-wrapping `resume_session`
|
|
24
23
|
# (throttled touch + opt-in expiry) and `terminate_session`
|
|
25
|
-
# (labeling the
|
|
24
|
+
# (labeling the lifecycle end as a logout) is clean.
|
|
26
25
|
#
|
|
27
26
|
# 3. The generated SessionsController#create gets FailedLoginHooks
|
|
28
27
|
# prepended: `authenticate_by` is deliberately silent on failure
|
|
@@ -129,32 +128,77 @@ module Sessions
|
|
|
129
128
|
module ControllerHooks
|
|
130
129
|
private
|
|
131
130
|
|
|
132
|
-
# After the host resolves the session row,
|
|
133
|
-
# apply the throttled last_seen_at touch.
|
|
134
|
-
#
|
|
135
|
-
#
|
|
131
|
+
# After the host resolves the session row, refuse ended lifecycle rows,
|
|
132
|
+
# enforce opt-in expiry and apply the throttled last_seen_at touch.
|
|
133
|
+
# In Rails 8 auth this row is the session of record, so an ended row
|
|
134
|
+
# must be treated as unauthenticated even though it still exists for
|
|
135
|
+
# audit/history.
|
|
136
136
|
def resume_session
|
|
137
137
|
session = super
|
|
138
138
|
return session unless session.respond_to?(:sessions_expired?)
|
|
139
139
|
|
|
140
|
-
if session.
|
|
141
|
-
Sessions.safely("omakase.
|
|
140
|
+
if session.ended?
|
|
141
|
+
Sessions.safely("omakase.ended") { sessions_clear_omakase_session_cookie }
|
|
142
142
|
::Current.session = nil if defined?(::Current) && ::Current.respond_to?(:session=)
|
|
143
143
|
nil
|
|
144
|
+
elsif session.sessions_expired?
|
|
145
|
+
# The lifecycle row is the server-side liveness source of truth.
|
|
146
|
+
# If the end transition rolls back (for example, the audit event
|
|
147
|
+
# cannot be written), do not clear the Rails auth cookie: that
|
|
148
|
+
# would log the user out while leaving a stale `.live` row behind.
|
|
149
|
+
# Source: Warden's session_limitable pattern also kicks only after
|
|
150
|
+
# a durable server-side state change:
|
|
151
|
+
# https://github.com/devise-security/devise-security/blob/v0.18.0/lib/devise-security/hooks/session_limitable.rb
|
|
152
|
+
if sessions_end_omakase_session(session, reason: :expired, context: "omakase.expire")
|
|
153
|
+
Sessions.safely("omakase.expire.cookie") { sessions_clear_omakase_session_cookie }
|
|
154
|
+
::Current.session = nil if defined?(::Current) && ::Current.respond_to?(:session=)
|
|
155
|
+
nil
|
|
156
|
+
else
|
|
157
|
+
session
|
|
158
|
+
end
|
|
144
159
|
else
|
|
145
160
|
Sessions.safely("omakase.touch") { session.touch_last_seen!(request) }
|
|
146
161
|
session
|
|
147
162
|
end
|
|
148
163
|
end
|
|
149
164
|
|
|
150
|
-
#
|
|
151
|
-
#
|
|
165
|
+
# Rails' generated auth calls `Current.session.destroy` here:
|
|
166
|
+
# https://github.com/rails/rails/blob/main/railties/lib/rails/generators/rails/authentication/templates/app/controllers/concerns/authentication.rb.tt
|
|
167
|
+
# v0.2 preserves the row and marks it ended instead, because the row is
|
|
168
|
+
# now the lifecycle source of truth. We still delete the signed cookie
|
|
169
|
+
# exactly like the generated method.
|
|
152
170
|
def terminate_session
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
session
|
|
171
|
+
session = defined?(::Current) ? ::Current.try(:session) : nil
|
|
172
|
+
if session.respond_to?(:end!)
|
|
173
|
+
sessions_end_omakase_session!(session, reason: :logout, context: "omakase.terminate")
|
|
174
|
+
::Current.session = nil if defined?(::Current) && ::Current.respond_to?(:session=)
|
|
175
|
+
sessions_clear_omakase_session_cookie
|
|
176
|
+
else
|
|
177
|
+
super
|
|
156
178
|
end
|
|
157
|
-
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def sessions_clear_omakase_session_cookie
|
|
182
|
+
cookies.delete(:session_id)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def sessions_end_omakase_session(session, reason:, context:)
|
|
186
|
+
session.end!(reason: reason)
|
|
187
|
+
true
|
|
188
|
+
rescue StandardError => e
|
|
189
|
+
Sessions.warn("#{context} failed open: #{e.class}: #{e.message}")
|
|
190
|
+
false
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def sessions_end_omakase_session!(session, reason:, context:)
|
|
194
|
+
session.end!(reason: reason)
|
|
195
|
+
true
|
|
196
|
+
rescue StandardError => e
|
|
197
|
+
# Explicit logout must either persist its lifecycle transition or
|
|
198
|
+
# abort before deleting the cookie. Otherwise the tracking layer
|
|
199
|
+
# silently changes auth state while the row still says "live".
|
|
200
|
+
Sessions.warn("#{context} aborted auth teardown: #{e.class}: #{e.message}")
|
|
201
|
+
raise
|
|
158
202
|
end
|
|
159
203
|
end
|
|
160
204
|
|
|
@@ -13,18 +13,25 @@ module Sessions
|
|
|
13
13
|
# to a sessions-table ROW, turning "exactly one session" into "N devices,
|
|
14
14
|
# each individually revocable" (→ docs/research/04-devise-warden.md §5).
|
|
15
15
|
#
|
|
16
|
-
# login — mint a random token, store
|
|
16
|
+
# login — mint a random token, store structured tracking state in the
|
|
17
17
|
# per-scope warden session (it survives Warden's :renew SID
|
|
18
18
|
# rotation and is deleted by Warden itself on logout; we
|
|
19
19
|
# never key on the Rack SID), persist only the SHA-256
|
|
20
20
|
# digest on the row.
|
|
21
21
|
# fetch — per-request liveness check: row exists + digest matches
|
|
22
|
-
# (constant-time) → throttled touch; row
|
|
23
|
-
# the proven session_limitable kick:
|
|
22
|
+
# (constant-time) + row is live → throttled touch; row ended
|
|
23
|
+
# for an explicit reason → the proven session_limitable kick:
|
|
24
|
+
# clear, logout, throw. Missing/mismatched tracking fails open
|
|
25
|
+
# unless a legacy v0.1.x event tombstone proves a pre-lifecycle
|
|
26
|
+
# remote revocation.
|
|
24
27
|
# failure — record the failed attempt with the typed identity.
|
|
25
|
-
# logout —
|
|
28
|
+
# logout — mark the row ended, labeled as a logout.
|
|
26
29
|
module Warden
|
|
27
|
-
# Key inside `warden.session(scope)` holding
|
|
30
|
+
# Key inside `warden.session(scope)` holding the sessions gem tracking
|
|
31
|
+
# state. v0.2 writes a small Hash (`id`, `token`, `mode`) instead of the
|
|
32
|
+
# old `[row_id, raw_token]` tuple so a nil token can never accidentally
|
|
33
|
+
# read like an auth credential. The parser still accepts arrays because
|
|
34
|
+
# production users may carry v0.1.x cookies during deploy.
|
|
28
35
|
SESSION_KEY = "sessions"
|
|
29
36
|
|
|
30
37
|
# Rememberable can restore a user on background/native JSON requests
|
|
@@ -145,7 +152,7 @@ module Sessions
|
|
|
145
152
|
row.sessions_skip_supersede = skip_supersede
|
|
146
153
|
Sessions.with_request(request) { row.save! }
|
|
147
154
|
|
|
148
|
-
warden.session(scope)[SESSION_KEY] =
|
|
155
|
+
warden.session(scope)[SESSION_KEY] = tracking_state(row, token: token, mode: "credential")
|
|
149
156
|
row
|
|
150
157
|
end
|
|
151
158
|
|
|
@@ -168,8 +175,8 @@ module Sessions
|
|
|
168
175
|
end
|
|
169
176
|
return if data == :skip
|
|
170
177
|
|
|
171
|
-
|
|
172
|
-
if
|
|
178
|
+
tracking = parse_tracking_state(data && data[:login])
|
|
179
|
+
if tracking.nil?
|
|
173
180
|
if data && data[:pending_login]
|
|
174
181
|
activate_pending_login(record, warden, scope, data[:pending_login])
|
|
175
182
|
else
|
|
@@ -179,34 +186,65 @@ module Sessions
|
|
|
179
186
|
end
|
|
180
187
|
|
|
181
188
|
# The lookup is NOT wrapped in `safely`: an ERRORED lookup and a
|
|
182
|
-
# MISSING row must be distinguishable
|
|
183
|
-
#
|
|
184
|
-
#
|
|
185
|
-
#
|
|
186
|
-
#
|
|
187
|
-
#
|
|
189
|
+
# MISSING row must be distinguishable from a raised lookup, but a
|
|
190
|
+
# missing/mismatched tracking row is still not automatically auth
|
|
191
|
+
# state. In v0.2, only a matching row whose own ended_reason is
|
|
192
|
+
# explicit may kick. The event lookup below is legacy-only for v0.1.x
|
|
193
|
+
# cookies whose rows were already destroyed before lifecycle columns
|
|
194
|
+
# existed.
|
|
195
|
+
# A raised lookup — the sessions table unreachable, a timeout, a
|
|
196
|
+
# migration mid-deploy — means the TRACKING layer is down, and
|
|
197
|
+
# tracking must never break authentication: fail OPEN, let the request
|
|
198
|
+
# through untracked, try again next request.
|
|
188
199
|
begin
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
200
|
+
found = Sessions.session_model.find_by(id: tracking[:id])
|
|
201
|
+
if tracking[:mode] == "hint"
|
|
202
|
+
# v0.1.3 intentionally reattached remember-me restores to an
|
|
203
|
+
# existing device row without writing another login event, storing
|
|
204
|
+
# [row_id, nil] in Warden. That is fine as a tracking hint, but it
|
|
205
|
+
# must never become an auth/liveness check. Touch when the signed
|
|
206
|
+
# browser-continuity cookie still agrees; otherwise clear only the
|
|
207
|
+
# gem's tracking key and let Devise/Rails keep owning auth.
|
|
208
|
+
# Source: https://github.com/rameerez/sessions/blob/v0.1.3/CHANGELOG.md
|
|
209
|
+
if existing_row_session?(found, record, scope, warden.request) && found.live?
|
|
210
|
+
Sessions.safely("warden.remembered_existing.touch") { found.touch_last_seen!(warden.request) }
|
|
211
|
+
elsif (replacement = live_replacement_for(found, record, scope, warden.request))
|
|
212
|
+
attach_existing_row(replacement, warden, scope)
|
|
213
|
+
else
|
|
214
|
+
clear_tracking_key(warden, scope)
|
|
215
|
+
end
|
|
216
|
+
return
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
row = found if found&.sessions_token_matches?(tracking[:token])
|
|
196
220
|
rescue StandardError => e
|
|
197
221
|
Sessions.warn("warden.fetch failed open: #{e.class}: #{e.message}")
|
|
198
222
|
return
|
|
199
223
|
end
|
|
200
224
|
|
|
201
225
|
if row.nil?
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
226
|
+
if legacy_explicitly_ended_session?(tracking[:id])
|
|
227
|
+
# Explicit remote revocation/expiry is the one intentional place
|
|
228
|
+
# where a legacy v0.1.x destroyed row may still end a Devise
|
|
229
|
+
# session. v0.2 rows should be present and ended in place.
|
|
230
|
+
kick!(warden, scope)
|
|
231
|
+
else
|
|
232
|
+
clear_tracking_key(warden, scope)
|
|
233
|
+
end
|
|
234
|
+
elsif row.ended?
|
|
235
|
+
if row.sessions_kicks_on_resume?
|
|
236
|
+
kick!(warden, scope)
|
|
237
|
+
elsif (replacement = live_replacement_for(row, record, scope, warden.request))
|
|
238
|
+
attach_existing_row(replacement, warden, scope)
|
|
239
|
+
else
|
|
240
|
+
clear_tracking_key(warden, scope)
|
|
241
|
+
end
|
|
207
242
|
elsif Sessions.safely("warden.expired?") { row.sessions_expired? }
|
|
208
|
-
|
|
209
|
-
|
|
243
|
+
# Expiry is gem-initiated, so it must be durable before we touch
|
|
244
|
+
# Warden auth state. If the lifecycle write rolls back, fail open:
|
|
245
|
+
# the user stays authenticated and the next request can retry the
|
|
246
|
+
# expiry instead of leaving an orphan `.live` row.
|
|
247
|
+
kick!(warden, scope) if end_row_for_auth_teardown(row, reason: :expired, context: "warden.expire")
|
|
210
248
|
else
|
|
211
249
|
Sessions.safely("warden.touch") { row.touch_last_seen!(warden.request) }
|
|
212
250
|
end
|
|
@@ -300,7 +338,7 @@ module Sessions
|
|
|
300
338
|
device_id = device_id_from_request(warden.request)
|
|
301
339
|
return if device_id.blank?
|
|
302
340
|
|
|
303
|
-
rows = Sessions.session_model.where(user: record, device_id: device_id)
|
|
341
|
+
rows = Sessions.session_model.live.where(user: record, device_id: device_id)
|
|
304
342
|
rows = rows.where(scope: scope.to_s) if Sessions.session_model.column_names.include?("scope")
|
|
305
343
|
rows.order(created_at: :desc).first
|
|
306
344
|
rescue StandardError
|
|
@@ -308,7 +346,7 @@ module Sessions
|
|
|
308
346
|
end
|
|
309
347
|
|
|
310
348
|
def attach_existing_row(row, warden, scope)
|
|
311
|
-
warden.session(scope)[SESSION_KEY] =
|
|
349
|
+
warden.session(scope)[SESSION_KEY] = tracking_state(row, mode: "hint")
|
|
312
350
|
Sessions.safely("warden.remembered_existing.touch") { row.touch_last_seen!(warden.request) }
|
|
313
351
|
row
|
|
314
352
|
end
|
|
@@ -327,6 +365,19 @@ module Sessions
|
|
|
327
365
|
false
|
|
328
366
|
end
|
|
329
367
|
|
|
368
|
+
def live_replacement_for(row, record, scope, request)
|
|
369
|
+
device_id = row&.try(:device_id).presence || device_id_from_request(request)
|
|
370
|
+
return if device_id.blank?
|
|
371
|
+
|
|
372
|
+
model = Sessions.session_model
|
|
373
|
+
rows = model.live.where(user: record, device_id: device_id)
|
|
374
|
+
rows = rows.where(scope: scope.to_s) if model.column_names.include?("scope")
|
|
375
|
+
rows = rows.where.not(id: row.id) if row
|
|
376
|
+
rows.order(created_at: :desc).first
|
|
377
|
+
rescue StandardError
|
|
378
|
+
nil
|
|
379
|
+
end
|
|
380
|
+
|
|
330
381
|
def device_id_from_request(request)
|
|
331
382
|
return unless request.respond_to?(:cookie_jar)
|
|
332
383
|
|
|
@@ -386,10 +437,10 @@ module Sessions
|
|
|
386
437
|
|
|
387
438
|
def adopted_row(record, scope, adoption_key:)
|
|
388
439
|
model = Sessions.session_model
|
|
389
|
-
rows = model.where(user: record)
|
|
440
|
+
rows = model.live.where(user: record)
|
|
390
441
|
rows = rows.where(scope: scope.to_s) if model.column_names.include?("scope")
|
|
391
442
|
|
|
392
|
-
row = model.find_by(adoption_key: adoption_key) if adoption_key_column?(model) && adoption_key.present?
|
|
443
|
+
row = model.live.find_by(adoption_key: adoption_key) if adoption_key_column?(model) && adoption_key.present?
|
|
393
444
|
row = nil unless adopted_row?(row)
|
|
394
445
|
row ||= rows.order(created_at: :desc).detect { |candidate| adopted_row?(candidate) }
|
|
395
446
|
|
|
@@ -429,13 +480,60 @@ module Sessions
|
|
|
429
480
|
model.column_names.include?("adoption_key")
|
|
430
481
|
end
|
|
431
482
|
|
|
483
|
+
def clear_tracking_key(warden, scope)
|
|
484
|
+
warden.session(scope).delete(SESSION_KEY)
|
|
485
|
+
rescue StandardError
|
|
486
|
+
nil
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def tracking_state(row, mode:, token: nil)
|
|
490
|
+
{
|
|
491
|
+
"v" => 2,
|
|
492
|
+
"id" => row.id,
|
|
493
|
+
"token" => token,
|
|
494
|
+
"mode" => mode
|
|
495
|
+
}
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def parse_tracking_state(value)
|
|
499
|
+
case value
|
|
500
|
+
when Hash
|
|
501
|
+
id = value["id"] || value[:id]
|
|
502
|
+
token = value["token"] || value[:token]
|
|
503
|
+
mode = (value["mode"] || value[:mode]).presence
|
|
504
|
+
when Array
|
|
505
|
+
id, token = value
|
|
506
|
+
mode = token.present? ? "credential" : "hint"
|
|
507
|
+
else
|
|
508
|
+
return nil
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
return nil if id.blank?
|
|
512
|
+
|
|
513
|
+
mode = token.present? ? "credential" : (mode || "hint")
|
|
514
|
+
{ id: id, token: token, mode: mode }
|
|
515
|
+
rescue StandardError
|
|
516
|
+
nil
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def legacy_explicitly_ended_session?(id)
|
|
520
|
+
return false if id.blank?
|
|
521
|
+
|
|
522
|
+
events = Sessions::Event.where(session_id: id)
|
|
523
|
+
events.expirations.exists? ||
|
|
524
|
+
events.revocations.where.not(revoked_reason: "superseded").exists?
|
|
525
|
+
rescue StandardError => e
|
|
526
|
+
Sessions.warn("warden.fetch legacy end-event lookup failed open: #{e.class}: #{e.message}")
|
|
527
|
+
false
|
|
528
|
+
end
|
|
529
|
+
|
|
432
530
|
# SCOPE-PRECISE teardown: only this scope's warden entries go (the
|
|
433
531
|
# serialized user key and our token stash) — an admin scope riding
|
|
434
532
|
# the same rack session, and unrelated host session data (carts,
|
|
435
533
|
# locale, return-to paths), survive a user-scope kick. Deleting the
|
|
436
534
|
# keys BEFORE logout matters: our before_logout hook then finds no
|
|
437
|
-
# token and records nothing (a kick is not a logout — the
|
|
438
|
-
# event
|
|
535
|
+
# token and records nothing (a kick is not a logout — the lifecycle
|
|
536
|
+
# reason/event were already written by the explicit ending).
|
|
439
537
|
def kick!(warden, scope)
|
|
440
538
|
warden.raw_session.delete("warden.user.#{scope}.key")
|
|
441
539
|
warden.raw_session.delete("warden.user.#{scope}.session")
|
|
@@ -483,9 +581,8 @@ module Sessions
|
|
|
483
581
|
# --- Hook 4: logout ---------------------------------------------------------
|
|
484
582
|
|
|
485
583
|
# Fires once per scope (including forced logouts: timeout, lockout,
|
|
486
|
-
# our own revocation kick). If the row is already
|
|
487
|
-
#
|
|
488
|
-
# written by whoever destroyed it.
|
|
584
|
+
# our own revocation kick). If the row is already ended, there's nothing
|
|
585
|
+
# to do; lifecycle state is idempotent.
|
|
489
586
|
#
|
|
490
587
|
# CRITICAL: read the RAW session here, never `warden.session(scope)`.
|
|
491
588
|
# Warden's logout deletes `@users[scope]` BEFORE running before_logout
|
|
@@ -494,19 +591,29 @@ module Sessions
|
|
|
494
591
|
# logout came from a hook that logs out and throws (Devise's
|
|
495
592
|
# activatable on unconfirmed/locked accounts, timeoutable) that loops:
|
|
496
593
|
# activatable → logout → us → re-auth → activatable → … SystemStackError.
|
|
497
|
-
def record_logout(
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
next unless data
|
|
594
|
+
def record_logout(record, warden, opts)
|
|
595
|
+
scope = opts[:scope]
|
|
596
|
+
tracking = parse_tracking_state(warden.raw_session["warden.user.#{scope}.session"]&.dig(SESSION_KEY))
|
|
597
|
+
return unless tracking
|
|
502
598
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
599
|
+
row = Sessions.session_model.find_by(id: tracking[:id])
|
|
600
|
+
return unless row
|
|
601
|
+
return unless row.live?
|
|
506
602
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
603
|
+
token_backed = tracking[:mode] == "credential" && row.sessions_token_matches?(tracking[:token])
|
|
604
|
+
tokenless_known_device = tracking[:mode] == "hint" &&
|
|
605
|
+
existing_row_session?(row, record, scope, warden.request)
|
|
606
|
+
return unless token_backed || tokenless_known_device
|
|
607
|
+
|
|
608
|
+
# Warden 1.2.9 runs before_logout callbacks before deleting the
|
|
609
|
+
# serialized session keys (proxy.rb#logout). Raising here aborts the
|
|
610
|
+
# auth teardown, so an audit/lifecycle persistence failure cannot log
|
|
611
|
+
# the user out while leaving the row live.
|
|
612
|
+
# Source: https://github.com/wardencommunity/warden/blob/v1.2.9/lib/warden/proxy.rb#L266-L279
|
|
613
|
+
row.end!(reason: :logout)
|
|
614
|
+
rescue StandardError => e
|
|
615
|
+
Sessions.warn("warden.logout aborted auth teardown: #{e.class}: #{e.message}")
|
|
616
|
+
raise
|
|
510
617
|
end
|
|
511
618
|
|
|
512
619
|
def warden_default_scope(env)
|
|
@@ -529,6 +636,14 @@ module Sessions
|
|
|
529
636
|
rescue StandardError
|
|
530
637
|
false
|
|
531
638
|
end
|
|
639
|
+
|
|
640
|
+
def end_row_for_auth_teardown(row, reason:, context:)
|
|
641
|
+
row.end!(reason: reason)
|
|
642
|
+
true
|
|
643
|
+
rescue StandardError => e
|
|
644
|
+
Sessions.warn("#{context} failed open: #{e.class}: #{e.message}")
|
|
645
|
+
false
|
|
646
|
+
end
|
|
532
647
|
end
|
|
533
648
|
end
|
|
534
649
|
end
|
|
@@ -55,9 +55,10 @@ module Sessions
|
|
|
55
55
|
|
|
56
56
|
# Terminate other sessions when the user's password changes (ASVS 3.3.3
|
|
57
57
|
# / 7.4.3; Laravel's logoutOtherDevices and Phoenix's token nuke are the
|
|
58
|
-
# cross-framework precedent; Rails 8.1's
|
|
59
|
-
#
|
|
60
|
-
#
|
|
58
|
+
# cross-framework precedent; Rails 8.1's generated password reset uses
|
|
59
|
+
# `destroy_all`, which our direct-delete compatibility hook still labels
|
|
60
|
+
# honestly). Wired by `has_sessions` via an after_update on the password
|
|
61
|
+
# digest column, so it works on both auth stacks.
|
|
61
62
|
attr_accessor :revoke_on_password_change
|
|
62
63
|
|
|
63
64
|
# Devise mode only: revoking a session also rotates the user's
|
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
|
|
@@ -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
|
|