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
|
@@ -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,20 +13,32 @@ 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
|
|
|
37
|
+
# Rememberable can restore a user on background/native JSON requests
|
|
38
|
+
# before the browser/WebView has actually navigated. Defer the row
|
|
39
|
+
# until a document request can name the user-visible device.
|
|
40
|
+
PENDING_LOGIN_KEY = "sessions.pending_login"
|
|
41
|
+
|
|
30
42
|
# Sticky per-scope flag: a login recorded with `sessions_skip: true`
|
|
31
43
|
# must not be kicked by the fetch validation later (session_limitable's
|
|
32
44
|
# third skip layer).
|
|
@@ -104,11 +116,23 @@ module Sessions
|
|
|
104
116
|
|
|
105
117
|
next unless row_accepts?(record)
|
|
106
118
|
|
|
119
|
+
auth = Sessions::Classifier.classify(warden.request)
|
|
120
|
+
if deferred_login_request?(warden.request, auth)
|
|
121
|
+
stash_pending_login(warden, scope, auth)
|
|
122
|
+
next
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
if (row = remembered_existing_row(record, warden, scope, auth))
|
|
126
|
+
attach_existing_row(row, warden, scope)
|
|
127
|
+
next
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
warden.session(scope).delete(PENDING_LOGIN_KEY)
|
|
107
131
|
create_row_for(record, warden, scope)
|
|
108
132
|
end
|
|
109
133
|
end
|
|
110
134
|
|
|
111
|
-
def create_row_for(record, warden, scope, suppress_login_event: false, attributes: {})
|
|
135
|
+
def create_row_for(record, warden, scope, suppress_login_event: false, skip_supersede: false, attributes: {})
|
|
112
136
|
token = Sessions.generate_token
|
|
113
137
|
request = warden.request
|
|
114
138
|
model = Sessions.session_model
|
|
@@ -125,9 +149,10 @@ module Sessions
|
|
|
125
149
|
end
|
|
126
150
|
end
|
|
127
151
|
row.sessions_suppress_login_event = suppress_login_event
|
|
152
|
+
row.sessions_skip_supersede = skip_supersede
|
|
128
153
|
Sessions.with_request(request) { row.save! }
|
|
129
154
|
|
|
130
|
-
warden.session(scope)[SESSION_KEY] =
|
|
155
|
+
warden.session(scope)[SESSION_KEY] = tracking_state(row, token: token, mode: "credential")
|
|
131
156
|
row
|
|
132
157
|
end
|
|
133
158
|
|
|
@@ -143,40 +168,83 @@ module Sessions
|
|
|
143
168
|
session_data = warden.session(scope)
|
|
144
169
|
next :skip if session_data[SKIP_SESSION_KEY]
|
|
145
170
|
|
|
146
|
-
|
|
171
|
+
{
|
|
172
|
+
login: session_data[SESSION_KEY],
|
|
173
|
+
pending_login: session_data[PENDING_LOGIN_KEY]
|
|
174
|
+
}
|
|
147
175
|
end
|
|
148
176
|
return if data == :skip
|
|
149
177
|
|
|
150
|
-
|
|
151
|
-
|
|
178
|
+
tracking = parse_tracking_state(data && data[:login])
|
|
179
|
+
if tracking.nil?
|
|
180
|
+
if data && data[:pending_login]
|
|
181
|
+
activate_pending_login(record, warden, scope, data[:pending_login])
|
|
182
|
+
else
|
|
183
|
+
adopt_preexisting_session(record, warden, scope)
|
|
184
|
+
end
|
|
152
185
|
return
|
|
153
186
|
end
|
|
154
187
|
|
|
155
188
|
# The lookup is NOT wrapped in `safely`: an ERRORED lookup and a
|
|
156
|
-
# MISSING row must be distinguishable
|
|
157
|
-
#
|
|
158
|
-
#
|
|
159
|
-
#
|
|
160
|
-
#
|
|
161
|
-
#
|
|
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.
|
|
162
199
|
begin
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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])
|
|
166
220
|
rescue StandardError => e
|
|
167
221
|
Sessions.warn("warden.fetch failed open: #{e.class}: #{e.message}")
|
|
168
222
|
return
|
|
169
223
|
end
|
|
170
224
|
|
|
171
225
|
if row.nil?
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
177
242
|
elsif Sessions.safely("warden.expired?") { row.sessions_expired? }
|
|
178
|
-
|
|
179
|
-
|
|
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")
|
|
180
248
|
else
|
|
181
249
|
Sessions.safely("warden.touch") { row.touch_last_seen!(warden.request) }
|
|
182
250
|
end
|
|
@@ -190,6 +258,7 @@ module Sessions
|
|
|
190
258
|
def adopt_preexisting_session(record, warden, scope)
|
|
191
259
|
Sessions.safely("warden.adopt") do
|
|
192
260
|
next unless row_accepts?(record)
|
|
261
|
+
next unless document_request?(warden.request)
|
|
193
262
|
|
|
194
263
|
# IDEMPOTENT, because a client that can't persist cookies re-enters
|
|
195
264
|
# adoption on EVERY request: the SESSION_KEY we write rides a
|
|
@@ -212,11 +281,154 @@ module Sessions
|
|
|
212
281
|
end
|
|
213
282
|
end
|
|
214
283
|
|
|
284
|
+
def activate_pending_login(record, warden, scope, pending_login)
|
|
285
|
+
Sessions.safely("warden.pending_login") do
|
|
286
|
+
next unless row_accepts?(record)
|
|
287
|
+
next unless document_request?(warden.request)
|
|
288
|
+
|
|
289
|
+
if (row = pending_existing_row(record, warden, scope, pending_login))
|
|
290
|
+
attach_existing_row(row, warden, scope)
|
|
291
|
+
warden.session(scope).delete(PENDING_LOGIN_KEY)
|
|
292
|
+
next row
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
row = create_row_for(record, warden, scope, attributes: pending_login_attributes(pending_login))
|
|
296
|
+
warden.session(scope).delete(PENDING_LOGIN_KEY)
|
|
297
|
+
row
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def deferred_login_request?(request, auth)
|
|
302
|
+
remembered_login?(auth) && !document_request?(request)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def remembered_login?(auth)
|
|
306
|
+
detail = auth[:detail].to_h
|
|
307
|
+
detail["remembered"] || detail[:remembered]
|
|
308
|
+
rescue StandardError
|
|
309
|
+
false
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def stash_pending_login(warden, scope, auth)
|
|
313
|
+
warden.session(scope)[PENDING_LOGIN_KEY] = login_auth_attributes(auth).transform_keys(&:to_s)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def login_auth_attributes(auth)
|
|
317
|
+
{
|
|
318
|
+
auth_method: auth[:method],
|
|
319
|
+
auth_provider: auth[:provider],
|
|
320
|
+
auth_detail: auth[:detail].presence
|
|
321
|
+
}.compact
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def pending_login_attributes(attributes)
|
|
325
|
+
attributes.to_h.slice("auth_method", "auth_provider", "auth_detail")
|
|
326
|
+
rescue StandardError
|
|
327
|
+
{}
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def pending_existing_row(record, warden, scope, pending_login)
|
|
331
|
+
auth = { detail: pending_login.to_h["auth_detail"] || {} }
|
|
332
|
+
remembered_existing_row(record, warden, scope, auth)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def remembered_existing_row(record, warden, scope, auth)
|
|
336
|
+
return unless remembered_login?(auth)
|
|
337
|
+
|
|
338
|
+
device_id = device_id_from_request(warden.request)
|
|
339
|
+
return if device_id.blank?
|
|
340
|
+
|
|
341
|
+
rows = Sessions.session_model.live.where(user: record, device_id: device_id)
|
|
342
|
+
rows = rows.where(scope: scope.to_s) if Sessions.session_model.column_names.include?("scope")
|
|
343
|
+
rows.order(created_at: :desc).first
|
|
344
|
+
rescue StandardError
|
|
345
|
+
nil
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def attach_existing_row(row, warden, scope)
|
|
349
|
+
warden.session(scope)[SESSION_KEY] = tracking_state(row, mode: "hint")
|
|
350
|
+
Sessions.safely("warden.remembered_existing.touch") { row.touch_last_seen!(warden.request) }
|
|
351
|
+
row
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def existing_row_session?(row, record, scope, request)
|
|
355
|
+
return false unless row
|
|
356
|
+
|
|
357
|
+
device_id = device_id_from_request(request)
|
|
358
|
+
return false if device_id.blank?
|
|
359
|
+
return false unless row.try(:device_id) == device_id
|
|
360
|
+
return false unless row.user == record
|
|
361
|
+
return false if row.respond_to?(:scope) && row.scope.present? && row.scope != scope.to_s
|
|
362
|
+
|
|
363
|
+
true
|
|
364
|
+
rescue StandardError
|
|
365
|
+
false
|
|
366
|
+
end
|
|
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
|
+
|
|
381
|
+
def device_id_from_request(request)
|
|
382
|
+
return unless request.respond_to?(:cookie_jar)
|
|
383
|
+
|
|
384
|
+
request.cookie_jar.signed[Sessions::DEVICE_COOKIE].presence
|
|
385
|
+
rescue StandardError
|
|
386
|
+
nil
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def document_request?(request)
|
|
390
|
+
return true unless request
|
|
391
|
+
return false if non_document_path?(request)
|
|
392
|
+
|
|
393
|
+
accept = request_header(request, "HTTP_ACCEPT").to_s
|
|
394
|
+
return true if accept.empty? || accept == "*/*"
|
|
395
|
+
return true if accept.match?(%r{\btext/html\b|\bapplication/xhtml\+xml\b|\btext/vnd\.turbo-stream\.html\b})
|
|
396
|
+
return false if accept.match?(%r{\b(?:application|text)/(?:[\w.+-]+\+)?json\b})
|
|
397
|
+
return false if request_header(request, "HTTP_X_REQUESTED_WITH").to_s.casecmp("XMLHttpRequest").zero?
|
|
398
|
+
|
|
399
|
+
if request.respond_to?(:format)
|
|
400
|
+
format = request.format
|
|
401
|
+
return true if format.respond_to?(:html?) && format.html?
|
|
402
|
+
return false if format.respond_to?(:json?) && format.json?
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
true
|
|
406
|
+
rescue StandardError
|
|
407
|
+
true
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def non_document_path?(request)
|
|
411
|
+
path = if request.respond_to?(:path)
|
|
412
|
+
request.path
|
|
413
|
+
elsif request.respond_to?(:path_info)
|
|
414
|
+
request.path_info
|
|
415
|
+
end
|
|
416
|
+
File.extname(path.to_s).delete(".").casecmp("json").zero?
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def request_header(request, key)
|
|
420
|
+
if request.respond_to?(:get_header)
|
|
421
|
+
request.get_header(key)
|
|
422
|
+
elsif request.respond_to?(:env)
|
|
423
|
+
request.env[key]
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
|
|
215
427
|
def create_adopted_row(record, warden, scope, adoption_key:)
|
|
216
428
|
attributes = { auth_detail: { "adopted" => true } }
|
|
217
429
|
attributes[:adoption_key] = adoption_key if adoption_key_column?
|
|
218
430
|
|
|
219
|
-
create_row_for(record, warden, scope, suppress_login_event: true, attributes: attributes)
|
|
431
|
+
create_row_for(record, warden, scope, suppress_login_event: true, skip_supersede: true, attributes: attributes)
|
|
220
432
|
rescue ActiveRecord::RecordNotUnique
|
|
221
433
|
adopted_row(record, scope, adoption_key: adoption_key)&.tap do |row|
|
|
222
434
|
Sessions.safely("warden.adopt.touch") { row.touch_last_seen!(warden.request) }
|
|
@@ -225,10 +437,10 @@ module Sessions
|
|
|
225
437
|
|
|
226
438
|
def adopted_row(record, scope, adoption_key:)
|
|
227
439
|
model = Sessions.session_model
|
|
228
|
-
rows = model.where(user: record)
|
|
440
|
+
rows = model.live.where(user: record)
|
|
229
441
|
rows = rows.where(scope: scope.to_s) if model.column_names.include?("scope")
|
|
230
442
|
|
|
231
|
-
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?
|
|
232
444
|
row = nil unless adopted_row?(row)
|
|
233
445
|
row ||= rows.order(created_at: :desc).detect { |candidate| adopted_row?(candidate) }
|
|
234
446
|
|
|
@@ -268,13 +480,60 @@ module Sessions
|
|
|
268
480
|
model.column_names.include?("adoption_key")
|
|
269
481
|
end
|
|
270
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
|
+
|
|
271
530
|
# SCOPE-PRECISE teardown: only this scope's warden entries go (the
|
|
272
531
|
# serialized user key and our token stash) — an admin scope riding
|
|
273
532
|
# the same rack session, and unrelated host session data (carts,
|
|
274
533
|
# locale, return-to paths), survive a user-scope kick. Deleting the
|
|
275
534
|
# keys BEFORE logout matters: our before_logout hook then finds no
|
|
276
|
-
# token and records nothing (a kick is not a logout — the
|
|
277
|
-
# event
|
|
535
|
+
# token and records nothing (a kick is not a logout — the lifecycle
|
|
536
|
+
# reason/event were already written by the explicit ending).
|
|
278
537
|
def kick!(warden, scope)
|
|
279
538
|
warden.raw_session.delete("warden.user.#{scope}.key")
|
|
280
539
|
warden.raw_session.delete("warden.user.#{scope}.session")
|
|
@@ -322,9 +581,8 @@ module Sessions
|
|
|
322
581
|
# --- Hook 4: logout ---------------------------------------------------------
|
|
323
582
|
|
|
324
583
|
# Fires once per scope (including forced logouts: timeout, lockout,
|
|
325
|
-
# our own revocation kick). If the row is already
|
|
326
|
-
#
|
|
327
|
-
# 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.
|
|
328
586
|
#
|
|
329
587
|
# CRITICAL: read the RAW session here, never `warden.session(scope)`.
|
|
330
588
|
# Warden's logout deletes `@users[scope]` BEFORE running before_logout
|
|
@@ -333,19 +591,29 @@ module Sessions
|
|
|
333
591
|
# logout came from a hook that logs out and throws (Devise's
|
|
334
592
|
# activatable on unconfirmed/locked accounts, timeoutable) that loops:
|
|
335
593
|
# activatable → logout → us → re-auth → activatable → … SystemStackError.
|
|
336
|
-
def record_logout(
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
|
598
|
+
|
|
599
|
+
row = Sessions.session_model.find_by(id: tracking[:id])
|
|
600
|
+
return unless row
|
|
601
|
+
return unless row.live?
|
|
602
|
+
|
|
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
|
|
349
617
|
end
|
|
350
618
|
|
|
351
619
|
def warden_default_scope(env)
|
|
@@ -368,6 +636,14 @@ module Sessions
|
|
|
368
636
|
rescue StandardError
|
|
369
637
|
false
|
|
370
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
|
|
371
647
|
end
|
|
372
648
|
end
|
|
373
649
|
end
|
data/lib/sessions/classifier.rb
CHANGED
|
@@ -77,7 +77,7 @@ module Sessions
|
|
|
77
77
|
from_omniauth(request) ||
|
|
78
78
|
from_warden(request) ||
|
|
79
79
|
from_google_sign_in(request) ||
|
|
80
|
-
|
|
80
|
+
from_password_request(request) ||
|
|
81
81
|
blank
|
|
82
82
|
rescue StandardError => e
|
|
83
83
|
Sessions.warn("auth classification failed: #{e.class}: #{e.message}")
|
|
@@ -176,10 +176,11 @@ module Sessions
|
|
|
176
176
|
nil
|
|
177
177
|
end
|
|
178
178
|
|
|
179
|
-
# A
|
|
180
|
-
# covers the omakase SessionsController
|
|
181
|
-
|
|
182
|
-
|
|
179
|
+
# A non-idempotent request that exchanged a password for a session IS a
|
|
180
|
+
# password login — covers the omakase SessionsController, hand-rolled
|
|
181
|
+
# password forms, and Devise password-reset PATCHes that sign the user in.
|
|
182
|
+
def from_password_request(request)
|
|
183
|
+
return unless credential_request?(request)
|
|
183
184
|
|
|
184
185
|
params = request.params
|
|
185
186
|
return unless params.is_a?(Hash) || params.respond_to?(:[])
|
|
@@ -190,6 +191,18 @@ module Sessions
|
|
|
190
191
|
nil
|
|
191
192
|
end
|
|
192
193
|
|
|
194
|
+
def credential_request?(request)
|
|
195
|
+
method = if request.respond_to?(:request_method)
|
|
196
|
+
request.request_method
|
|
197
|
+
elsif request.respond_to?(:method)
|
|
198
|
+
request.method
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
!%w[GET HEAD OPTIONS].include?(method.to_s.upcase)
|
|
202
|
+
rescue StandardError
|
|
203
|
+
false
|
|
204
|
+
end
|
|
205
|
+
|
|
193
206
|
def password_param?(params)
|
|
194
207
|
return true if params["password"].present?
|
|
195
208
|
|
|
@@ -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
|