sessions 0.1.1 → 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 +21 -0
- data/README.md +11 -0
- data/lib/generators/sessions/templates/add_adoption_key_to_sessions.rb.erb +14 -0
- data/lib/generators/sessions/templates/add_app_build_to_sessions_events.rb.erb +9 -0
- data/lib/generators/sessions/templates/add_sessions_columns.rb.erb +7 -0
- data/lib/generators/sessions/templates/create_sessions.rb.erb +7 -0
- data/lib/generators/sessions/templates/create_sessions_events.rb.erb +1 -0
- data/lib/generators/sessions/templates/madmin/session_resource.rb +1 -0
- data/lib/generators/sessions/upgrade_generator.rb +55 -0
- data/lib/sessions/adapters/warden.rb +236 -25
- 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 +5 -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,26 @@
|
|
|
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
|
+
|
|
15
|
+
## 0.1.2 (2026-06-16)
|
|
16
|
+
|
|
17
|
+
Production-found hardening for Devise/Warden adoption, the path that turns already-authenticated pre-gem sessions into registry rows.
|
|
18
|
+
|
|
19
|
+
- **Adoption is now keyed by owner + scope, not user agent or time.** One pre-existing authenticated session marker is enough; Hotwire Native devices legitimately present both a WebView UA and a native HTTP-client UA, and clients that drop `Set-Cookie` responses must not mint one adopted row per UA or per day.
|
|
20
|
+
- **Concurrent adoption bursts are now atomic when the upgrade migration has run.** Adopted rows get a nullable `adoption_key` with a plain unique index. Normal sessions keep it `NULL`, so the index behaves like a portable partial unique index across PostgreSQL, MySQL and SQLite. Racing requests either create the one keyed row or catch the uniqueness collision, re-select it, and touch it — still inside `Sessions.safely`, so tracking never breaks login.
|
|
21
|
+
- **Existing installs have an explicit upgrade path.** Run `rails generate sessions:upgrade` and `rails db:migrate` to add `sessions.adoption_key`. Fresh installs get the column from `sessions:install`.
|
|
22
|
+
- **The adapter degrades gracefully before the migration.** If `adoption_key` is absent, it still reuses any existing adopted row for the same owner + scope, eliminating the UA split and 24-hour leak while the schema upgrade is pending; the database-level race guarantee starts once the migration is deployed.
|
|
23
|
+
|
|
3
24
|
## 0.1.1 (2026-06-12)
|
|
4
25
|
|
|
5
26
|
Production-found fix, hours after 0.1.0: a client that forwards cookies **read-only** — the canonical case is a native app's HTTP layer that attaches the WebView's cookie but drops `Set-Cookie` responses — re-enters the Devise-mode *adoption* path on every request, because the session token the gem writes never persists client-side. Each pass minted a fresh adopted row (and adoption also skipped the per-user cap), so one phone on a polling screen accumulated hundreds of "live devices" in an hour.
|
data/README.md
CHANGED
|
@@ -96,6 +96,17 @@ That's it. Every sign-in from now on lands on the devices page and in the trail
|
|
|
96
96
|
>
|
|
97
97
|
> **API-only app?** The gem boots and tracks fine (use the model APIs and `Sessions.track_login`), but the devices page is HTML — mounting the engine needs an `ActionController::Base`-derived `config.parent_controller`, which API-only apps don't have.
|
|
98
98
|
|
|
99
|
+
### Upgrading
|
|
100
|
+
|
|
101
|
+
Existing apps upgrading from 0.1.0, 0.1.1, or 0.1.2 should copy the upgrade migrations and run them:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
rails generate sessions:upgrade
|
|
105
|
+
rails db:migrate
|
|
106
|
+
```
|
|
107
|
+
|
|
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
|
+
|
|
99
110
|
## What `sessions` does (and doesn't) do
|
|
100
111
|
|
|
101
112
|
**Does:**
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# v0.1.2 upgrade: adopted rows are keyed by owner+scope so clients that never
|
|
4
|
+
# persist Set-Cookie responses cannot create unbounded duplicate sessions.
|
|
5
|
+
# Existing adopted rows keep NULL keys; the adapter claims one lazily on the
|
|
6
|
+
# next request. Normal, non-adopted sessions also keep NULL, so this plain
|
|
7
|
+
# unique index behaves like a portable partial unique index across PostgreSQL,
|
|
8
|
+
# MySQL and SQLite.
|
|
9
|
+
class AddSessionsAdoptionKeyTo<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_version %>
|
|
10
|
+
def change
|
|
11
|
+
add_column :<%= table_name %>, :adoption_key, :string unless column_exists?(:<%= table_name %>, :adoption_key)
|
|
12
|
+
add_index :<%= table_name %>, :adoption_key, unique: true unless index_exists?(:<%= table_name %>, :adoption_key)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -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
|
|
@@ -17,6 +17,12 @@ class AddSessionsColumnsTo<%= table_name.camelize %> < ActiveRecord::Migration<%
|
|
|
17
17
|
# The Warden scope ("user") — multi-scope Devise apps.
|
|
18
18
|
t.string :scope
|
|
19
19
|
|
|
20
|
+
# Adopted sessions (pre-gem logins that had no sessions token yet)
|
|
21
|
+
# get one deterministic owner+scope key. Normal sessions keep this
|
|
22
|
+
# NULL, so the unique index below constrains only adopted rows while
|
|
23
|
+
# staying portable across PostgreSQL, MySQL and SQLite.
|
|
24
|
+
t.string :adoption_key
|
|
25
|
+
|
|
20
26
|
# How this session started: password / oauth / google_one_tap /
|
|
21
27
|
# passkey / magic_link / otp / sso / token / unknown — plus the
|
|
22
28
|
# provider ("google", "apple", "github") and per-method extras
|
|
@@ -62,6 +68,7 @@ class AddSessionsColumnsTo<%= table_name.camelize %> < ActiveRecord::Migration<%
|
|
|
62
68
|
|
|
63
69
|
add_index :<%= table_name %>, :device_id
|
|
64
70
|
add_index :<%= table_name %>, :token_digest, unique: true
|
|
71
|
+
add_index :<%= table_name %>, :adoption_key, unique: true
|
|
65
72
|
add_index :<%= table_name %>, :auth_method
|
|
66
73
|
add_index :<%= table_name %>, :auth_provider
|
|
67
74
|
add_index :<%= table_name %>, :country_code
|
|
@@ -30,6 +30,12 @@ class Create<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_ve
|
|
|
30
30
|
# The Warden scope ("user") — multi-scope Devise apps.
|
|
31
31
|
t.string :scope
|
|
32
32
|
|
|
33
|
+
# Adopted sessions (pre-gem logins that had no sessions token yet)
|
|
34
|
+
# get one deterministic owner+scope key. Normal sessions keep this
|
|
35
|
+
# NULL, so the unique index below constrains only adopted rows while
|
|
36
|
+
# staying portable across PostgreSQL, MySQL and SQLite.
|
|
37
|
+
t.string :adoption_key
|
|
38
|
+
|
|
33
39
|
# How this session started: password / oauth / google_one_tap /
|
|
34
40
|
# passkey / magic_link / otp / sso / token / unknown — plus the
|
|
35
41
|
# provider and per-method extras.
|
|
@@ -71,6 +77,7 @@ class Create<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_ve
|
|
|
71
77
|
|
|
72
78
|
add_index :<%= table_name %>, :device_id
|
|
73
79
|
add_index :<%= table_name %>, :token_digest, unique: true
|
|
80
|
+
add_index :<%= table_name %>, :adoption_key, unique: true
|
|
74
81
|
add_index :<%= table_name %>, :auth_method
|
|
75
82
|
add_index :<%= table_name %>, :auth_provider
|
|
76
83
|
add_index :<%= table_name %>, :country_code
|
|
@@ -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,
|
|
@@ -36,6 +36,7 @@ class SessionResource < Madmin::Resource
|
|
|
36
36
|
# NEVER rendered: the token digest is a credential hash, and the raw
|
|
37
37
|
# header blobs are noise next to the parsed columns above.
|
|
38
38
|
attribute :token_digest, show: false, form: false
|
|
39
|
+
attribute :adoption_key, show: false, form: false
|
|
39
40
|
attribute :auth_detail, show: false, form: false
|
|
40
41
|
attribute :client_hints, show: false, form: false
|
|
41
42
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Sessions
|
|
7
|
+
module Generators
|
|
8
|
+
# `rails generate sessions:upgrade` — copies version-to-version
|
|
9
|
+
# migrations for apps that installed a prior release. The install
|
|
10
|
+
# generator remains the source for fresh apps; this generator gives
|
|
11
|
+
# existing hosts an explicit, reviewable upgrade path.
|
|
12
|
+
class UpgradeGenerator < Rails::Generators::Base
|
|
13
|
+
include ActiveRecord::Generators::Migration
|
|
14
|
+
|
|
15
|
+
source_root File.expand_path("templates", __dir__)
|
|
16
|
+
desc "Install sessions upgrade migrations"
|
|
17
|
+
|
|
18
|
+
class_option :model, type: :string, default: "Session",
|
|
19
|
+
desc: "Session model name (escape hatch for apps with a custom session class)"
|
|
20
|
+
|
|
21
|
+
def self.next_migration_number(dir)
|
|
22
|
+
ActiveRecord::Generators::Base.next_migration_number(dir)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def create_upgrade_migrations
|
|
26
|
+
migration_template "add_adoption_key_to_sessions.rb.erb",
|
|
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")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def display_post_upgrade_message
|
|
33
|
+
say "\n🔐 sessions upgrade migrations have been installed.", :green
|
|
34
|
+
say "\nTo complete the upgrade:"
|
|
35
|
+
say " 1. Review the generated migration."
|
|
36
|
+
say " 2. Run 'rails db:migrate'."
|
|
37
|
+
say " ⚠️ Deploy the migrations before relying on adoption hardening or app-build event columns.", :yellow
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def migration_version
|
|
43
|
+
"[#{ActiveRecord::VERSION::STRING.to_f}]"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def model_name
|
|
47
|
+
options[:model].presence || "Session"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def table_name
|
|
51
|
+
model_name.underscore.pluralize.tr("/", "_")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -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,22 +109,40 @@ 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)
|
|
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
|
|
131
|
+
model = Sessions.session_model
|
|
114
132
|
|
|
115
|
-
row =
|
|
133
|
+
row = model.new(
|
|
116
134
|
user: record,
|
|
117
135
|
scope: scope.to_s,
|
|
118
136
|
ip_address: Sessions::IpAddress.resolve(request),
|
|
119
137
|
user_agent: request.user_agent,
|
|
120
138
|
token_digest: Sessions.token_digest(token)
|
|
121
|
-
)
|
|
139
|
+
).tap do |session|
|
|
140
|
+
attributes.each do |column, value|
|
|
141
|
+
session[column] = value if model.column_names.include?(column.to_s)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
122
144
|
row.sessions_suppress_login_event = suppress_login_event
|
|
145
|
+
row.sessions_skip_supersede = skip_supersede
|
|
123
146
|
Sessions.with_request(request) { row.save! }
|
|
124
147
|
|
|
125
148
|
warden.session(scope)[SESSION_KEY] = [row.id, token]
|
|
@@ -138,12 +161,20 @@ module Sessions
|
|
|
138
161
|
session_data = warden.session(scope)
|
|
139
162
|
next :skip if session_data[SKIP_SESSION_KEY]
|
|
140
163
|
|
|
141
|
-
|
|
164
|
+
{
|
|
165
|
+
login: session_data[SESSION_KEY],
|
|
166
|
+
pending_login: session_data[PENDING_LOGIN_KEY]
|
|
167
|
+
}
|
|
142
168
|
end
|
|
143
169
|
return if data == :skip
|
|
144
170
|
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
147
178
|
return
|
|
148
179
|
end
|
|
149
180
|
|
|
@@ -155,9 +186,13 @@ module Sessions
|
|
|
155
186
|
# never break authentication: fail OPEN, let the request through
|
|
156
187
|
# untracked, try again next request.
|
|
157
188
|
begin
|
|
158
|
-
id, token =
|
|
189
|
+
id, token = session_token
|
|
159
190
|
found = Sessions.session_model.find_by(id: id)
|
|
160
|
-
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
|
|
161
196
|
rescue StandardError => e
|
|
162
197
|
Sessions.warn("warden.fetch failed open: #{e.class}: #{e.message}")
|
|
163
198
|
return
|
|
@@ -185,37 +220,213 @@ module Sessions
|
|
|
185
220
|
def adopt_preexisting_session(record, warden, scope)
|
|
186
221
|
Sessions.safely("warden.adopt") do
|
|
187
222
|
next unless row_accepts?(record)
|
|
223
|
+
next unless document_request?(warden.request)
|
|
188
224
|
|
|
189
225
|
# IDEMPOTENT, because a client that can't persist cookies re-enters
|
|
190
226
|
# adoption on EVERY request: the SESSION_KEY we write rides a
|
|
191
|
-
# Set-Cookie the client drops, so the next request adopts again
|
|
192
|
-
#
|
|
193
|
-
#
|
|
194
|
-
#
|
|
195
|
-
#
|
|
196
|
-
#
|
|
197
|
-
#
|
|
198
|
-
#
|
|
199
|
-
|
|
227
|
+
# Set-Cookie the client drops, so the next request adopts again.
|
|
228
|
+
#
|
|
229
|
+
# Adoption is intentionally coarse: it is a low-fidelity marker for
|
|
230
|
+
# "this owner already had an authenticated session when the gem
|
|
231
|
+
# arrived", not a real login. One owner+scope marker is enough. Do
|
|
232
|
+
# not key it on UA (Hotwire Native devices legitimately use WebView
|
|
233
|
+
# and native-client UAs) or time (cookie-dropping clients would mint
|
|
234
|
+
# one per day forever). When the adoption_key column is present, the
|
|
235
|
+
# unique index makes the first concurrent burst atomic.
|
|
236
|
+
adoption_key = adoption_key_for(record, scope)
|
|
237
|
+
if (row = adopted_row(record, scope, adoption_key: adoption_key))
|
|
200
238
|
Sessions.safely("warden.adopt.touch") { row.touch_last_seen!(warden.request) }
|
|
201
239
|
next
|
|
202
240
|
end
|
|
203
241
|
|
|
204
|
-
|
|
205
|
-
|
|
242
|
+
create_adopted_row(record, warden, scope, adoption_key: adoption_key)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
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
|
|
206
260
|
end
|
|
207
261
|
end
|
|
208
262
|
|
|
209
|
-
def
|
|
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
|
+
|
|
376
|
+
def create_adopted_row(record, warden, scope, adoption_key:)
|
|
377
|
+
attributes = { auth_detail: { "adopted" => true } }
|
|
378
|
+
attributes[:adoption_key] = adoption_key if adoption_key_column?
|
|
379
|
+
|
|
380
|
+
create_row_for(record, warden, scope, suppress_login_event: true, skip_supersede: true, attributes: attributes)
|
|
381
|
+
rescue ActiveRecord::RecordNotUnique
|
|
382
|
+
adopted_row(record, scope, adoption_key: adoption_key)&.tap do |row|
|
|
383
|
+
Sessions.safely("warden.adopt.touch") { row.touch_last_seen!(warden.request) }
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def adopted_row(record, scope, adoption_key:)
|
|
210
388
|
model = Sessions.session_model
|
|
211
389
|
rows = model.where(user: record)
|
|
212
390
|
rows = rows.where(scope: scope.to_s) if model.column_names.include?("scope")
|
|
213
|
-
rows = rows.where(user_agent: warden.request.user_agent) if model.column_names.include?("user_agent")
|
|
214
391
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
392
|
+
row = model.find_by(adoption_key: adoption_key) if adoption_key_column?(model) && adoption_key.present?
|
|
393
|
+
row = nil unless adopted_row?(row)
|
|
394
|
+
row ||= rows.order(created_at: :desc).detect { |candidate| adopted_row?(candidate) }
|
|
395
|
+
|
|
396
|
+
claim_adoption_key(row, adoption_key)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def adopted_row?(row)
|
|
400
|
+
row && row.try(:auth_detail).to_h["adopted"]
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def claim_adoption_key(row, adoption_key)
|
|
404
|
+
return row unless row
|
|
405
|
+
return row unless adoption_key_column?(row.class)
|
|
406
|
+
return row if adoption_key.blank? || row.try(:adoption_key).present?
|
|
407
|
+
|
|
408
|
+
row.update_columns(adoption_key: adoption_key)
|
|
409
|
+
row.adoption_key = adoption_key
|
|
410
|
+
row
|
|
411
|
+
rescue ActiveRecord::RecordNotUnique
|
|
412
|
+
row.class.find_by(adoption_key: adoption_key) || row
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def adoption_key_for(record, scope)
|
|
416
|
+
owner_id = record.respond_to?(:to_key) ? Array(record.to_key).join("/") : record.try(:id)
|
|
417
|
+
return if owner_id.blank?
|
|
418
|
+
|
|
419
|
+
owner_type = if record.class.respond_to?(:polymorphic_name)
|
|
420
|
+
record.class.polymorphic_name
|
|
421
|
+
else
|
|
422
|
+
record.class.name
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
"adopt:#{Sessions.token_digest([owner_type, owner_id, scope.to_s].join("\0"))}"
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def adoption_key_column?(model = Sessions.session_model)
|
|
429
|
+
model.column_names.include?("adoption_key")
|
|
219
430
|
end
|
|
220
431
|
|
|
221
432
|
# SCOPE-PRECISE teardown: only this scope's warden entries go (the
|
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
|
|
@@ -163,6 +163,8 @@ files:
|
|
|
163
163
|
- gemfiles/rails_8.1.gemfile
|
|
164
164
|
- lib/generators/sessions/install_generator.rb
|
|
165
165
|
- lib/generators/sessions/madmin_generator.rb
|
|
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
|
|
166
168
|
- lib/generators/sessions/templates/add_sessions_columns.rb.erb
|
|
167
169
|
- lib/generators/sessions/templates/create_sessions.rb.erb
|
|
168
170
|
- lib/generators/sessions/templates/create_sessions_events.rb.erb
|
|
@@ -173,6 +175,7 @@ files:
|
|
|
173
175
|
- lib/generators/sessions/templates/madmin/sessions_controller.rb
|
|
174
176
|
- lib/generators/sessions/templates/session.rb.erb
|
|
175
177
|
- lib/generators/sessions/templates/sessions_sweep_job.rb
|
|
178
|
+
- lib/generators/sessions/upgrade_generator.rb
|
|
176
179
|
- lib/generators/sessions/views_generator.rb
|
|
177
180
|
- lib/sessions.rb
|
|
178
181
|
- lib/sessions/adapters/omakase.rb
|