sessions 0.1.2 → 0.1.3
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 +12 -0
- data/README.md +2 -2
- data/lib/generators/sessions/templates/add_app_build_to_sessions_events.rb.erb +9 -0
- data/lib/generators/sessions/templates/create_sessions_events.rb.erb +1 -0
- data/lib/generators/sessions/upgrade_generator.rb +3 -1
- data/lib/sessions/adapters/warden.rb +168 -7
- data/lib/sessions/classifier.rb +18 -5
- data/lib/sessions/geolocation.rb +18 -8
- data/lib/sessions/models/concerns/model.rb +17 -14
- data/lib/sessions/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2c9417a89572ea4f860d64648e8d085b9074dd4914d3078dbdf6e116c5087d10
|
|
4
|
+
data.tar.gz: fda35041ba991b3dbfd4c4789eedd16f17c42764805f6fba633e956920796e6b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 428832edcb252f835b89a351f30459e9a884a288622d988df050356e5cd4322eafb70f070f9ae91b418451bdd4adfad111c2ab984eb9e2fe86950651266b7a23
|
|
7
|
+
data.tar.gz: d56946f36346faa79f2c27c12bd69fa954018425ba85d852e98abce8b69c79d472f973c71a73e33a324598f1ddd5bb1ec058c5a5a6d34778748d0c4fc7f03111
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.3 (2026-06-21)
|
|
4
|
+
|
|
5
|
+
Production-found hardening for Hotwire Native / Devise remember-me startup flows, plus compatibility and generated admin-schema fixes.
|
|
6
|
+
|
|
7
|
+
- **Remembered Warden logins on non-document requests are deferred until the first document navigation.** A native iOS path-configuration fetch like `/native/configurations/ios/v1.json` can carry the remember-me cookie before the WebView entry page loads; the gem now keeps that login pending instead of recording a misleading `RailsFast/... CFNetwork/...` row/event, then records the real WebView/native device on the HTML request with the original `remembered` auth detail.
|
|
8
|
+
- **Remembered restores for an already-live device now reattach to the existing row.** iOS WebView startup bursts can run several remembered document requests before the app settles; if the signed device cookie already names a live row for the same user/scope, the gem reuses it, writes no duplicate login event, and validates the tokenless Warden session against that same signed device cookie on later fetches. A destroyed row still kicks normally.
|
|
9
|
+
- **Internal same-device superseding is quiet housekeeping again.** Replacing an abandoned same-browser row no longer writes a user-facing `revoked` / `superseded` event; remote revocations, password-change revocations, expiry and logout still write their normal trail entries.
|
|
10
|
+
- **Pre-gem adoption also waits for a document request.** Existing authenticated sessions no longer get adopted from background JSON/native HTTP requests before the actual browser/WebView request can name the device.
|
|
11
|
+
- **Trackdown integration duck-types optional fields.** `sessions` now tolerates older `trackdown` releases that do not expose `region` or coordinate methods, preserving valid country/city data instead of swallowing the whole geo result.
|
|
12
|
+
- **`sessions_events.app_build` is now part of the trail schema.** Fresh installs get the column, login/logout/revocation events copy it from the registry row, and `rails generate sessions:upgrade` adds it for existing installs so the generated Madmin resource matches the database.
|
|
13
|
+
- **Password-bearing non-idempotent requests classify as password logins.** This covers Devise password-reset/update flows that sign the user in with `PATCH` instead of `POST`, avoiding misleading `auth_method: "unknown"` rows.
|
|
14
|
+
|
|
3
15
|
## 0.1.2 (2026-06-16)
|
|
4
16
|
|
|
5
17
|
Production-found hardening for Devise/Warden adoption, the path that turns already-authenticated pre-gem sessions into registry rows.
|
data/README.md
CHANGED
|
@@ -98,14 +98,14 @@ That's it. Every sign-in from now on lands on the devices page and in the trail
|
|
|
98
98
|
|
|
99
99
|
### Upgrading
|
|
100
100
|
|
|
101
|
-
Existing apps upgrading from 0.1.0 or 0.1.
|
|
101
|
+
Existing apps upgrading from 0.1.0, 0.1.1, or 0.1.2 should copy the upgrade migrations and run them:
|
|
102
102
|
|
|
103
103
|
```bash
|
|
104
104
|
rails generate sessions:upgrade
|
|
105
105
|
rails db:migrate
|
|
106
106
|
```
|
|
107
107
|
|
|
108
|
-
This adds `sessions.adoption_key
|
|
108
|
+
This adds `sessions.adoption_key` (for installs before 0.1.2), the portable unique guard that makes pre-gem session adoption atomic under concurrent native-app request bursts, and `sessions_events.app_build` (for installs before 0.1.3), which keeps the event trail and generated madmin resource in sync. Fresh installs already get both from `sessions:install`.
|
|
109
109
|
|
|
110
110
|
## What `sessions` does (and doesn't) do
|
|
111
111
|
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# v0.1.3 upgrade: events mirror the registry's native app build so generated
|
|
4
|
+
# madmin resources never reference a missing sessions_events.app_build column.
|
|
5
|
+
class AddSessionsAppBuildToSessionsEvents < ActiveRecord::Migration<%= migration_version %>
|
|
6
|
+
def change
|
|
7
|
+
add_column :sessions_events, :app_build, :string unless column_exists?(:sessions_events, :app_build)
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -52,6 +52,7 @@ class CreateSessionsEvents < ActiveRecord::Migration<%= migration_version %>
|
|
|
52
52
|
t.string :device_model
|
|
53
53
|
t.string :app_name
|
|
54
54
|
t.string :app_version
|
|
55
|
+
t.string :app_build
|
|
55
56
|
|
|
56
57
|
# Geo via trackdown (soft dependency). lat/lng precision-reduced per
|
|
57
58
|
# config.geo_precision (2 decimals ≈ 1km) — privacy now,
|
|
@@ -25,6 +25,8 @@ module Sessions
|
|
|
25
25
|
def create_upgrade_migrations
|
|
26
26
|
migration_template "add_adoption_key_to_sessions.rb.erb",
|
|
27
27
|
File.join(db_migrate_path, "add_sessions_adoption_key_to_#{table_name}.rb")
|
|
28
|
+
migration_template "add_app_build_to_sessions_events.rb.erb",
|
|
29
|
+
File.join(db_migrate_path, "add_sessions_app_build_to_sessions_events.rb")
|
|
28
30
|
end
|
|
29
31
|
|
|
30
32
|
def display_post_upgrade_message
|
|
@@ -32,7 +34,7 @@ module Sessions
|
|
|
32
34
|
say "\nTo complete the upgrade:"
|
|
33
35
|
say " 1. Review the generated migration."
|
|
34
36
|
say " 2. Run 'rails db:migrate'."
|
|
35
|
-
say " ⚠️ Deploy the
|
|
37
|
+
say " ⚠️ Deploy the migrations before relying on adoption hardening or app-build event columns.", :yellow
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
private
|
|
@@ -27,6 +27,11 @@ module Sessions
|
|
|
27
27
|
# Key inside `warden.session(scope)` holding [row_id, raw_token].
|
|
28
28
|
SESSION_KEY = "sessions"
|
|
29
29
|
|
|
30
|
+
# Rememberable can restore a user on background/native JSON requests
|
|
31
|
+
# before the browser/WebView has actually navigated. Defer the row
|
|
32
|
+
# until a document request can name the user-visible device.
|
|
33
|
+
PENDING_LOGIN_KEY = "sessions.pending_login"
|
|
34
|
+
|
|
30
35
|
# Sticky per-scope flag: a login recorded with `sessions_skip: true`
|
|
31
36
|
# must not be kicked by the fetch validation later (session_limitable's
|
|
32
37
|
# third skip layer).
|
|
@@ -104,11 +109,23 @@ module Sessions
|
|
|
104
109
|
|
|
105
110
|
next unless row_accepts?(record)
|
|
106
111
|
|
|
112
|
+
auth = Sessions::Classifier.classify(warden.request)
|
|
113
|
+
if deferred_login_request?(warden.request, auth)
|
|
114
|
+
stash_pending_login(warden, scope, auth)
|
|
115
|
+
next
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
if (row = remembered_existing_row(record, warden, scope, auth))
|
|
119
|
+
attach_existing_row(row, warden, scope)
|
|
120
|
+
next
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
warden.session(scope).delete(PENDING_LOGIN_KEY)
|
|
107
124
|
create_row_for(record, warden, scope)
|
|
108
125
|
end
|
|
109
126
|
end
|
|
110
127
|
|
|
111
|
-
def create_row_for(record, warden, scope, suppress_login_event: false, attributes: {})
|
|
128
|
+
def create_row_for(record, warden, scope, suppress_login_event: false, skip_supersede: false, attributes: {})
|
|
112
129
|
token = Sessions.generate_token
|
|
113
130
|
request = warden.request
|
|
114
131
|
model = Sessions.session_model
|
|
@@ -125,6 +142,7 @@ module Sessions
|
|
|
125
142
|
end
|
|
126
143
|
end
|
|
127
144
|
row.sessions_suppress_login_event = suppress_login_event
|
|
145
|
+
row.sessions_skip_supersede = skip_supersede
|
|
128
146
|
Sessions.with_request(request) { row.save! }
|
|
129
147
|
|
|
130
148
|
warden.session(scope)[SESSION_KEY] = [row.id, token]
|
|
@@ -143,12 +161,20 @@ module Sessions
|
|
|
143
161
|
session_data = warden.session(scope)
|
|
144
162
|
next :skip if session_data[SKIP_SESSION_KEY]
|
|
145
163
|
|
|
146
|
-
|
|
164
|
+
{
|
|
165
|
+
login: session_data[SESSION_KEY],
|
|
166
|
+
pending_login: session_data[PENDING_LOGIN_KEY]
|
|
167
|
+
}
|
|
147
168
|
end
|
|
148
169
|
return if data == :skip
|
|
149
170
|
|
|
150
|
-
|
|
151
|
-
|
|
171
|
+
session_token = data && data[:login]
|
|
172
|
+
if session_token.nil?
|
|
173
|
+
if data && data[:pending_login]
|
|
174
|
+
activate_pending_login(record, warden, scope, data[:pending_login])
|
|
175
|
+
else
|
|
176
|
+
adopt_preexisting_session(record, warden, scope)
|
|
177
|
+
end
|
|
152
178
|
return
|
|
153
179
|
end
|
|
154
180
|
|
|
@@ -160,9 +186,13 @@ module Sessions
|
|
|
160
186
|
# never break authentication: fail OPEN, let the request through
|
|
161
187
|
# untracked, try again next request.
|
|
162
188
|
begin
|
|
163
|
-
id, token =
|
|
189
|
+
id, token = session_token
|
|
164
190
|
found = Sessions.session_model.find_by(id: id)
|
|
165
|
-
row =
|
|
191
|
+
row = if found&.sessions_token_matches?(token)
|
|
192
|
+
found
|
|
193
|
+
elsif token.nil? && existing_row_session?(found, record, scope, warden.request)
|
|
194
|
+
found
|
|
195
|
+
end
|
|
166
196
|
rescue StandardError => e
|
|
167
197
|
Sessions.warn("warden.fetch failed open: #{e.class}: #{e.message}")
|
|
168
198
|
return
|
|
@@ -190,6 +220,7 @@ module Sessions
|
|
|
190
220
|
def adopt_preexisting_session(record, warden, scope)
|
|
191
221
|
Sessions.safely("warden.adopt") do
|
|
192
222
|
next unless row_accepts?(record)
|
|
223
|
+
next unless document_request?(warden.request)
|
|
193
224
|
|
|
194
225
|
# IDEMPOTENT, because a client that can't persist cookies re-enters
|
|
195
226
|
# adoption on EVERY request: the SESSION_KEY we write rides a
|
|
@@ -212,11 +243,141 @@ module Sessions
|
|
|
212
243
|
end
|
|
213
244
|
end
|
|
214
245
|
|
|
246
|
+
def activate_pending_login(record, warden, scope, pending_login)
|
|
247
|
+
Sessions.safely("warden.pending_login") do
|
|
248
|
+
next unless row_accepts?(record)
|
|
249
|
+
next unless document_request?(warden.request)
|
|
250
|
+
|
|
251
|
+
if (row = pending_existing_row(record, warden, scope, pending_login))
|
|
252
|
+
attach_existing_row(row, warden, scope)
|
|
253
|
+
warden.session(scope).delete(PENDING_LOGIN_KEY)
|
|
254
|
+
next row
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
row = create_row_for(record, warden, scope, attributes: pending_login_attributes(pending_login))
|
|
258
|
+
warden.session(scope).delete(PENDING_LOGIN_KEY)
|
|
259
|
+
row
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def deferred_login_request?(request, auth)
|
|
264
|
+
remembered_login?(auth) && !document_request?(request)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def remembered_login?(auth)
|
|
268
|
+
detail = auth[:detail].to_h
|
|
269
|
+
detail["remembered"] || detail[:remembered]
|
|
270
|
+
rescue StandardError
|
|
271
|
+
false
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def stash_pending_login(warden, scope, auth)
|
|
275
|
+
warden.session(scope)[PENDING_LOGIN_KEY] = login_auth_attributes(auth).transform_keys(&:to_s)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def login_auth_attributes(auth)
|
|
279
|
+
{
|
|
280
|
+
auth_method: auth[:method],
|
|
281
|
+
auth_provider: auth[:provider],
|
|
282
|
+
auth_detail: auth[:detail].presence
|
|
283
|
+
}.compact
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def pending_login_attributes(attributes)
|
|
287
|
+
attributes.to_h.slice("auth_method", "auth_provider", "auth_detail")
|
|
288
|
+
rescue StandardError
|
|
289
|
+
{}
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def pending_existing_row(record, warden, scope, pending_login)
|
|
293
|
+
auth = { detail: pending_login.to_h["auth_detail"] || {} }
|
|
294
|
+
remembered_existing_row(record, warden, scope, auth)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def remembered_existing_row(record, warden, scope, auth)
|
|
298
|
+
return unless remembered_login?(auth)
|
|
299
|
+
|
|
300
|
+
device_id = device_id_from_request(warden.request)
|
|
301
|
+
return if device_id.blank?
|
|
302
|
+
|
|
303
|
+
rows = Sessions.session_model.where(user: record, device_id: device_id)
|
|
304
|
+
rows = rows.where(scope: scope.to_s) if Sessions.session_model.column_names.include?("scope")
|
|
305
|
+
rows.order(created_at: :desc).first
|
|
306
|
+
rescue StandardError
|
|
307
|
+
nil
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def attach_existing_row(row, warden, scope)
|
|
311
|
+
warden.session(scope)[SESSION_KEY] = [row.id, nil]
|
|
312
|
+
Sessions.safely("warden.remembered_existing.touch") { row.touch_last_seen!(warden.request) }
|
|
313
|
+
row
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def existing_row_session?(row, record, scope, request)
|
|
317
|
+
return false unless row
|
|
318
|
+
|
|
319
|
+
device_id = device_id_from_request(request)
|
|
320
|
+
return false if device_id.blank?
|
|
321
|
+
return false unless row.try(:device_id) == device_id
|
|
322
|
+
return false unless row.user == record
|
|
323
|
+
return false if row.respond_to?(:scope) && row.scope.present? && row.scope != scope.to_s
|
|
324
|
+
|
|
325
|
+
true
|
|
326
|
+
rescue StandardError
|
|
327
|
+
false
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def device_id_from_request(request)
|
|
331
|
+
return unless request.respond_to?(:cookie_jar)
|
|
332
|
+
|
|
333
|
+
request.cookie_jar.signed[Sessions::DEVICE_COOKIE].presence
|
|
334
|
+
rescue StandardError
|
|
335
|
+
nil
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def document_request?(request)
|
|
339
|
+
return true unless request
|
|
340
|
+
return false if non_document_path?(request)
|
|
341
|
+
|
|
342
|
+
accept = request_header(request, "HTTP_ACCEPT").to_s
|
|
343
|
+
return true if accept.empty? || accept == "*/*"
|
|
344
|
+
return true if accept.match?(%r{\btext/html\b|\bapplication/xhtml\+xml\b|\btext/vnd\.turbo-stream\.html\b})
|
|
345
|
+
return false if accept.match?(%r{\b(?:application|text)/(?:[\w.+-]+\+)?json\b})
|
|
346
|
+
return false if request_header(request, "HTTP_X_REQUESTED_WITH").to_s.casecmp("XMLHttpRequest").zero?
|
|
347
|
+
|
|
348
|
+
if request.respond_to?(:format)
|
|
349
|
+
format = request.format
|
|
350
|
+
return true if format.respond_to?(:html?) && format.html?
|
|
351
|
+
return false if format.respond_to?(:json?) && format.json?
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
true
|
|
355
|
+
rescue StandardError
|
|
356
|
+
true
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def non_document_path?(request)
|
|
360
|
+
path = if request.respond_to?(:path)
|
|
361
|
+
request.path
|
|
362
|
+
elsif request.respond_to?(:path_info)
|
|
363
|
+
request.path_info
|
|
364
|
+
end
|
|
365
|
+
File.extname(path.to_s).delete(".").casecmp("json").zero?
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def request_header(request, key)
|
|
369
|
+
if request.respond_to?(:get_header)
|
|
370
|
+
request.get_header(key)
|
|
371
|
+
elsif request.respond_to?(:env)
|
|
372
|
+
request.env[key]
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
215
376
|
def create_adopted_row(record, warden, scope, adoption_key:)
|
|
216
377
|
attributes = { auth_detail: { "adopted" => true } }
|
|
217
378
|
attributes[:adoption_key] = adoption_key if adoption_key_column?
|
|
218
379
|
|
|
219
|
-
create_row_for(record, warden, scope, suppress_login_event: true, attributes: attributes)
|
|
380
|
+
create_row_for(record, warden, scope, suppress_login_event: true, skip_supersede: true, attributes: attributes)
|
|
220
381
|
rescue ActiveRecord::RecordNotUnique
|
|
221
382
|
adopted_row(record, scope, adoption_key: adoption_key)&.tap do |row|
|
|
222
383
|
Sessions.safely("warden.adopt.touch") { row.touch_last_seen!(warden.request) }
|
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
|
|
data/lib/sessions/geolocation.rb
CHANGED
|
@@ -79,13 +79,15 @@ module Sessions
|
|
|
79
79
|
|
|
80
80
|
def columns_from(result)
|
|
81
81
|
return {} unless result
|
|
82
|
-
|
|
82
|
+
|
|
83
|
+
country_code = location_value(result, :country_code)
|
|
84
|
+
return {} if country_code.to_s.empty?
|
|
83
85
|
|
|
84
86
|
{
|
|
85
|
-
country_code:
|
|
86
|
-
country_name: presence(result
|
|
87
|
-
city: presence(result
|
|
88
|
-
region: presence(result
|
|
87
|
+
country_code: country_code,
|
|
88
|
+
country_name: presence(location_value(result, :country_name)),
|
|
89
|
+
city: presence(location_value(result, :city)),
|
|
90
|
+
region: presence(location_value(result, :region))
|
|
89
91
|
}.compact
|
|
90
92
|
end
|
|
91
93
|
|
|
@@ -93,17 +95,25 @@ module Sessions
|
|
|
93
95
|
# config.geo_precision (2 decimals ≈ 1km — privacy now,
|
|
94
96
|
# impossible-travel math later).
|
|
95
97
|
def coordinates_from(result)
|
|
96
|
-
|
|
98
|
+
latitude = location_value(result, :latitude)
|
|
99
|
+
longitude = location_value(result, :longitude)
|
|
100
|
+
return {} if latitude.nil? || longitude.nil?
|
|
97
101
|
|
|
98
102
|
precision = Sessions.config.geo_precision
|
|
99
103
|
{
|
|
100
|
-
latitude:
|
|
101
|
-
longitude:
|
|
104
|
+
latitude: latitude.to_f.round(precision),
|
|
105
|
+
longitude: longitude.to_f.round(precision)
|
|
102
106
|
}
|
|
103
107
|
rescue StandardError
|
|
104
108
|
{}
|
|
105
109
|
end
|
|
106
110
|
|
|
111
|
+
def location_value(result, attribute)
|
|
112
|
+
result.public_send(attribute) if result.respond_to?(attribute)
|
|
113
|
+
rescue StandardError
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
107
117
|
def presence(value)
|
|
108
118
|
value.nil? || value.to_s.empty? || value.to_s == "Unknown" ? nil : value
|
|
109
119
|
end
|
|
@@ -57,7 +57,7 @@ module Sessions
|
|
|
57
57
|
|
|
58
58
|
# Set on adopted rows (sessions that predate the gem) so adoption
|
|
59
59
|
# doesn't fabricate a `login` event in the trail.
|
|
60
|
-
attr_accessor :sessions_suppress_login_event
|
|
60
|
+
attr_accessor :sessions_suppress_login_event, :sessions_skip_supersede
|
|
61
61
|
|
|
62
62
|
before_create :sessions_enrich
|
|
63
63
|
after_create_commit :sessions_record_login
|
|
@@ -335,22 +335,22 @@ module Sessions
|
|
|
335
335
|
|
|
336
336
|
def sessions_record_login
|
|
337
337
|
Sessions.safely("record_login") do
|
|
338
|
-
if sessions_suppress_login_event
|
|
339
|
-
# Suppressed writes (adoption) skip the trail event, dedup and
|
|
340
|
-
# the new-device hook — but never the cap: it's the hard limit on
|
|
341
|
-
# LIVE rows, and a misbehaving client looping through adoption
|
|
342
|
-
# must hit it like everyone else.
|
|
343
|
-
sessions_enforce_cap!
|
|
344
|
-
next
|
|
345
|
-
end
|
|
346
|
-
|
|
347
338
|
# Same browser signing in again (abandoned session, expired
|
|
348
339
|
# remember-me, browser update — anything) replaces its old row
|
|
349
340
|
# instead of stacking a duplicate device. Runs BEFORE new-device
|
|
350
341
|
# detection on purpose: the trail (which survives the superseded
|
|
351
342
|
# row) is what remembers known devices, so dedup never causes
|
|
352
343
|
# false "new device" alerts.
|
|
353
|
-
Sessions.safely("supersede") { sessions_supersede_previous_rows! }
|
|
344
|
+
Sessions.safely("supersede") { sessions_supersede_previous_rows! } unless sessions_skip_supersede
|
|
345
|
+
|
|
346
|
+
if sessions_suppress_login_event
|
|
347
|
+
# Suppressed writes skip the trail event and the new-device hook
|
|
348
|
+
# — but never the cap: it's the hard limit on LIVE rows, and a
|
|
349
|
+
# misbehaving client looping through adoption/remembered refreshes
|
|
350
|
+
# must hit it like everyone else.
|
|
351
|
+
sessions_enforce_cap!
|
|
352
|
+
next
|
|
353
|
+
end
|
|
354
354
|
|
|
355
355
|
new_device = Sessions.safely("new_device") { sessions_new_device? } || false
|
|
356
356
|
|
|
@@ -401,9 +401,9 @@ module Sessions
|
|
|
401
401
|
# The dedup half of browser continuity: prior live rows for the SAME
|
|
402
402
|
# user on the SAME browser install are superseded by this login. A
|
|
403
403
|
# quiet destroy on purpose — no on_session_revoked hook, no
|
|
404
|
-
# remember-me rotation (this is housekeeping, not a
|
|
405
|
-
#
|
|
406
|
-
#
|
|
404
|
+
# remember-me rotation, no trail event (this is housekeeping, not a
|
|
405
|
+
# security event). Scoped to the user: a shared computer with two
|
|
406
|
+
# accounts keeps both rows.
|
|
407
407
|
def sessions_supersede_previous_rows!
|
|
408
408
|
return if try(:device_id).blank?
|
|
409
409
|
|
|
@@ -438,6 +438,8 @@ module Sessions
|
|
|
438
438
|
next if user.nil? || user.destroyed?
|
|
439
439
|
|
|
440
440
|
reason = revocation_reason&.to_sym
|
|
441
|
+
next if reason == :superseded
|
|
442
|
+
|
|
441
443
|
event_name = case reason
|
|
442
444
|
when :logout then "logout"
|
|
443
445
|
when :expired then "expired"
|
|
@@ -483,6 +485,7 @@ module Sessions
|
|
|
483
485
|
device_model: try(:device_model),
|
|
484
486
|
app_name: try(:app_name),
|
|
485
487
|
app_version: try(:app_version),
|
|
488
|
+
app_build: try(:app_build),
|
|
486
489
|
country_code: try(:country_code),
|
|
487
490
|
country_name: try(:country_name),
|
|
488
491
|
city: try(:city),
|
data/lib/sessions/version.rb
CHANGED
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.1.
|
|
4
|
+
version: 0.1.3
|
|
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-06-21 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: actionpack
|
|
@@ -164,6 +164,7 @@ files:
|
|
|
164
164
|
- lib/generators/sessions/install_generator.rb
|
|
165
165
|
- lib/generators/sessions/madmin_generator.rb
|
|
166
166
|
- lib/generators/sessions/templates/add_adoption_key_to_sessions.rb.erb
|
|
167
|
+
- lib/generators/sessions/templates/add_app_build_to_sessions_events.rb.erb
|
|
167
168
|
- lib/generators/sessions/templates/add_sessions_columns.rb.erb
|
|
168
169
|
- lib/generators/sessions/templates/create_sessions.rb.erb
|
|
169
170
|
- lib/generators/sessions/templates/create_sessions_events.rb.erb
|