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
|
@@ -27,6 +27,8 @@ module Sessions
|
|
|
27
27
|
File.join(db_migrate_path, "add_sessions_adoption_key_to_#{table_name}.rb")
|
|
28
28
|
migration_template "add_app_build_to_sessions_events.rb.erb",
|
|
29
29
|
File.join(db_migrate_path, "add_sessions_app_build_to_sessions_events.rb")
|
|
30
|
+
migration_template "add_lifecycle_to_sessions.rb.erb",
|
|
31
|
+
File.join(db_migrate_path, "add_sessions_lifecycle_to_#{table_name}.rb")
|
|
30
32
|
end
|
|
31
33
|
|
|
32
34
|
def display_post_upgrade_message
|
|
@@ -34,7 +36,8 @@ module Sessions
|
|
|
34
36
|
say "\nTo complete the upgrade:"
|
|
35
37
|
say " 1. Review the generated migration."
|
|
36
38
|
say " 2. Run 'rails db:migrate'."
|
|
37
|
-
say " ⚠️ Deploy the migrations before relying on
|
|
39
|
+
say " ⚠️ Deploy the migrations before relying on lifecycle-row revocation, " \
|
|
40
|
+
"adoption hardening, or app-build event columns.", :yellow
|
|
38
41
|
end
|
|
39
42
|
|
|
40
43
|
private
|
|
@@ -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,74 @@ 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 FROM ABSENCE: missing
|
|
206
|
+
# rows, mismatched cookies and quiet housekeeping all fail open.
|
|
207
|
+
# An EXPLICIT ending is different — when the authenticated owner,
|
|
208
|
+
# the signed browser-continuity cookie and the row all agree this
|
|
209
|
+
# is the same device, and that row was deliberately ended
|
|
210
|
+
# (admin/user revoke, sign-out-everywhere), the revocation must
|
|
211
|
+
# mean what the UI promised: signed out on its next request.
|
|
212
|
+
# Housekeeping reasons (superseded) never kick and fall through
|
|
213
|
+
# to the live-replacement reattach below.
|
|
214
|
+
# Source: https://github.com/rameerez/sessions/blob/v0.1.3/CHANGELOG.md
|
|
215
|
+
same_device = existing_row_session?(found, record, scope, warden.request)
|
|
216
|
+
if same_device && found.live?
|
|
217
|
+
Sessions.safely("warden.remembered_existing.touch") { found.touch_last_seen!(warden.request) }
|
|
218
|
+
elsif same_device && found.sessions_kicks_on_resume?
|
|
219
|
+
kick!(warden, scope)
|
|
220
|
+
elsif (replacement = live_replacement_for(found, record, scope, warden.request))
|
|
221
|
+
attach_existing_row(replacement, warden, scope)
|
|
222
|
+
else
|
|
223
|
+
clear_tracking_key(warden, scope)
|
|
224
|
+
end
|
|
225
|
+
return
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
row = found if found&.sessions_token_matches?(tracking[:token])
|
|
196
229
|
rescue StandardError => e
|
|
197
230
|
Sessions.warn("warden.fetch failed open: #{e.class}: #{e.message}")
|
|
198
231
|
return
|
|
199
232
|
end
|
|
200
233
|
|
|
201
234
|
if row.nil?
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
235
|
+
if legacy_explicitly_ended_session?(tracking[:id])
|
|
236
|
+
# Explicit remote revocation/expiry is the one intentional place
|
|
237
|
+
# where a legacy v0.1.x destroyed row may still end a Devise
|
|
238
|
+
# session. v0.2 rows should be present and ended in place.
|
|
239
|
+
kick!(warden, scope)
|
|
240
|
+
else
|
|
241
|
+
clear_tracking_key(warden, scope)
|
|
242
|
+
end
|
|
243
|
+
elsif row.ended?
|
|
244
|
+
if row.sessions_kicks_on_resume?
|
|
245
|
+
kick!(warden, scope)
|
|
246
|
+
elsif (replacement = live_replacement_for(row, record, scope, warden.request))
|
|
247
|
+
attach_existing_row(replacement, warden, scope)
|
|
248
|
+
else
|
|
249
|
+
clear_tracking_key(warden, scope)
|
|
250
|
+
end
|
|
207
251
|
elsif Sessions.safely("warden.expired?") { row.sessions_expired? }
|
|
208
|
-
|
|
209
|
-
|
|
252
|
+
# Expiry is gem-initiated, so it must be durable before we touch
|
|
253
|
+
# Warden auth state. If the lifecycle write rolls back, fail open:
|
|
254
|
+
# the user stays authenticated and the next request can retry the
|
|
255
|
+
# expiry instead of leaving an orphan `.live` row.
|
|
256
|
+
kick!(warden, scope) if end_row_for_auth_teardown(row, reason: :expired, context: "warden.expire")
|
|
210
257
|
else
|
|
211
258
|
Sessions.safely("warden.touch") { row.touch_last_seen!(warden.request) }
|
|
212
259
|
end
|
|
@@ -300,7 +347,7 @@ module Sessions
|
|
|
300
347
|
device_id = device_id_from_request(warden.request)
|
|
301
348
|
return if device_id.blank?
|
|
302
349
|
|
|
303
|
-
rows = Sessions.session_model.where(user: record, device_id: device_id)
|
|
350
|
+
rows = Sessions.session_model.live.where(user: record, device_id: device_id)
|
|
304
351
|
rows = rows.where(scope: scope.to_s) if Sessions.session_model.column_names.include?("scope")
|
|
305
352
|
rows.order(created_at: :desc).first
|
|
306
353
|
rescue StandardError
|
|
@@ -308,7 +355,7 @@ module Sessions
|
|
|
308
355
|
end
|
|
309
356
|
|
|
310
357
|
def attach_existing_row(row, warden, scope)
|
|
311
|
-
warden.session(scope)[SESSION_KEY] =
|
|
358
|
+
warden.session(scope)[SESSION_KEY] = tracking_state(row, mode: "hint")
|
|
312
359
|
Sessions.safely("warden.remembered_existing.touch") { row.touch_last_seen!(warden.request) }
|
|
313
360
|
row
|
|
314
361
|
end
|
|
@@ -327,6 +374,19 @@ module Sessions
|
|
|
327
374
|
false
|
|
328
375
|
end
|
|
329
376
|
|
|
377
|
+
def live_replacement_for(row, record, scope, request)
|
|
378
|
+
device_id = row&.try(:device_id).presence || device_id_from_request(request)
|
|
379
|
+
return if device_id.blank?
|
|
380
|
+
|
|
381
|
+
model = Sessions.session_model
|
|
382
|
+
rows = model.live.where(user: record, device_id: device_id)
|
|
383
|
+
rows = rows.where(scope: scope.to_s) if model.column_names.include?("scope")
|
|
384
|
+
rows = rows.where.not(id: row.id) if row
|
|
385
|
+
rows.order(created_at: :desc).first
|
|
386
|
+
rescue StandardError
|
|
387
|
+
nil
|
|
388
|
+
end
|
|
389
|
+
|
|
330
390
|
def device_id_from_request(request)
|
|
331
391
|
return unless request.respond_to?(:cookie_jar)
|
|
332
392
|
|
|
@@ -386,10 +446,10 @@ module Sessions
|
|
|
386
446
|
|
|
387
447
|
def adopted_row(record, scope, adoption_key:)
|
|
388
448
|
model = Sessions.session_model
|
|
389
|
-
rows = model.where(user: record)
|
|
449
|
+
rows = model.live.where(user: record)
|
|
390
450
|
rows = rows.where(scope: scope.to_s) if model.column_names.include?("scope")
|
|
391
451
|
|
|
392
|
-
row = model.find_by(adoption_key: adoption_key) if adoption_key_column?(model) && adoption_key.present?
|
|
452
|
+
row = model.live.find_by(adoption_key: adoption_key) if adoption_key_column?(model) && adoption_key.present?
|
|
393
453
|
row = nil unless adopted_row?(row)
|
|
394
454
|
row ||= rows.order(created_at: :desc).detect { |candidate| adopted_row?(candidate) }
|
|
395
455
|
|
|
@@ -429,13 +489,60 @@ module Sessions
|
|
|
429
489
|
model.column_names.include?("adoption_key")
|
|
430
490
|
end
|
|
431
491
|
|
|
492
|
+
def clear_tracking_key(warden, scope)
|
|
493
|
+
warden.session(scope).delete(SESSION_KEY)
|
|
494
|
+
rescue StandardError
|
|
495
|
+
nil
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def tracking_state(row, mode:, token: nil)
|
|
499
|
+
{
|
|
500
|
+
"v" => 2,
|
|
501
|
+
"id" => row.id,
|
|
502
|
+
"token" => token,
|
|
503
|
+
"mode" => mode
|
|
504
|
+
}
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def parse_tracking_state(value)
|
|
508
|
+
case value
|
|
509
|
+
when Hash
|
|
510
|
+
id = value["id"] || value[:id]
|
|
511
|
+
token = value["token"] || value[:token]
|
|
512
|
+
mode = (value["mode"] || value[:mode]).presence
|
|
513
|
+
when Array
|
|
514
|
+
id, token = value
|
|
515
|
+
mode = token.present? ? "credential" : "hint"
|
|
516
|
+
else
|
|
517
|
+
return nil
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
return nil if id.blank?
|
|
521
|
+
|
|
522
|
+
mode = token.present? ? "credential" : (mode || "hint")
|
|
523
|
+
{ id: id, token: token, mode: mode }
|
|
524
|
+
rescue StandardError
|
|
525
|
+
nil
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
def legacy_explicitly_ended_session?(id)
|
|
529
|
+
return false if id.blank?
|
|
530
|
+
|
|
531
|
+
events = Sessions::Event.where(session_id: id)
|
|
532
|
+
events.expirations.exists? ||
|
|
533
|
+
events.revocations.where.not(revoked_reason: "superseded").exists?
|
|
534
|
+
rescue StandardError => e
|
|
535
|
+
Sessions.warn("warden.fetch legacy end-event lookup failed open: #{e.class}: #{e.message}")
|
|
536
|
+
false
|
|
537
|
+
end
|
|
538
|
+
|
|
432
539
|
# SCOPE-PRECISE teardown: only this scope's warden entries go (the
|
|
433
540
|
# serialized user key and our token stash) — an admin scope riding
|
|
434
541
|
# the same rack session, and unrelated host session data (carts,
|
|
435
542
|
# locale, return-to paths), survive a user-scope kick. Deleting the
|
|
436
543
|
# 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
|
|
544
|
+
# token and records nothing (a kick is not a logout — the lifecycle
|
|
545
|
+
# reason/event were already written by the explicit ending).
|
|
439
546
|
def kick!(warden, scope)
|
|
440
547
|
warden.raw_session.delete("warden.user.#{scope}.key")
|
|
441
548
|
warden.raw_session.delete("warden.user.#{scope}.session")
|
|
@@ -483,9 +590,8 @@ module Sessions
|
|
|
483
590
|
# --- Hook 4: logout ---------------------------------------------------------
|
|
484
591
|
|
|
485
592
|
# 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.
|
|
593
|
+
# our own revocation kick). If the row is already ended, there's nothing
|
|
594
|
+
# to do; lifecycle state is idempotent.
|
|
489
595
|
#
|
|
490
596
|
# CRITICAL: read the RAW session here, never `warden.session(scope)`.
|
|
491
597
|
# Warden's logout deletes `@users[scope]` BEFORE running before_logout
|
|
@@ -494,19 +600,29 @@ module Sessions
|
|
|
494
600
|
# logout came from a hook that logs out and throws (Devise's
|
|
495
601
|
# activatable on unconfirmed/locked accounts, timeoutable) that loops:
|
|
496
602
|
# activatable → logout → us → re-auth → activatable → … SystemStackError.
|
|
497
|
-
def record_logout(
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
next unless data
|
|
603
|
+
def record_logout(record, warden, opts)
|
|
604
|
+
scope = opts[:scope]
|
|
605
|
+
tracking = parse_tracking_state(warden.raw_session["warden.user.#{scope}.session"]&.dig(SESSION_KEY))
|
|
606
|
+
return unless tracking
|
|
502
607
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
608
|
+
row = Sessions.session_model.find_by(id: tracking[:id])
|
|
609
|
+
return unless row
|
|
610
|
+
return unless row.live?
|
|
506
611
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
612
|
+
token_backed = tracking[:mode] == "credential" && row.sessions_token_matches?(tracking[:token])
|
|
613
|
+
tokenless_known_device = tracking[:mode] == "hint" &&
|
|
614
|
+
existing_row_session?(row, record, scope, warden.request)
|
|
615
|
+
return unless token_backed || tokenless_known_device
|
|
616
|
+
|
|
617
|
+
# Warden 1.2.9 runs before_logout callbacks before deleting the
|
|
618
|
+
# serialized session keys (proxy.rb#logout). Raising here aborts the
|
|
619
|
+
# auth teardown, so an audit/lifecycle persistence failure cannot log
|
|
620
|
+
# the user out while leaving the row live.
|
|
621
|
+
# Source: https://github.com/wardencommunity/warden/blob/v1.2.9/lib/warden/proxy.rb#L266-L279
|
|
622
|
+
row.end!(reason: :logout)
|
|
623
|
+
rescue StandardError => e
|
|
624
|
+
Sessions.warn("warden.logout aborted auth teardown: #{e.class}: #{e.message}")
|
|
625
|
+
raise
|
|
510
626
|
end
|
|
511
627
|
|
|
512
628
|
def warden_default_scope(env)
|
|
@@ -529,6 +645,14 @@ module Sessions
|
|
|
529
645
|
rescue StandardError
|
|
530
646
|
false
|
|
531
647
|
end
|
|
648
|
+
|
|
649
|
+
def end_row_for_auth_teardown(row, reason:, context:)
|
|
650
|
+
row.end!(reason: reason)
|
|
651
|
+
true
|
|
652
|
+
rescue StandardError => e
|
|
653
|
+
Sessions.warn("#{context} failed open: #{e.class}: #{e.message}")
|
|
654
|
+
false
|
|
655
|
+
end
|
|
532
656
|
end
|
|
533
657
|
end
|
|
534
658
|
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
|
|
@@ -180,7 +181,7 @@ module Sessions
|
|
|
180
181
|
# whatever layout the parent controller resolves — usually the host's
|
|
181
182
|
# `application` layout. Set it when your signed-in surfaces render with
|
|
182
183
|
# a different one (e.g. "app").
|
|
183
|
-
|
|
184
|
+
attr_reader :layout
|
|
184
185
|
|
|
185
186
|
# ->(controller) — optional sudo gate run before destructive actions on
|
|
186
187
|
# the devices page (ASVS 3.3.4's "having re-entered login credentials").
|
|
@@ -376,6 +377,15 @@ module Sessions
|
|
|
376
377
|
@parent_controller = name
|
|
377
378
|
end
|
|
378
379
|
|
|
380
|
+
def layout=(value)
|
|
381
|
+
if value.nil? || value.respond_to?(:call) || value.is_a?(String) || value.is_a?(Symbol)
|
|
382
|
+
@layout = value
|
|
383
|
+
return
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
raise ConfigurationError, "layout must be a String, Symbol, callable, or nil, got #{value.inspect}"
|
|
387
|
+
end
|
|
388
|
+
|
|
379
389
|
def require_reauthentication=(value)
|
|
380
390
|
if value.nil?
|
|
381
391
|
@require_reauthentication = nil
|
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
|
|