sessions 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +16 -0
- data/app/controllers/sessions/application_controller.rb +10 -3
- data/app/helpers/sessions/engine_helper.rb +8 -1
- data/config/locales/en.yml +11 -0
- data/config/locales/es.yml +7 -0
- data/lib/generators/sessions/install_generator.rb +55 -0
- data/lib/generators/sessions/templates/add_adoption_key_to_sessions.rb.erb +14 -0
- data/lib/generators/sessions/templates/add_sessions_columns.rb.erb +7 -0
- data/lib/generators/sessions/templates/create_sessions.rb.erb +22 -5
- data/lib/generators/sessions/templates/create_sessions_events.rb.erb +15 -6
- data/lib/generators/sessions/templates/madmin/session_resource.rb +1 -0
- data/lib/generators/sessions/upgrade_generator.rb +53 -0
- data/lib/sessions/adapters/warden.rb +88 -8
- data/lib/sessions/classifier.rb +7 -1
- data/lib/sessions/models/concerns/model.rb +8 -1
- data/lib/sessions/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f088a81ac84fec5df0bfd521c3d4012fa6a91a4a84e22e997e22d18ef1658b1b
|
|
4
|
+
data.tar.gz: d50021247992ad1bef65b9a4d5f678146e774501b6adaa0af87840ec1e8ce988
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ab013f9e59218a51e6f580deaa8d1bf01e45a4fd22eded95d91e1d4e2ba434d433bceedddf06d349dcfdfa63fd2b3678c8aaa9742729353da439e6e01778115e
|
|
7
|
+
data.tar.gz: 10eabd4758d01e1cf0ed44df351f8182dfe27c80f4fec7ee2024f0500da199a1ca356c77c72d444efface0c56f091f9d1312c9d46a2c2bd80ea77a6115fe94a9
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.2 (2026-06-16)
|
|
4
|
+
|
|
5
|
+
Production-found hardening for Devise/Warden adoption, the path that turns already-authenticated pre-gem sessions into registry rows.
|
|
6
|
+
|
|
7
|
+
- **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.
|
|
8
|
+
- **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.
|
|
9
|
+
- **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`.
|
|
10
|
+
- **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.
|
|
11
|
+
|
|
12
|
+
## 0.1.1 (2026-06-12)
|
|
13
|
+
|
|
14
|
+
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.
|
|
15
|
+
|
|
16
|
+
- **Adoption is now idempotent**: a re-entering client matches its recent adopted row (same user, scope, and user agent within 24h) and just touches it — no new row, no token rotation (rotating would kick a sibling client validly holding that row's token, e.g. the app's WebView next to its native HTTP stack).
|
|
17
|
+
- **The per-user session cap now applies to suppressed (adopted) writes too** — it's the hard limit on live rows, whatever path creates them.
|
|
18
|
+
|
|
19
|
+
Plus a full-codebase audit pass:
|
|
20
|
+
|
|
21
|
+
- **The remote-revocation kick now ships its own flash copy.** The Warden adapter throws `message: :session_revoked`, and Devise's failure app resolves it via `devise.failure.session_revoked` — a key nothing shipped, so revoked devices saw a literal "Translation missing: en.devise.failure.user.session_revoked" at the exact moment the flagship feature fired. The gem now provides the key in English and Spanish (override it like any I18n key), pinned by a test that mirrors Devise's exact lookup (`I18n.t(:"#{scope}.#{message}", scope: "devise.failure", default: [message])`).
|
|
22
|
+
- **`config.strategy_methods` now truly overrides built-ins on a shared key.** The mapping merge let the built-in value win a duplicate key (e.g. remapping `"Rememberable"`), contradicting the documented host-wins contract; host entries now win both the iteration order and key conflicts.
|
|
23
|
+
- **Failed-login identity capture also reads `email_address`** — the omakase-era key, and what Devise apps with `authentication_keys = [:email_address]` post. Those failures used to record `identity: nil`, quietly gutting ATO triage and `repeated_failed_logins` for that setup.
|
|
24
|
+
- **The installer now validates the Devise auth model fit.** Default (non-polymorphic) mode assumes a `User` class — the same assumption `rails generate authentication` makes. A Devise app on `Member`/`Account` used to pass detection and then break at `db:migrate` (foreign key to a missing `users` table); the installer now reads `Devise.mappings` and stops with the fix (`--polymorphic`) before writing anything, and warns (without blocking) when extra scopes ride alongside `User`, since those stay silently untracked in default mode. Unreadable mappings stay permissive. README gained the matching callout.
|
|
25
|
+
- **API-only hosts boot.** The engine controller's class body used `helper`/`helper_method`/`layout` unconditionally — none of which exist on `ActionController::API` — and production eager-loads engine controllers whether or not the engine is mounted, so an API-only app bundling the gem for the model/trail APIs failed to boot. The view-layer DSL is now capability-guarded (mounting the HTML page still requires a `Base`-derived parent, as documented).
|
|
26
|
+
- **Generated migrations honor exotic primary-key types.** `primary_key_type: :string` (ULID-style apps) now flows through to the events table's PK and the `session_id` linkage column (previously coerced to `bigint`, breaking the trail↔registry join), and the serial pseudo-types map to their plain integer column equivalents for reference columns — a `t.bigserial` reference would mint its own sequence.
|
|
27
|
+
- `sessions_format_date`/`sessions_format_time` tolerate `nil` (custom views passing a nullable column used to hit `nil.strftime` *inside* the fallback rescue).
|
|
28
|
+
|
|
3
29
|
## 0.1.0 (2026-06-12)
|
|
4
30
|
|
|
5
31
|
First release — the missing session layer for Rails. 🔐
|
data/README.md
CHANGED
|
@@ -91,6 +91,22 @@ end
|
|
|
91
91
|
|
|
92
92
|
That's it. Every sign-in from now on lands on the devices page and in the trail — on Rails 8 auth there is literally nothing else to wire (the gem decorates the generated `Session` model automatically; your app code stays untouched).
|
|
93
93
|
|
|
94
|
+
> [!NOTE]
|
|
95
|
+
> **Auth model isn't `User`?** (Devise on `Member`/`Account`, or several `devise_for` scopes): install with `rails generate sessions:install --polymorphic` — the session owner becomes polymorphic and every scope/model gets tracked; put `has_sessions` on each of them. The default install assumes a `User` class (the same assumption `rails generate authentication` makes), and the installer stops with exactly this advice when your Devise mappings don't fit it.
|
|
96
|
+
>
|
|
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
|
+
|
|
99
|
+
### Upgrading
|
|
100
|
+
|
|
101
|
+
Existing apps upgrading from 0.1.0 or 0.1.1 should copy the upgrade migration and run it:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
rails generate sessions:upgrade
|
|
105
|
+
rails db:migrate
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
This adds `sessions.adoption_key`, the portable unique guard that makes pre-gem session adoption atomic under concurrent native-app request bursts. Fresh installs already get it from `sessions:install`.
|
|
109
|
+
|
|
94
110
|
## What `sessions` does (and doesn't) do
|
|
95
111
|
|
|
96
112
|
**Does:**
|
|
@@ -33,12 +33,19 @@ module Sessions
|
|
|
33
33
|
|
|
34
34
|
before_action :sessions_authenticate!
|
|
35
35
|
|
|
36
|
-
helper
|
|
37
|
-
|
|
36
|
+
# The view-layer DSL (`helper`, `helper_method`, `layout`) doesn't exist
|
|
37
|
+
# on ActionController::API — and an API-only host that bundles the gem
|
|
38
|
+
# purely for the model/trail APIs still EAGER LOADS this class in
|
|
39
|
+
# production, mounted or not. Guarding keeps such hosts bootable; the
|
|
40
|
+
# HTML devices page itself still requires a Base-derived
|
|
41
|
+
# config.parent_controller (the default), which is the only setup it's
|
|
42
|
+
# rendered under.
|
|
43
|
+
helper Sessions::EngineHelper if respond_to?(:helper)
|
|
44
|
+
helper_method :sessions_current_user, :sessions_current_session if respond_to?(:helper_method)
|
|
38
45
|
|
|
39
46
|
# nil falls through to the parent controller's regular layout
|
|
40
47
|
# resolution, so by default the page looks like the rest of the host.
|
|
41
|
-
layout :sessions_layout
|
|
48
|
+
layout :sessions_layout if respond_to?(:layout)
|
|
42
49
|
|
|
43
50
|
private
|
|
44
51
|
|
|
@@ -102,14 +102,21 @@ module Sessions
|
|
|
102
102
|
|
|
103
103
|
# "Signed in May 2, 2026" — localized when the host bundles date
|
|
104
104
|
# formats (rails-i18n or its own locale files), with a safe fallback so
|
|
105
|
-
# a bare host never 500s over a missing `date.formats.long`.
|
|
105
|
+
# a bare host never 500s over a missing `date.formats.long`. nil-safe:
|
|
106
|
+
# without the guard, I18n.l(nil) raises I18n::ArgumentError and the
|
|
107
|
+
# rescue would then call nil.strftime — a trap for custom views passing
|
|
108
|
+
# a nullable column.
|
|
106
109
|
def sessions_format_date(date)
|
|
110
|
+
return nil unless date
|
|
111
|
+
|
|
107
112
|
I18n.l(date, format: :long)
|
|
108
113
|
rescue I18n::MissingTranslationData, I18n::ArgumentError
|
|
109
114
|
date.strftime("%Y-%m-%d")
|
|
110
115
|
end
|
|
111
116
|
|
|
112
117
|
def sessions_format_time(time)
|
|
118
|
+
return nil unless time
|
|
119
|
+
|
|
113
120
|
I18n.l(time, format: :short)
|
|
114
121
|
rescue I18n::MissingTranslationData, I18n::ArgumentError
|
|
115
122
|
time.strftime("%Y-%m-%d %H:%M")
|
data/config/locales/en.yml
CHANGED
|
@@ -57,3 +57,14 @@ en:
|
|
|
57
57
|
composite: "%{client} on %{platform}"
|
|
58
58
|
unknown: "Unknown device"
|
|
59
59
|
bot: "Bot (%{name})"
|
|
60
|
+
# The flash a remotely-revoked device sees on its next request, rendered by
|
|
61
|
+
# Devise's failure app (the Warden adapter kicks with
|
|
62
|
+
# `throw :warden, message: :session_revoked`). Devise's lookup is
|
|
63
|
+
# `I18n.t(:"#{scope}.#{message}", scope: "devise.failure", default: [message])`
|
|
64
|
+
# — see Devise::FailureApp#i18n_message (devise/lib/devise/failure_app.rb).
|
|
65
|
+
# Without this key the flash literally reads
|
|
66
|
+
# "Translation missing: en.devise.failure.user.session_revoked".
|
|
67
|
+
# Hosts override it like any I18n key (app locale files load after engines).
|
|
68
|
+
devise:
|
|
69
|
+
failure:
|
|
70
|
+
session_revoked: "That session was signed out remotely. Please sign in again."
|
data/config/locales/es.yml
CHANGED
|
@@ -57,3 +57,10 @@ es:
|
|
|
57
57
|
composite: "%{client} en %{platform}"
|
|
58
58
|
unknown: "Dispositivo desconocido"
|
|
59
59
|
bot: "Bot (%{name})"
|
|
60
|
+
# El aviso que ve un dispositivo cuya sesión fue revocada en remoto, en su
|
|
61
|
+
# siguiente petición (lo muestra la failure app de Devise; el adaptador de
|
|
62
|
+
# Warden expulsa con `throw :warden, message: :session_revoked`). Sin esta
|
|
63
|
+
# clave, el flash mostraría literalmente "Translation missing: …".
|
|
64
|
+
devise:
|
|
65
|
+
failure:
|
|
66
|
+
session_revoked: "Esa sesión se cerró de forma remota. Vuelve a iniciar sesión."
|
|
@@ -78,6 +78,48 @@ module Sessions
|
|
|
78
78
|
MSG
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
+
# Default (non-polymorphic) mode assumes a `User` class — the same
|
|
82
|
+
# assumption `rails generate authentication` makes: `belongs_to :user`
|
|
83
|
+
# and a foreign key to `users`. A Devise app whose auth model is
|
|
84
|
+
# Member/Account would pass detection and then break at db:migrate
|
|
85
|
+
# (FK to a missing `users` table) or at runtime (`belongs_to :user`
|
|
86
|
+
# constantizing a class that doesn't exist) — catch it HERE, with the
|
|
87
|
+
# fix in the error message. `--polymorphic` works with any model(s).
|
|
88
|
+
def check_devise_auth_model_fit!
|
|
89
|
+
return if polymorphic?
|
|
90
|
+
return unless devise_detected?
|
|
91
|
+
# An adopted omakase table already proves whatever owner shape the
|
|
92
|
+
# host made work — nothing for us to second-guess.
|
|
93
|
+
return if adopt_existing_table?
|
|
94
|
+
|
|
95
|
+
classes = devise_auth_class_names
|
|
96
|
+
return if classes.empty? # mappings unreadable — stay permissive
|
|
97
|
+
return if classes == ["User"] # the default assumption holds
|
|
98
|
+
|
|
99
|
+
if classes.include?("User")
|
|
100
|
+
# User plus other scopes: default mode tracks User and SILENTLY
|
|
101
|
+
# skips the rest (the runtime adapter's row_accepts? guard) —
|
|
102
|
+
# surface that tradeoff at install time, but proceed.
|
|
103
|
+
say "⚠️ Multiple Devise models detected (#{classes.join(", ")}).", :yellow
|
|
104
|
+
say " The default install tracks only User — sessions for the other models", :yellow
|
|
105
|
+
say " stay untracked. Re-run with --polymorphic to track them all.", :yellow
|
|
106
|
+
return
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
raise Thor::Error, <<~MSG
|
|
110
|
+
❌ Your Devise model is #{classes.join(", ")} — not User. The default install
|
|
111
|
+
assumes a `User` class (`belongs_to :user`, foreign key to `users`, the
|
|
112
|
+
same assumption `rails generate authentication` makes) and would break
|
|
113
|
+
at migrate/runtime.
|
|
114
|
+
|
|
115
|
+
Re-run with the polymorphic owner, which works with any model(s):
|
|
116
|
+
|
|
117
|
+
rails generate sessions:install --polymorphic
|
|
118
|
+
|
|
119
|
+
…then declare `has_sessions` on #{classes.join(" and ")}.
|
|
120
|
+
MSG
|
|
121
|
+
end
|
|
122
|
+
|
|
81
123
|
def create_migration_files
|
|
82
124
|
if adopt_existing_table?
|
|
83
125
|
migration_template "add_sessions_columns.rb.erb",
|
|
@@ -191,6 +233,19 @@ module Sessions
|
|
|
191
233
|
defined?(::Devise) ? true : false
|
|
192
234
|
end
|
|
193
235
|
|
|
236
|
+
# The class names behind the host's `devise_for` scopes, as strings
|
|
237
|
+
# (never constantized — the generator must not boot-order-couple to
|
|
238
|
+
# app models). On Rails 8, `Devise.mappings` itself force-loads the
|
|
239
|
+
# lazy routes (Devise 5.x, lib/devise.rb), so an empty hash genuinely
|
|
240
|
+
# means "no devise_for drawn yet" — nothing to validate against.
|
|
241
|
+
def devise_auth_class_names
|
|
242
|
+
return [] unless devise_detected?
|
|
243
|
+
|
|
244
|
+
::Devise.mappings.values.map { |mapping| mapping.class_name.to_s }.uniq.sort
|
|
245
|
+
rescue StandardError
|
|
246
|
+
[]
|
|
247
|
+
end
|
|
248
|
+
|
|
194
249
|
def adopt_existing_table?
|
|
195
250
|
return @adopt_existing_table if defined?(@adopt_existing_table)
|
|
196
251
|
|
|
@@ -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
|
|
@@ -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
|
|
@@ -79,16 +86,26 @@ class Create<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_ve
|
|
|
79
86
|
|
|
80
87
|
private
|
|
81
88
|
|
|
82
|
-
# Honor the host's configured primary key type (uuid
|
|
83
|
-
# same setting `rails g model` uses, so an app generated with
|
|
89
|
+
# Honor the host's configured primary key type (uuid, string/ULID, bigint…).
|
|
90
|
+
# Reads the same setting `rails g model` uses, so an app generated with
|
|
84
91
|
# `config.generators { |g| g.orm :active_record, primary_key_type: :uuid }`
|
|
85
92
|
# gets uuid sessions tables and uuid foreign keys, automatically.
|
|
86
93
|
def primary_and_foreign_key_types
|
|
87
94
|
config = Rails.configuration.generators
|
|
88
95
|
setting = config.options[config.orm][:primary_key_type]
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
96
|
+
[ setting || :primary_key, reference_column_type(setting) ]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Reference columns hold VALUES of the PK type, so the serial pseudo-types
|
|
100
|
+
# map to their plain integer equivalents — a `t.bigserial` reference would
|
|
101
|
+
# mint its own auto-increment sequence, which is exactly wrong for a
|
|
102
|
+
# foreign key. Everything else (uuid, string ULIDs, …) passes through.
|
|
103
|
+
def reference_column_type(setting)
|
|
104
|
+
case setting
|
|
105
|
+
when nil, :bigserial then :bigint
|
|
106
|
+
when :serial then :integer
|
|
107
|
+
else setting
|
|
108
|
+
end
|
|
92
109
|
end
|
|
93
110
|
|
|
94
111
|
# match? (not equality): PostGIS apps report adapter_name "PostGIS" and
|
|
@@ -85,17 +85,26 @@ class CreateSessionsEvents < ActiveRecord::Migration<%= migration_version %>
|
|
|
85
85
|
def primary_and_foreign_key_types
|
|
86
86
|
config = Rails.configuration.generators
|
|
87
87
|
setting = config.options[config.orm][:primary_key_type]
|
|
88
|
-
|
|
89
|
-
foreign_key_type = setting || :bigint
|
|
90
|
-
[ primary_key_type, foreign_key_type ]
|
|
88
|
+
[ setting || :primary_key, reference_column_type(setting) ]
|
|
91
89
|
end
|
|
92
90
|
|
|
93
91
|
# session_id must match the sessions table's PRIMARY KEY type (uuid hosts
|
|
94
|
-
# store uuid linkage
|
|
92
|
+
# store uuid linkage, string/ULID hosts string, bigint hosts bigint).
|
|
95
93
|
def session_id_column_type
|
|
96
94
|
config = Rails.configuration.generators
|
|
97
|
-
|
|
98
|
-
|
|
95
|
+
reference_column_type(config.options[config.orm][:primary_key_type])
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Reference columns hold VALUES of the PK type, so the serial pseudo-types
|
|
99
|
+
# map to their plain integer equivalents — a `t.bigserial` reference would
|
|
100
|
+
# mint its own auto-increment sequence, which is exactly wrong for a
|
|
101
|
+
# foreign key. Everything else (uuid, string ULIDs, …) passes through.
|
|
102
|
+
def reference_column_type(setting)
|
|
103
|
+
case setting
|
|
104
|
+
when nil, :bigserial then :bigint
|
|
105
|
+
when :serial then :integer
|
|
106
|
+
else setting
|
|
107
|
+
end
|
|
99
108
|
end
|
|
100
109
|
|
|
101
110
|
# match? (not equality): PostGIS apps report adapter_name "PostGIS" and
|
|
@@ -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,53 @@
|
|
|
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
|
+
end
|
|
29
|
+
|
|
30
|
+
def display_post_upgrade_message
|
|
31
|
+
say "\n🔐 sessions upgrade migrations have been installed.", :green
|
|
32
|
+
say "\nTo complete the upgrade:"
|
|
33
|
+
say " 1. Review the generated migration."
|
|
34
|
+
say " 2. Run 'rails db:migrate'."
|
|
35
|
+
say " ⚠️ Deploy the migration before relying on adoption hardening.", :yellow
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def migration_version
|
|
41
|
+
"[#{ActiveRecord::VERSION::STRING.to_f}]"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def model_name
|
|
45
|
+
options[:model].presence || "Session"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def table_name
|
|
49
|
+
model_name.underscore.pluralize.tr("/", "_")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -36,8 +36,9 @@ module Sessions
|
|
|
36
36
|
SKIP_ENV_KEY = "sessions.skip" # = Sessions::SKIP_ENV_KEY (set by Sessions.skip!)
|
|
37
37
|
|
|
38
38
|
# The `throw :warden` message on revoked sessions — Devise's failure
|
|
39
|
-
# app surfaces it like :timeout/:session_limited
|
|
40
|
-
# `devise.failure.session_revoked`
|
|
39
|
+
# app surfaces it like :timeout/:session_limited. The gem SHIPS the
|
|
40
|
+
# `devise.failure.session_revoked` copy (en + es, config/locales/);
|
|
41
|
+
# hosts override that key for custom wording.
|
|
41
42
|
THROW_MESSAGE = :session_revoked
|
|
42
43
|
|
|
43
44
|
module_function
|
|
@@ -107,17 +108,22 @@ module Sessions
|
|
|
107
108
|
end
|
|
108
109
|
end
|
|
109
110
|
|
|
110
|
-
def create_row_for(record, warden, scope, suppress_login_event: false)
|
|
111
|
+
def create_row_for(record, warden, scope, suppress_login_event: false, attributes: {})
|
|
111
112
|
token = Sessions.generate_token
|
|
112
113
|
request = warden.request
|
|
114
|
+
model = Sessions.session_model
|
|
113
115
|
|
|
114
|
-
row =
|
|
116
|
+
row = model.new(
|
|
115
117
|
user: record,
|
|
116
118
|
scope: scope.to_s,
|
|
117
119
|
ip_address: Sessions::IpAddress.resolve(request),
|
|
118
120
|
user_agent: request.user_agent,
|
|
119
121
|
token_digest: Sessions.token_digest(token)
|
|
120
|
-
)
|
|
122
|
+
).tap do |session|
|
|
123
|
+
attributes.each do |column, value|
|
|
124
|
+
session[column] = value if model.column_names.include?(column.to_s)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
121
127
|
row.sessions_suppress_login_event = suppress_login_event
|
|
122
128
|
Sessions.with_request(request) { row.save! }
|
|
123
129
|
|
|
@@ -185,11 +191,83 @@ module Sessions
|
|
|
185
191
|
Sessions.safely("warden.adopt") do
|
|
186
192
|
next unless row_accepts?(record)
|
|
187
193
|
|
|
188
|
-
|
|
189
|
-
|
|
194
|
+
# IDEMPOTENT, because a client that can't persist cookies re-enters
|
|
195
|
+
# adoption on EVERY request: the SESSION_KEY we write rides a
|
|
196
|
+
# Set-Cookie the client drops, so the next request adopts again.
|
|
197
|
+
#
|
|
198
|
+
# Adoption is intentionally coarse: it is a low-fidelity marker for
|
|
199
|
+
# "this owner already had an authenticated session when the gem
|
|
200
|
+
# arrived", not a real login. One owner+scope marker is enough. Do
|
|
201
|
+
# not key it on UA (Hotwire Native devices legitimately use WebView
|
|
202
|
+
# and native-client UAs) or time (cookie-dropping clients would mint
|
|
203
|
+
# one per day forever). When the adoption_key column is present, the
|
|
204
|
+
# unique index makes the first concurrent burst atomic.
|
|
205
|
+
adoption_key = adoption_key_for(record, scope)
|
|
206
|
+
if (row = adopted_row(record, scope, adoption_key: adoption_key))
|
|
207
|
+
Sessions.safely("warden.adopt.touch") { row.touch_last_seen!(warden.request) }
|
|
208
|
+
next
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
create_adopted_row(record, warden, scope, adoption_key: adoption_key)
|
|
190
212
|
end
|
|
191
213
|
end
|
|
192
214
|
|
|
215
|
+
def create_adopted_row(record, warden, scope, adoption_key:)
|
|
216
|
+
attributes = { auth_detail: { "adopted" => true } }
|
|
217
|
+
attributes[:adoption_key] = adoption_key if adoption_key_column?
|
|
218
|
+
|
|
219
|
+
create_row_for(record, warden, scope, suppress_login_event: true, attributes: attributes)
|
|
220
|
+
rescue ActiveRecord::RecordNotUnique
|
|
221
|
+
adopted_row(record, scope, adoption_key: adoption_key)&.tap do |row|
|
|
222
|
+
Sessions.safely("warden.adopt.touch") { row.touch_last_seen!(warden.request) }
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def adopted_row(record, scope, adoption_key:)
|
|
227
|
+
model = Sessions.session_model
|
|
228
|
+
rows = model.where(user: record)
|
|
229
|
+
rows = rows.where(scope: scope.to_s) if model.column_names.include?("scope")
|
|
230
|
+
|
|
231
|
+
row = model.find_by(adoption_key: adoption_key) if adoption_key_column?(model) && adoption_key.present?
|
|
232
|
+
row = nil unless adopted_row?(row)
|
|
233
|
+
row ||= rows.order(created_at: :desc).detect { |candidate| adopted_row?(candidate) }
|
|
234
|
+
|
|
235
|
+
claim_adoption_key(row, adoption_key)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def adopted_row?(row)
|
|
239
|
+
row && row.try(:auth_detail).to_h["adopted"]
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def claim_adoption_key(row, adoption_key)
|
|
243
|
+
return row unless row
|
|
244
|
+
return row unless adoption_key_column?(row.class)
|
|
245
|
+
return row if adoption_key.blank? || row.try(:adoption_key).present?
|
|
246
|
+
|
|
247
|
+
row.update_columns(adoption_key: adoption_key)
|
|
248
|
+
row.adoption_key = adoption_key
|
|
249
|
+
row
|
|
250
|
+
rescue ActiveRecord::RecordNotUnique
|
|
251
|
+
row.class.find_by(adoption_key: adoption_key) || row
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def adoption_key_for(record, scope)
|
|
255
|
+
owner_id = record.respond_to?(:to_key) ? Array(record.to_key).join("/") : record.try(:id)
|
|
256
|
+
return if owner_id.blank?
|
|
257
|
+
|
|
258
|
+
owner_type = if record.class.respond_to?(:polymorphic_name)
|
|
259
|
+
record.class.polymorphic_name
|
|
260
|
+
else
|
|
261
|
+
record.class.name
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
"adopt:#{Sessions.token_digest([owner_type, owner_id, scope.to_s].join("\0"))}"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def adoption_key_column?(model = Sessions.session_model)
|
|
268
|
+
model.column_names.include?("adoption_key")
|
|
269
|
+
end
|
|
270
|
+
|
|
193
271
|
# SCOPE-PRECISE teardown: only this scope's warden entries go (the
|
|
194
272
|
# serialized user key and our token stash) — an admin scope riding
|
|
195
273
|
# the same rack session, and unrelated host session data (carts,
|
|
@@ -225,7 +303,9 @@ module Sessions
|
|
|
225
303
|
credentials = request.params[scope.to_s]
|
|
226
304
|
next unless credentials.is_a?(Hash)
|
|
227
305
|
|
|
228
|
-
|
|
306
|
+
# `email_address` included: it's the omakase-era key, and Devise
|
|
307
|
+
# apps configure `authentication_keys = [:email_address]` too.
|
|
308
|
+
identity = credentials.values_at("email", "email_address", "login", "username", "phone").compact.first
|
|
229
309
|
|
|
230
310
|
Sessions::Event.record_failure(
|
|
231
311
|
request,
|
data/lib/sessions/classifier.rb
CHANGED
|
@@ -153,7 +153,13 @@ module Sessions
|
|
|
153
153
|
end
|
|
154
154
|
|
|
155
155
|
def method_for_strategy(strategy_name)
|
|
156
|
-
|
|
156
|
+
# Host entries are consulted FIRST (Hash#merge keeps the receiver's
|
|
157
|
+
# keys in front, so config substrings win the iteration order) and the
|
|
158
|
+
# block makes them WIN on a shared key too — a bare `merge` would let
|
|
159
|
+
# the built-in value silently clobber a host override of, say,
|
|
160
|
+
# "Rememberable".
|
|
161
|
+
mappings = Sessions.config.strategy_methods.merge(STRATEGY_METHODS) { |_key, custom, _builtin| custom }
|
|
162
|
+
mappings.each do |substring, method|
|
|
157
163
|
return method if strategy_name.include?(substring)
|
|
158
164
|
end
|
|
159
165
|
nil
|
|
@@ -335,7 +335,14 @@ module Sessions
|
|
|
335
335
|
|
|
336
336
|
def sessions_record_login
|
|
337
337
|
Sessions.safely("record_login") do
|
|
338
|
-
|
|
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
|
|
339
346
|
|
|
340
347
|
# Same browser signing in again (abandoned session, expired
|
|
341
348
|
# remember-me, browser update — anything) replaces its old row
|
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.2
|
|
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-16 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: actionpack
|
|
@@ -163,6 +163,7 @@ 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
|
|
166
167
|
- lib/generators/sessions/templates/add_sessions_columns.rb.erb
|
|
167
168
|
- lib/generators/sessions/templates/create_sessions.rb.erb
|
|
168
169
|
- lib/generators/sessions/templates/create_sessions_events.rb.erb
|
|
@@ -173,6 +174,7 @@ files:
|
|
|
173
174
|
- lib/generators/sessions/templates/madmin/sessions_controller.rb
|
|
174
175
|
- lib/generators/sessions/templates/session.rb.erb
|
|
175
176
|
- lib/generators/sessions/templates/sessions_sweep_job.rb
|
|
177
|
+
- lib/generators/sessions/upgrade_generator.rb
|
|
176
178
|
- lib/generators/sessions/views_generator.rb
|
|
177
179
|
- lib/sessions.rb
|
|
178
180
|
- lib/sessions/adapters/omakase.rb
|