sessions 0.1.3 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +35 -2
- data/README.md +26 -16
- data/app/controllers/sessions/application_controller.rb +4 -3
- data/app/helpers/sessions/engine_helper.rb +23 -0
- data/app/views/sessions/_devices.html.erb +2 -2
- data/app/views/sessions/_history.html.erb +2 -2
- data/docs/PRD.md +52 -52
- data/docs/research/{01-carhey.md → 01-host-app.md} +23 -23
- data/docs/research/02-ecosystem.md +1 -1
- data/docs/research/07-device-detection.md +13 -13
- data/lib/generators/sessions/install_generator.rb +1 -1
- data/lib/generators/sessions/madmin_generator.rb +23 -7
- data/lib/generators/sessions/templates/add_lifecycle_to_sessions.rb.erb +37 -0
- data/lib/generators/sessions/templates/add_sessions_columns.rb.erb +28 -0
- data/lib/generators/sessions/templates/create_sessions.rb.erb +20 -5
- data/lib/generators/sessions/templates/create_sessions_events.rb.erb +4 -4
- data/lib/generators/sessions/templates/initializer.rb +9 -8
- data/lib/generators/sessions/templates/madmin/session_resource.rb +24 -13
- data/lib/generators/sessions/templates/madmin/sessions_controller.rb +11 -7
- data/lib/generators/sessions/templates/madmin/user_panel/madmin_user_panel.rb +43 -0
- data/lib/generators/sessions/templates/madmin/user_panel/sessions.html.erb +133 -0
- data/lib/generators/sessions/upgrade_generator.rb +4 -1
- data/lib/sessions/adapters/omakase.rb +62 -18
- data/lib/sessions/adapters/warden.rb +172 -48
- data/lib/sessions/configuration.rb +14 -4
- data/lib/sessions/current.rb +4 -4
- data/lib/sessions/end_reason.rb +67 -0
- data/lib/sessions/models/concerns/has_sessions.rb +3 -3
- data/lib/sessions/models/concerns/model.rb +124 -59
- data/lib/sessions/models/event.rb +56 -21
- data/lib/sessions/version.rb +1 -1
- data/lib/sessions.rb +68 -14
- metadata +9 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6ead91af5a7b6b9818df58d3c068ce4d59e09a5b865e80373ba2e7b7c5854e75
|
|
4
|
+
data.tar.gz: 6ad52d4780745c525de40a76cb462e5cedc87b3f54dfff7f9d803f69ad9f0033
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f32fb15dbbef609551071da2de77a4b6c0f9117e496a2405548d87a5fcddad11bb14b1e7c738e0bdd70112634ad92d7b5a1ecf3fb041b4b2d3420e8d0fba3e3b
|
|
7
|
+
data.tar.gz: fffa0d52b6c6353b1d326f0a76c7a3e49ea8bac0f3bf8b160923675c99bfa9d7a12db4f85e3120a532b677e4539fedb90b5aa80917eb88f6c87455699d52726e
|
data/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,44 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.2 (2026-07-06)
|
|
4
|
+
|
|
5
|
+
Closes the revocation-semantics gaps found auditing the 0.2.x lifecycle model against production data.
|
|
6
|
+
|
|
7
|
+
- **Explicit revocations now kick tokenless remembered devices too.** Remember-me restores reattach to their device row as a tokenless tracking hint (0.1.3 semantics). Hints still never turn absence or housekeeping into a logout — but when the authenticated owner, the signed browser-continuity cookie, and the row all agree it is the same device AND that row was explicitly ended (`admin_revoked`, `user_revoked`, `logout_everywhere`, …), the next request now kicks, exactly as the admin/devices UI promises. Previously such devices kept their live host session until it expired naturally (remember-me revival was already blocked; the active rack session was not).
|
|
8
|
+
- **`Sessions.current` resolves hint-tracked rows.** Remembered devices get their "this device" badge back on the devices page — and the page's refuse-to-revoke-current guard (plus `revoke_other_sessions!(current:)`) works for them again. The hint is corroborated against the signed browser-continuity cookie before being trusted.
|
|
9
|
+
- **Sweep expiry no longer masquerades as revocation.** `Sessions.sweep!` ends idle/over-age sessions with `end!(reason: :expired)` (audit event written, kicks on resume) instead of `revoke!` — no more account-wide remember-me rotation or `on_session_revoked` fire for timeout policy, matching the adapter's inline expiry and Devise's own timeoutable semantics.
|
|
10
|
+
- **Plain callable-object hooks receive `on_repeated_failed_logins` again.** 0.2.1's keyword-slicing dispatch introspected `#parameters`, which only Procs and Methods expose; a class-based hook (`def call(identity:, count:, event:)`) silently lost the alert. Dispatch now introspects `#method(:call)` for such objects.
|
|
11
|
+
|
|
12
|
+
## 0.2.1 (2026-07-03)
|
|
13
|
+
|
|
14
|
+
- Prevent fresh-install and adopted-table migrations from creating the `ended_by` polymorphic index twice.
|
|
15
|
+
- Preserve custom string/ULID primary-key types in the 0.2 lifecycle upgrade migration.
|
|
16
|
+
- Make generated Madmin revocation controls lifecycle-aware so ended rows cannot report a second successful revoke.
|
|
17
|
+
- Correct the 0.2 documentation for `event.session`: it resolves retained lifecycle rows after logout/revocation and becomes `nil` only after hard deletion.
|
|
18
|
+
- Document that Devise's `bypass_sign_in` skips the Warden callbacks required to register a new session.
|
|
19
|
+
- Bound the device parser dependency to the tested `browser` 6.x API line.
|
|
20
|
+
- Let `config.layout` be a callable (`->(controller) { ... }`) for hosts that switch layouts between web and native shells.
|
|
21
|
+
- Pass `user:` (resolved best-effort, never persisted on the failed event) to `on_repeated_failed_logins` when the hook opts into the keyword.
|
|
22
|
+
- Add `sessions_devices_for(user)` / `sessions_history_for(user)` helpers so ejected/custom views keep live-device and enumeration-safe history semantics in one place.
|
|
23
|
+
- Add `rails generate sessions:madmin --user-panel` for the repeated per-user admin security panel concern/view.
|
|
24
|
+
|
|
25
|
+
## 0.2.0 (2026-06-21)
|
|
26
|
+
|
|
27
|
+
Lifecycle-row rewrite and auth-safety correction for the Hotwire Native / Devise remember-me hardening introduced in 0.1.3.
|
|
28
|
+
|
|
29
|
+
- **Session rows are now the lifecycle source of truth.** 0.1.x used "row missing + event tombstone" as the revocation signal. That made Warden infer security intent from absence, which crossed the gem's own boundary: `sessions` decorates Devise/Rails auth, it does not own auth. 0.2 adds `ended_at`, `ended_reason`, `ended_by`, and `ended_metadata`; `sessions.live` is the revocable device set, and `sessions_events` is audit history.
|
|
30
|
+
- **Tracking can no longer turn quiet housekeeping into logout.** Warden fetch now kicks only when a token-backed row exists and has an explicit kicking lifecycle reason (`logout`, `expired`, `user_revoked`, `admin_revoked`, `password_change`, `logout_everywhere`, `pruned`, `unknown`). Quiet `superseded`, token mismatch, database lookup errors, stale tracking keys, and missing rows fail open and clear only the gem's tracking key. Legacy v0.1.x destroyed rows still kick when an old `revoked`/`expired` event proves explicit action.
|
|
31
|
+
- **Remembered Warden restores stay quiet.** Same-device remember-me restores still reattach to the existing row without a duplicate login event, preserving the 0.1.3 noise reduction. The tokenless `[row_id, nil]` value is now only a tracking hint: if the signed browser-continuity cookie matches, the row may be touched; if it does not, tracking is dropped for that request and Devise/Rails continue to own authentication.
|
|
32
|
+
- **Explicit revocation is transactional.** `revoke!` now ends the row and persists the matching `revoked`/`expired` audit event in one transaction. If the event cannot be written, the row is left live and the explicit action fails loudly.
|
|
33
|
+
- **Tokenless remembered restores still log out cleanly.** The quiet known-device hint introduced in 0.1.3 now participates in the Warden logout hook when the signed browser-continuity cookie still matches the row, so a user sign-out ends the device row and writes a `logout` event without needing a raw Warden token.
|
|
34
|
+
- **Upgrade migration added.** `rails generate sessions:upgrade` now adds the 0.2 lifecycle columns to existing installs. Fresh installs include them in the base sessions table.
|
|
35
|
+
|
|
3
36
|
## 0.1.3 (2026-06-21)
|
|
4
37
|
|
|
5
38
|
Production-found hardening for Hotwire Native / Devise remember-me startup flows, plus compatibility and generated admin-schema fixes.
|
|
6
39
|
|
|
7
40
|
- **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.
|
|
41
|
+
- **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. Explicitly ended legacy rows still kick normally.
|
|
9
42
|
- **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
43
|
- **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
44
|
- **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.
|
|
@@ -43,7 +76,7 @@ Plus a full-codebase audit pass:
|
|
|
43
76
|
First release — the missing session layer for Rails. 🔐
|
|
44
77
|
|
|
45
78
|
- **Live device registry** on the session table your app already has: the Rails 8 `sessions` table is adopted and enriched in place (zero app-code edits), and Devise apps get the same Rails-8-shaped table generated for them.
|
|
46
|
-
- **Per-session remote revocation** that actually works on both stacks: `session.revoke!`, `user.revoke_other_sessions!`, `user.revoke_all_sessions!` —
|
|
79
|
+
- **Per-session remote revocation** that actually works on both stacks: `session.revoke!`, `user.revoke_other_sessions!`, `user.revoke_all_sessions!` — the device is signed out on its very next request. On Devise this generalizes the proven `session_limitable` mechanism into token-per-row (N devices, each individually revocable) and rotates remember-me credentials on revoke.
|
|
47
80
|
- **Append-only login-activity trail** (`sessions_events`): every successful *and failed* login, logout, revocation and expiry — with the attempted identity (even for unknown accounts), the device, the geo, and the trail↔registry linkage (`session_id`) no prior art has.
|
|
48
81
|
- **Device dedup via browser continuity**: a signed, long-lived `sessions_device_id` cookie (minted only at login — never a pre-login tracker) identifies the browser install, so a repeat login from the same browser *supersedes* its old row instead of stacking duplicate "Firefox on macOS" entries. Robust to browser updates (identity is the cookie, not the UA); private windows and other users on the same machine stay separate; superseding is quiet housekeeping (no revocation hook, no remember-me rotation, and the surviving trail prevents false new-device alerts).
|
|
49
82
|
- **The "Last used" badge, server-side**: `Sessions.last_login(request)` returns the most recent login event from THIS browser — on the login page, signed out — because the continuity cookie survives logout and login events carry the device id. One lookup powers the "Last used" pill next to your OAuth/passkey/password buttons; no JavaScript, no localStorage.
|
data/README.md
CHANGED
|
@@ -14,7 +14,7 @@ And it's built for how people actually sign in now: password, OAuth (Google, App
|
|
|
14
14
|
## 👨💻 Example
|
|
15
15
|
|
|
16
16
|
```ruby
|
|
17
|
-
current_user.sessions.
|
|
17
|
+
current_user.sessions.live # every live device, most recent first
|
|
18
18
|
|
|
19
19
|
session = current_user.sessions.first
|
|
20
20
|
session.device_name # => "Chrome 137 on macOS"
|
|
@@ -98,21 +98,21 @@ 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
|
|
101
|
+
Existing apps upgrading from any 0.1.x release 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` (for installs before 0.1.2),
|
|
108
|
+
This adds `sessions.adoption_key` (for installs before 0.1.2), `sessions_events.app_build` (for installs before 0.1.3), and the 0.2 lifecycle columns (`sessions.ended_at`, `ended_reason`, `ended_by`, `ended_metadata`). Fresh installs already get all of them from `sessions:install`.
|
|
109
109
|
|
|
110
110
|
## What `sessions` does (and doesn't) do
|
|
111
111
|
|
|
112
112
|
**Does:**
|
|
113
113
|
|
|
114
114
|
- **Live device registry** — one row per signed-in device on the (Rails-8-shaped) `sessions` table, enriched with parsed device intelligence, geolocation, auth method, and a throttled `last_seen_at`.
|
|
115
|
-
- **Remote revocation that actually works** —
|
|
115
|
+
- **Remote revocation that actually works** — mark the row ended, and that device is logged out on its very next matching request, on both auth stacks. Revoking a Devise session also rotates remember-me credentials so a stolen long-lived cookie can't quietly revive it.
|
|
116
116
|
- **Append-only login trail** — logins, *failed* logins (with the typed identity, even for accounts that don't exist), logouts, revocations, expirations. Each trail row links to the live session it created: a suspicious login is one lookup away from the kill switch.
|
|
117
117
|
- **Every 2026 login method** — password and OAuth classify automatically (OmniAuth failures get captured too, via a composed `on_failure`); One Tap / passkeys / magic links / SSO take one `Sessions.tag` line.
|
|
118
118
|
- **Hotwire Native device intelligence** — platform, OS version, and (on Android) device model work with zero setup; add the [UA prefix convention](#-hotwire-native) for app versions and iOS hardware models.
|
|
@@ -141,7 +141,7 @@ Three ways to ship it, pick your layer:
|
|
|
141
141
|
(Revoke buttons render when the engine is mounted; without it you get the read-only registry.)
|
|
142
142
|
3. **Eject and restyle** — `rails generate sessions:views` copies every template into `app/views/sessions/`, where your copies shadow the gem's automatically (the Devise move).
|
|
143
143
|
|
|
144
|
-
The engine inherits from your `ApplicationController` (configurable via `config.parent_controller`), so your layout, auth and locale apply automatically. The current session is resolved on both stacks; destructive actions can be gated behind
|
|
144
|
+
The engine inherits from your `ApplicationController` (configurable via `config.parent_controller`), so your layout, auth and locale apply automatically. `config.layout` can be a string/symbol or a callable (`->(controller) { controller.hotwire_native_app? ? "app" : "web" }`) when native and browser shells differ. The current session is resolved on both stacks; destructive actions can be gated behind a sudo/password-confirm flow with `config.require_reauthentication`. One heads-up: if your app layout leans heavily on host route helpers, isolated-engine rendering means those resolve through `main_app.*` — rendering the partials in your own page (layer 2) sidesteps the whole topic.
|
|
145
145
|
|
|
146
146
|
A hard rule the page enforces: **you can never touch a session you don't own** (foreign ids 404 — existence never leaks), and the current session is never revocable from the page (that's what sign-out is for).
|
|
147
147
|
|
|
@@ -152,7 +152,7 @@ A hard rule the page enforces: **you can never touch a session you don't own** (
|
|
|
152
152
|
```ruby
|
|
153
153
|
Sessions::Event.logins / .failed_logins / .logouts / .revocations / .expirations
|
|
154
154
|
Sessions::Event.recent.last_days(90).for_ip("203.0.113.7")
|
|
155
|
-
event.session # the
|
|
155
|
+
event.session # the lifecycle row it created (live or explicitly ended)
|
|
156
156
|
event.new_device? # flagged when the login matched no prior device
|
|
157
157
|
```
|
|
158
158
|
|
|
@@ -212,6 +212,8 @@ Sessions.record_failed_attempt(request, scope: :user, identity: params[:email],
|
|
|
212
212
|
|
|
213
213
|
Custom Warden strategies map with `config.strategy_methods = { "OtpAuthenticatable" => :otp }`. Everything else is `unknown` — the gem never guesses.
|
|
214
214
|
|
|
215
|
+
Devise's `bypass_sign_in` writes directly to its session serializer and deliberately skips Warden callbacks. Do not use it to establish a new login that must appear in the registry; use `sign_in` instead. It remains appropriate for refreshing the serialization of an already-tracked login.
|
|
216
|
+
|
|
215
217
|
### Two-factor flows (TOTP apps, security keys, Touch ID)
|
|
216
218
|
|
|
217
219
|
Every mainstream Ruby 2FA setup creates the session at **full** authentication — we verified each one against its source — so the registry and trail stay correct without configuration. What varies is the labeling, and where a recipe is needed it's one line:
|
|
@@ -278,13 +280,13 @@ Per-attempt alerts are notification fatigue *and* an abuse vector (an attacker h
|
|
|
278
280
|
|
|
279
281
|
```ruby
|
|
280
282
|
config.repeated_failed_logins = { threshold: 5, within: 15.minutes }
|
|
281
|
-
config.on_repeated_failed_logins = ->(identity:, count:, event:) do
|
|
282
|
-
|
|
283
|
+
config.on_repeated_failed_logins = ->(identity:, count:, event:, user:) do
|
|
284
|
+
next unless user # nil when the typed identity matches no account
|
|
283
285
|
SecurityMailer.with(user: user, event: event).repeated_failed_logins.deliver_later
|
|
284
286
|
end
|
|
285
287
|
```
|
|
286
288
|
|
|
287
|
-
The `event` is the attempt that tripped the threshold — IP, location and device included. This complements (not replaces) Devise's `:lockable` and Rails 8's `rate_limit`: they *stop* the attacker; this tells the *user*.
|
|
289
|
+
The `event` is the attempt that tripped the threshold — IP, location and device included. `user:` is resolved in memory for the hook but still not persisted on failed events, preserving the no-enumeration data model. Existing hooks that only accept `identity:, count:, event:` keep working. This complements (not replaces) Devise's `:lockable` and Rails 8's `rate_limit`: they *stop* the attacker; this tells the *user*.
|
|
288
290
|
|
|
289
291
|
## 🌍 Geolocation (via `trackdown`, soft dependency)
|
|
290
292
|
|
|
@@ -305,7 +307,7 @@ config.on_new_device = ->(user:, session:, event:) do
|
|
|
305
307
|
end
|
|
306
308
|
```
|
|
307
309
|
|
|
308
|
-
Pass the **event** to your mailer, not the session: the event is a persisted, GlobalID-able record that survives revocation
|
|
310
|
+
Pass the **event** to your mailer, not the session: the event is a persisted, GlobalID-able record that survives revocation and account-erasure cleanup, and it carries everything the email needs — `event.user`, `event.device_name`, `event.location`, `event.country_flag`, `event.source_line`, `event.occurred_at`:
|
|
309
311
|
|
|
310
312
|
```ruby
|
|
311
313
|
class SecurityMailer < ApplicationMailer
|
|
@@ -353,7 +355,15 @@ Scopes are the admin product — `Sessions::Event.failed_logins.last_24_hours.gr
|
|
|
353
355
|
rails generate sessions:madmin
|
|
354
356
|
```
|
|
355
357
|
|
|
356
|
-
…generates the two resources (the
|
|
358
|
+
…generates the two resources (the lifecycle registry with a per-row **Revoke session** action, and the login trail with its triage scopes as filters) plus their controllers, with madmin's two namespacing footguns pre-solved. The generated files use only stock madmin APIs and are yours to restyle.
|
|
359
|
+
|
|
360
|
+
For the per-user security panel that most admin dashboards want (live devices + that user's login trail), run:
|
|
361
|
+
|
|
362
|
+
```bash
|
|
363
|
+
rails generate sessions:madmin --user-panel
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
That adds a reusable `Sessions::MadminUserPanel` concern and `app/views/madmin/users/sessions.html.erb`. Include the concern in `Madmin::UsersController`, add the printed member routes, and add a `member_action` link from your `UserResource`.
|
|
357
367
|
|
|
358
368
|
## 🧹 Retention & the sweep
|
|
359
369
|
|
|
@@ -372,7 +382,7 @@ It purges trail rows past `config.events_retention` (12 months by default — CN
|
|
|
372
382
|
## 🔏 Security & privacy posture
|
|
373
383
|
|
|
374
384
|
- **Tracking never breaks login.** Every adapter path, parser, geo lookup and hook is error-isolated; the test suite includes a chaos test that detonates every pipeline stage at once and asserts sign-in still works.
|
|
375
|
-
- **Tracking outages fail OPEN.** A revoked session is a row
|
|
385
|
+
- **Tracking outages fail OPEN.** A revoked session is a row with an explicit lifecycle end (`ended_reason`); an *errored* lookup (sessions table unreachable, a migration mid-deploy, a timeout) is an outage — the request proceeds untracked instead of logging anyone out. Kicks are scope-precise, too: revoking a user session never touches an admin scope riding the same rack session, or your cart/locale data.
|
|
376
386
|
- **The trail rejects rewrites.** Normal Active Record mutations on events are blocked — `update`/`destroy` raise `ActiveRecord::ReadOnlyRecord`. The callback-bypassing APIs (`update_columns`, `delete_all`) remain available, because the gem's own sanctioned paths use them: async geo backfill, `Sessions.forget`'s GDPR scrub, the retention sweep. Append-only at the model-contract level — not a database constraint, and a host determined to rewrite history still can.
|
|
377
387
|
- **No usable credential is ever persisted.** Devise-mode session tokens are random 32-byte values stored as SHA-256 digests; the raw token lives only in the user's own session. Rails-8-mode rows store nothing secret (the signed cookie is the credential). Nothing secret is ever logged.
|
|
378
388
|
- **Revocation is server-side and immediate** (checked on the very next request, both stacks) — OWASP ASVS 7.4.1; "view and terminate any or all currently active sessions" is literally ASVS 3.3.4 / 7.5.2, the requirement this gem exists to satisfy.
|
|
@@ -418,7 +428,7 @@ Sessions.configure do |config|
|
|
|
418
428
|
config.parent_controller = "::ApplicationController"
|
|
419
429
|
config.current_user_method = :current_user # chain: this → current_user → Current.session&.user
|
|
420
430
|
config.authenticate_method = :authenticate_user!
|
|
421
|
-
config.layout = nil # nil
|
|
431
|
+
config.layout = nil # nil, "app", or ->(controller) { ... }
|
|
422
432
|
config.require_reauthentication = nil # ->(controller) { ... } sudo gate
|
|
423
433
|
config.session_class = "Session"
|
|
424
434
|
config.strategy_methods = {} # { "OtpAuthenticatable" => :otp }
|
|
@@ -427,10 +437,10 @@ end
|
|
|
427
437
|
|
|
428
438
|
## 🧱 Why the models?
|
|
429
439
|
|
|
430
|
-
Two primitives, linked — **rows are
|
|
440
|
+
Two primitives, linked — **rows are lifecycle state; events are history**:
|
|
431
441
|
|
|
432
|
-
- **`sessions`** (the registry — *your* table, Rails-8-shaped on both stacks): one row = one signed-in device.
|
|
433
|
-
- **`sessions_events`** (the trail — gem-owned, append-only): what happened and from where
|
|
442
|
+
- **`sessions`** (the registry — *your* table, Rails-8-shaped on both stacks): one row = one signed-in device lifecycle. `ended_at: nil` means live; logout, revocation, expiry, pruning, and supersede mark the row ended in place with `ended_reason`. Adapters only disconnect a device after the explicit lifecycle state says they should, so a tracking failure cannot silently delete auth state.
|
|
443
|
+
- **`sessions_events`** (the trail — gem-owned, append-only): what happened and from where. Events describe the lifecycle transition; the row itself remains the liveness source of truth. Its `session_id` is a plain column with no foreign key *on purpose*: history and retention must survive row purges, account erasure, and older host tables.
|
|
434
444
|
|
|
435
445
|
On Rails 8 auth, the gem **adopts** the generated table and model: one migration adds columns (the `add_devise_to_users` precedent), and the 2-line `Session` model is decorated via a concern at boot — your generated code stays byte-identical. On Devise, the install generator creates the same Rails-8-shaped table and a 3-line shell model — so if you ever migrate Devise → Rails auth, your sessions table is already exactly where Rails expects it.
|
|
436
446
|
|
|
@@ -112,7 +112,8 @@ module Sessions
|
|
|
112
112
|
end
|
|
113
113
|
|
|
114
114
|
def sessions_layout
|
|
115
|
-
Sessions.config.layout
|
|
115
|
+
configured = Sessions.config.layout
|
|
116
|
+
configured.respond_to?(:call) ? configured.call(self) : configured
|
|
116
117
|
end
|
|
117
118
|
|
|
118
119
|
# The optional sudo gate (ASVS 3.3.4's "having re-entered login
|
|
@@ -141,9 +142,9 @@ module Sessions
|
|
|
141
142
|
def sessions_owner_sessions
|
|
142
143
|
user = sessions_current_user
|
|
143
144
|
if user.respond_to?(:sessions)
|
|
144
|
-
user.sessions
|
|
145
|
+
user.sessions.live
|
|
145
146
|
else
|
|
146
|
-
Sessions.session_model.where(user: user)
|
|
147
|
+
Sessions.session_model.live.where(user: user)
|
|
147
148
|
end
|
|
148
149
|
end
|
|
149
150
|
|
|
@@ -45,6 +45,29 @@ module Sessions
|
|
|
45
45
|
EVENT_ICON_NAMES.fetch(event.event.to_s, "information-circle")
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
+
# Semantic queries for custom/ejected views. Keep liveness and
|
|
49
|
+
# enumeration-safe history semantics in the gem, so host styling changes
|
|
50
|
+
# don't accidentally regress the data contract.
|
|
51
|
+
def sessions_devices_for(user, limit: nil)
|
|
52
|
+
scope = if user.respond_to?(:sessions)
|
|
53
|
+
user.sessions.live.by_recency
|
|
54
|
+
else
|
|
55
|
+
Sessions.session_model.live.where(user: user).by_recency
|
|
56
|
+
end
|
|
57
|
+
limit ? scope.limit(limit) : scope
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def sessions_history_for(user, limit: nil)
|
|
61
|
+
scope = if user.respond_to?(:session_history)
|
|
62
|
+
user.session_history.recent
|
|
63
|
+
elsif user.respond_to?(:session_events)
|
|
64
|
+
user.session_events.recent
|
|
65
|
+
else
|
|
66
|
+
Sessions::Event.where(authenticatable: user).recent
|
|
67
|
+
end
|
|
68
|
+
limit ? scope.limit(limit) : scope
|
|
69
|
+
end
|
|
70
|
+
|
|
48
71
|
# "Active now" within the touch window, else "Active 3 minutes ago".
|
|
49
72
|
def sessions_last_active_in_words(session)
|
|
50
73
|
# active_now? owns the window (config.touch_every) — the badge can't
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
<%# The live device registry — renderable from the engine OR straight from %>
|
|
2
2
|
<%# any host view: render "sessions/devices", user: current_user %>
|
|
3
3
|
<%# Locals: %>
|
|
4
|
-
<%# sessions: the rows to render (defaults
|
|
4
|
+
<%# sessions: the rows to render (defaults via sessions_devices_for)%>
|
|
5
5
|
<%# user: whose devices (only needed when sessions: isn't given)%>
|
|
6
6
|
<%# current_session: the row serving this request (badged, not revocable) %>
|
|
7
7
|
<%
|
|
8
8
|
current_session = local_assigns[:current_session] || Sessions.current(request)
|
|
9
9
|
sessions = local_assigns[:sessions] ||
|
|
10
|
-
local_assigns[:user]
|
|
10
|
+
(sessions_devices_for(local_assigns[:user]).to_a if local_assigns[:user] && respond_to?(:sessions_devices_for)) ||
|
|
11
11
|
[]
|
|
12
12
|
routes = sessions_engine_routes if respond_to?(:sessions_engine_routes)
|
|
13
13
|
%>
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
<%# The login-activity trail — renderable from any host view too: %>
|
|
2
2
|
<%# render "sessions/history", user: current_user, limit: 10 %>
|
|
3
3
|
<%# Locals: %>
|
|
4
|
-
<%# events: the trail rows (defaults
|
|
4
|
+
<%# events: the trail rows (defaults via sessions_history_for) %>
|
|
5
5
|
<%# user: whose history (only needed when events: isn't given) %>
|
|
6
6
|
<%# limit: cap when deriving from user (default 10) %>
|
|
7
7
|
<%
|
|
8
8
|
events = local_assigns[:events] ||
|
|
9
|
-
local_assigns[:user]
|
|
9
|
+
(sessions_history_for(local_assigns[:user], limit: local_assigns.fetch(:limit, 10)) if local_assigns[:user] && respond_to?(:sessions_history_for)) ||
|
|
10
10
|
[]
|
|
11
11
|
%>
|
|
12
12
|
<% if events.respond_to?(:empty?) && events.empty? %>
|