sessions 0.2.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 10c90937b69181aefb74b594f97b4f0142d75148aac755cb3e180866df74e249
4
- data.tar.gz: 94219c51f790720e0bff485db1242f288b48161ffb5adab0bf066be22d189a30
3
+ metadata.gz: 6ead91af5a7b6b9818df58d3c068ce4d59e09a5b865e80373ba2e7b7c5854e75
4
+ data.tar.gz: 6ad52d4780745c525de40a76cb462e5cedc87b3f54dfff7f9d803f69ad9f0033
5
5
  SHA512:
6
- metadata.gz: 978aea94d42c6259b8add5117692fbfd28ef071b5c9a078ec45900173bc76041481cba36433d7ce6eaf2d6dafa2d7cf1365e92c2b55a6c0946f19ca212d3c8dd
7
- data.tar.gz: 4d956731a73bbc7750f67161f6a69acc9e7befa96c0ad8b5e02bfb7ccedbac4002cc2fa39b43ab0643c07a062c0d3e84315757d72ccac0a291ca1a0ad3cb09a2
6
+ metadata.gz: f32fb15dbbef609551071da2de77a4b6c0f9117e496a2405548d87a5fcddad11bb14b1e7c738e0bdd70112634ad92d7b5a1ecf3fb041b4b2d3420e8d0fba3e3b
7
+ data.tar.gz: fffa0d52b6c6353b1d326f0a76c7a3e49ea8bac0f3bf8b160923675c99bfa9d7a12db4f85e3120a532b677e4539fedb90b5aa80917eb88f6c87455699d52726e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
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
+
3
25
  ## 0.2.0 (2026-06-21)
4
26
 
5
27
  Lifecycle-row rewrite and auth-safety correction for the Hotwire Native / Devise remember-me hardening introduced in 0.1.3.
data/README.md CHANGED
@@ -98,7 +98,7 @@ That's it. Every sign-in from now on lands on the devices page and in the trail
98
98
 
99
99
  ### Upgrading
100
100
 
101
- Existing apps upgrading from 0.1.0, 0.1.1, or 0.1.2 should copy the upgrade migrations and run them:
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
@@ -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 your own 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.
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 live row it created nil once revoked (that's the point)
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
- user = User.find_by(email: identity) or next # identity is AS TYPED may match no account
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
 
@@ -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 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. For a per-user security panel (devices + trail on the user's show page), load `user.sessions.live.by_recency` and `user.session_events.recent` in a member action — including the user's *failed* attempts by matching `Sessions::Event.where(identity: Sessions::Event.normalize_identity(user.email))` (failures never link to accounts; matching the signed-in user's own identity is the safe way to show them).
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
 
@@ -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 inherits the parent controller's layout
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 }
@@ -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
@@ -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 to user.sessions) %>
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]&.sessions&.live&.by_recency&.to_a ||
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 to user.session_events.recent) %>
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]&.session_events&.recent&.limit(local_assigns.fetch(:limit, 10)) ||
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? %>
@@ -20,6 +20,9 @@ module Sessions
20
20
 
21
21
  desc "Generate Madmin resources + controllers for sessions and the login trail"
22
22
 
23
+ class_option :user_panel, type: :boolean, default: false,
24
+ desc: "Also generate a per-user Sessions panel concern and view"
25
+
23
26
  def check_for_madmin!
24
27
  return if madmin_available?
25
28
 
@@ -43,6 +46,13 @@ module Sessions
43
46
  template "session_events_controller.rb", "app/controllers/madmin/session_events_controller.rb"
44
47
  end
45
48
 
49
+ def create_user_panel
50
+ return unless options[:user_panel]
51
+
52
+ copy_file "user_panel/madmin_user_panel.rb", "app/controllers/concerns/sessions/madmin_user_panel.rb"
53
+ copy_file "user_panel/sessions.html.erb", "app/views/madmin/users/sessions.html.erb"
54
+ end
55
+
46
56
  def display_post_install_message
47
57
  say "\n🔐 Madmin resources for sessions installed.", :green
48
58
  say "\nTo complete the setup:"
@@ -70,9 +80,15 @@ module Sessions
70
80
  say " end"
71
81
 
72
82
  say "\n 3. (Optional) For a per-user panel (devices + trail on the user's"
73
- say " show page), add a member action to your users controller that"
74
- say " loads `user.sessions.live.by_recency` and `user.session_history.recent`"
75
- say " — the README's Admin section has the full recipe."
83
+ say " show page), re-run with --user-panel, include"
84
+ say " `Sessions::MadminUserPanel` in Madmin::UsersController, and add:"
85
+ say ""
86
+ say " resources :users do"
87
+ say " member do"
88
+ say " get :sessions"
89
+ say " post :revoke_all_sessions"
90
+ say " end"
91
+ say " end"
76
92
 
77
93
  say "\nRevoking from the index ends the row in place: that device is signed out"
78
94
  say "on its very next matching request, and the revocation lands in the trail"
@@ -23,10 +23,9 @@ class AddSessionsLifecycleTo<%= table_name.camelize %> < ActiveRecord::Migration
23
23
  def reference_column_type
24
24
  config = Rails.configuration.generators
25
25
  case config.options[config.orm][:primary_key_type]
26
- when :uuid then :uuid
27
- when :string then :string
28
- when :integer, :serial then :integer
29
- else :bigint
26
+ when nil, :bigserial then :bigint
27
+ when :serial then :integer
28
+ else config.options[config.orm][:primary_key_type]
30
29
  end
31
30
  end
32
31
 
@@ -70,7 +70,9 @@ class AddSessionsColumnsTo<%= table_name.camelize %> < ActiveRecord::Migration<%
70
70
  # truth for whether a session should still be accepted.
71
71
  t.datetime :ended_at
72
72
  t.string :ended_reason
73
- t.references :ended_by, polymorphic: true, type: foreign_key_type
73
+ # The explicit index below keeps the generated name consistent across
74
+ # fresh installs and upgrades. Avoid the implicit duplicate index.
75
+ t.references :ended_by, polymorphic: true, type: foreign_key_type, index: false
74
76
  t.send(json_column_type, :ended_metadata)
75
77
  end
76
78
 
@@ -78,7 +78,10 @@ class Create<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_ve
78
78
  # thing a Devise/Warden request is allowed to enforce.
79
79
  t.datetime :ended_at
80
80
  t.string :ended_reason
81
- t.references :ended_by, polymorphic: true, type: foreign_key_type
81
+ # The explicit index below keeps the generated name consistent across
82
+ # create-table and upgrade paths. `references` indexes by default, so
83
+ # disable that implicit index to avoid two identical indexes.
84
+ t.references :ended_by, polymorphic: true, type: foreign_key_type, index: false
82
85
  t.send(json_column_type, :ended_metadata)
83
86
 
84
87
  t.timestamps
@@ -136,11 +136,12 @@ Sessions.configure do |config|
136
136
  # end
137
137
  #
138
138
  # Someone crossed the repeated_failed_logins threshold (see BEHAVIOR
139
- # above). The identity is the email AS TYPED it may match no account,
140
- # so resolve it yourself before notifying:
139
+ # above). The event still stores no account link for failures; user: is a
140
+ # best-effort in-memory resolution for this hook and nil for unknown
141
+ # identities:
141
142
  #
142
- # config.on_repeated_failed_logins = ->(identity:, count:, event:) do
143
- # user = User.find_by(email: identity) or next
143
+ # config.on_repeated_failed_logins = ->(identity:, count:, event:, user:) do
144
+ # next unless user
144
145
  # SecurityMailer.with(user: user, event: event).repeated_failed_logins.deliver_later
145
146
  # end
146
147
  #
@@ -172,10 +173,10 @@ Sessions.configure do |config|
172
173
  # config.authenticate_method = :authenticate_user!
173
174
  #
174
175
  # Render the devices page with a specific layout (nil inherits whatever
175
- # your parent controller uses set this if your signed-in surfaces use a
176
- # different layout, e.g. "app"):
176
+ # your parent controller uses). Use a callable when web/native shells differ:
177
177
  #
178
178
  # config.layout = nil
179
+ # config.layout = ->(controller) { controller.hotwire_native_app? ? "app" : "web" }
179
180
  #
180
181
  # Optional sudo gate before destructive actions on the devices page (ASVS
181
182
  # 3.3.4's "having re-entered login credentials"). The action runs only
@@ -2,8 +2,9 @@
2
2
 
3
3
  # Device lifecycle registry (sessions gem): one row = one signed-in device.
4
4
  # `ended_at: nil` is the live set that can act on your app right now; ended
5
- # rows stay around as durable state/audit context until retention or account
6
- # erasure removes them. The append-only history lives in Sessions::Event.
5
+ # rows stay around as durable revocation state until account erasure or
6
+ # explicit host cleanup removes them. The append-only history lives in
7
+ # Sessions::Event.
7
8
  class SessionResource < Madmin::Resource
8
9
  model <%= session_class %>
9
10
 
@@ -65,10 +66,12 @@ class SessionResource < Madmin::Resource
65
66
  # attributed to the admin, and rotates the user's remember-me credentials
66
67
  # in Devise mode. The device is signed out on its next matching request.
67
68
  member_action do
68
- button_to "Revoke session",
69
- main_app.revoke_madmin_session_path(@record),
70
- method: :post,
71
- class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-red-700 shadow-sm ring-1 ring-inset ring-red-300 hover:bg-red-50",
72
- data: { turbo_confirm: "Revoke this session? The device will be signed out on its next request." }
69
+ if @record.live?
70
+ button_to "Revoke session",
71
+ main_app.revoke_madmin_session_path(@record),
72
+ method: :post,
73
+ class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-red-700 shadow-sm ring-1 ring-inset ring-red-300 hover:bg-red-50",
74
+ data: { turbo_confirm: "Revoke this session? The device will be signed out on its next request." }
75
+ end
73
76
  end
74
77
  end
@@ -10,12 +10,16 @@ module Madmin
10
10
  session_row = <%= session_class %>.find(params[:id])
11
11
  device = session_row.device_name
12
12
 
13
- session_row.revoke!(reason: :admin_revoked, by: current_user)
14
- flash[:notice] = "Session revoked (#{device}). The device will be signed out on its next request."
13
+ if session_row.live?
14
+ session_row.revoke!(reason: :admin_revoked, by: current_user)
15
+ flash[:notice] = "Session revoked (#{device}). The device will be signed out on its next request."
16
+ else
17
+ flash[:alert] = "That session had already ended (#{device}). There is no live device to sign out."
18
+ end
15
19
 
16
20
  redirect_back fallback_location: main_app.madmin_sessions_path
17
21
  rescue ActiveRecord::RecordNotFound
18
- flash[:alert] = "That session no longer exists (probably already revoked)."
22
+ flash[:alert] = "That session no longer exists."
19
23
  redirect_back fallback_location: main_app.madmin_sessions_path
20
24
  end
21
25
 
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sessions
4
+ # Optional per-user Madmin panel actions. Include this in
5
+ # Madmin::UsersController, then draw the routes printed by
6
+ # `rails generate sessions:madmin --user-panel`.
7
+ module MadminUserPanel
8
+ extend ActiveSupport::Concern
9
+
10
+ SESSION_EVENTS_PER_PAGE = 30
11
+
12
+ def sessions
13
+ @user = User.find(params[:id])
14
+ @sessions = @user.sessions.live.by_recency.to_a
15
+ events = @user.session_history.recent
16
+
17
+ @failed_last_24h = events.failed_logins.last_24_hours.count
18
+ if respond_to?(:pagy, true)
19
+ @pagy, @events = pagy(events, limit: SESSION_EVENTS_PER_PAGE)
20
+ else
21
+ @events = events.limit(SESSION_EVENTS_PER_PAGE)
22
+ end
23
+ rescue StandardError => e
24
+ raise unless defined?(::Pagy::OptionError) && e.is_a?(::Pagy::OptionError)
25
+
26
+ @pagy, @events = pagy(events, page: 1, limit: SESSION_EVENTS_PER_PAGE)
27
+ end
28
+
29
+ # Account-takeover response: revoke every live row with admin attribution.
30
+ # Each row writes its own `revoked` event and, in Devise mode, rotates
31
+ # remember-me credentials so a long-lived cookie cannot revive the device.
32
+ def revoke_all_sessions
33
+ user = User.find(params[:id])
34
+ count = user.sessions.live.count
35
+
36
+ user.revoke_all_sessions!(by: current_user)
37
+ flash[:notice] = "#{count} #{"session".pluralize(count)} revoked. " \
38
+ "Every device will be signed out on its next request."
39
+
40
+ redirect_to main_app.sessions_madmin_user_path(user)
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,133 @@
1
+ <% content_for :title, "Sessions - #{@user.try(:email) || @user.try(:email_address) || @user.id}" %>
2
+
3
+ <div class="mx-auto w-full max-w-6xl px-4 py-6">
4
+ <header class="mb-6 flex flex-wrap items-center justify-between gap-4">
5
+ <div>
6
+ <p class="text-sm text-gray-500">
7
+ <%= link_to "Users", madmin_users_path, class: "hover:text-gray-700" %>
8
+ <span class="mx-1 text-gray-400">/</span>
9
+ <%= link_to @user.try(:email) || @user.try(:email_address) || @user.id, madmin_user_path(@user), class: "hover:text-gray-700" %>
10
+ </p>
11
+ <h1 class="mt-1 text-2xl font-semibold text-gray-900">Sessions &amp; devices</h1>
12
+ </div>
13
+
14
+ <div class="flex items-center gap-2">
15
+ <%= link_to "Back to user", madmin_user_path(@user),
16
+ class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-700 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
17
+ <% if @sessions.any? %>
18
+ <%= button_to "Revoke all sessions", revoke_all_sessions_madmin_user_path(@user),
19
+ method: :post,
20
+ class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-red-700 shadow-sm ring-1 ring-inset ring-red-300 hover:bg-red-50",
21
+ data: { turbo_confirm: "Revoke ALL #{@sessions.size} sessions? Every device will be signed out on its next request." } %>
22
+ <% end %>
23
+ </div>
24
+ </header>
25
+
26
+ <div class="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-3">
27
+ <div class="rounded-lg border border-gray-200 bg-white p-4">
28
+ <p class="text-xs font-medium uppercase text-gray-500">Live sessions</p>
29
+ <p class="mt-1 text-2xl font-semibold text-gray-900"><%= @sessions.size %></p>
30
+ </div>
31
+ <div class="rounded-lg border border-gray-200 bg-white p-4">
32
+ <p class="text-xs font-medium uppercase text-gray-500">Last activity</p>
33
+ <p class="mt-1 text-2xl font-semibold text-gray-900">
34
+ <% if (latest = @sessions.filter_map(&:last_active_at).max) %>
35
+ <%= time_ago_in_words(latest) %> ago
36
+ <% else %>
37
+ -
38
+ <% end %>
39
+ </p>
40
+ </div>
41
+ <div class="rounded-lg border border-gray-200 bg-white p-4">
42
+ <p class="text-xs font-medium uppercase text-gray-500">Failed logins (24h)</p>
43
+ <p class="mt-1 text-2xl font-semibold <%= @failed_last_24h.positive? ? "text-red-700" : "text-gray-900" %>">
44
+ <%= @failed_last_24h %>
45
+ </p>
46
+ </div>
47
+ </div>
48
+
49
+ <section class="mb-6 overflow-hidden rounded-lg border border-gray-200 bg-white">
50
+ <header class="border-b border-gray-200 px-6 py-4">
51
+ <h2 class="text-base font-semibold text-gray-900">Live devices</h2>
52
+ </header>
53
+
54
+ <% if @sessions.any? %>
55
+ <ul class="divide-y divide-gray-100">
56
+ <% @sessions.each do |session_row| %>
57
+ <li class="flex items-start justify-between gap-4 px-6 py-4">
58
+ <div class="min-w-0">
59
+ <p class="text-sm font-medium text-gray-900">
60
+ <%= link_to session_row.device_name, madmin_session_path(session_row), class: "hover:underline" %>
61
+ </p>
62
+ <p class="mt-1 text-xs text-gray-500">
63
+ <% bits = [] %>
64
+ <% bits << session_row.location if session_row.location %>
65
+ <% bits << "IP #{session_row.last_seen_ip || session_row.ip_address}" if session_row.last_seen_ip || session_row.ip_address %>
66
+ <% bits << "via #{session_row.auth_method_label}" if session_row.auth_method_label %>
67
+ <% bits << "last seen #{time_ago_in_words(session_row.last_active_at)} ago" if session_row.last_active_at %>
68
+ <%= bits.join(" - ") %>
69
+ </p>
70
+ </div>
71
+
72
+ <%= button_to "Revoke", revoke_madmin_session_path(session_row),
73
+ method: :post,
74
+ class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-red-700 shadow-sm ring-1 ring-inset ring-red-300 hover:bg-red-50",
75
+ data: { turbo_confirm: "Revoke #{session_row.device_name}?" } %>
76
+ </li>
77
+ <% end %>
78
+ </ul>
79
+ <% else %>
80
+ <div class="px-6 py-10 text-center text-sm text-gray-500">No live sessions.</div>
81
+ <% end %>
82
+ </section>
83
+
84
+ <section class="overflow-hidden rounded-lg border border-gray-200 bg-white">
85
+ <header class="border-b border-gray-200 px-6 py-4">
86
+ <h2 class="text-base font-semibold text-gray-900">Login activity</h2>
87
+ </header>
88
+
89
+ <% if @events.any? %>
90
+ <div class="overflow-x-auto">
91
+ <table class="min-w-full divide-y divide-gray-200 text-sm">
92
+ <thead class="bg-gray-50 text-xs uppercase text-gray-500">
93
+ <tr>
94
+ <th class="px-4 py-2 text-left font-medium">When</th>
95
+ <th class="px-4 py-2 text-left font-medium">Event</th>
96
+ <th class="px-4 py-2 text-left font-medium">Device</th>
97
+ <th class="px-4 py-2 text-left font-medium">IP / Location</th>
98
+ <th class="px-4 py-2 text-left font-medium">Via</th>
99
+ </tr>
100
+ </thead>
101
+ <tbody class="divide-y divide-gray-100">
102
+ <% @events.each do |event| %>
103
+ <tr>
104
+ <td class="whitespace-nowrap px-4 py-3 align-top">
105
+ <%= l(event.occurred_at, format: :short) %>
106
+ </td>
107
+ <td class="px-4 py-3 align-top">
108
+ <%= event.label %>
109
+ <% if event.reason %>
110
+ <div class="text-xs text-gray-500"><%= event.reason_label %></div>
111
+ <% end %>
112
+ </td>
113
+ <td class="px-4 py-3 align-top"><%= event.device_name %></td>
114
+ <td class="whitespace-nowrap px-4 py-3 align-top">
115
+ <%= [ event.ip_address, event.location ].compact.join(" - ").presence || "-" %>
116
+ </td>
117
+ <td class="px-4 py-3 align-top"><%= event.auth_method_label || "-" %></td>
118
+ </tr>
119
+ <% end %>
120
+ </tbody>
121
+ </table>
122
+ </div>
123
+
124
+ <% if defined?(@pagy) && @pagy.respond_to?(:last) && @pagy.last > 1 %>
125
+ <div class="border-t border-gray-100 bg-gray-50 px-6 py-3">
126
+ <%= render "madmin/shared/pagination", pagy: @pagy %>
127
+ </div>
128
+ <% end %>
129
+ <% else %>
130
+ <div class="px-6 py-10 text-center text-sm text-gray-500">No login activity.</div>
131
+ <% end %>
132
+ </section>
133
+ </div>
@@ -202,12 +202,21 @@ module Sessions
202
202
  # v0.1.3 intentionally reattached remember-me restores to an
203
203
  # existing device row without writing another login event, storing
204
204
  # [row_id, nil] in Warden. That is fine as a tracking hint, but it
205
- # must never become an auth/liveness check. Touch when the signed
206
- # browser-continuity cookie still agrees; otherwise clear only the
207
- # gem's tracking key and let Devise/Rails keep owning auth.
205
+ # must never become an auth/liveness check FROM ABSENCE: missing
206
+ # rows, mismatched cookies and quiet housekeeping all fail open.
207
+ # An EXPLICIT ending is different when the authenticated owner,
208
+ # the signed browser-continuity cookie and the row all agree this
209
+ # is the same device, and that row was deliberately ended
210
+ # (admin/user revoke, sign-out-everywhere), the revocation must
211
+ # mean what the UI promised: signed out on its next request.
212
+ # Housekeeping reasons (superseded) never kick and fall through
213
+ # to the live-replacement reattach below.
208
214
  # Source: https://github.com/rameerez/sessions/blob/v0.1.3/CHANGELOG.md
209
- if existing_row_session?(found, record, scope, warden.request) && found.live?
215
+ same_device = existing_row_session?(found, record, scope, warden.request)
216
+ if same_device && found.live?
210
217
  Sessions.safely("warden.remembered_existing.touch") { found.touch_last_seen!(warden.request) }
218
+ elsif same_device && found.sessions_kicks_on_resume?
219
+ kick!(warden, scope)
211
220
  elsif (replacement = live_replacement_for(found, record, scope, warden.request))
212
221
  attach_existing_row(replacement, warden, scope)
213
222
  else
@@ -181,7 +181,7 @@ module Sessions
181
181
  # whatever layout the parent controller resolves — usually the host's
182
182
  # `application` layout. Set it when your signed-in surfaces render with
183
183
  # a different one (e.g. "app").
184
- attr_accessor :layout
184
+ attr_reader :layout
185
185
 
186
186
  # ->(controller) — optional sudo gate run before destructive actions on
187
187
  # the devices page (ASVS 3.3.4's "having re-entered login credentials").
@@ -377,6 +377,15 @@ module Sessions
377
377
  @parent_controller = name
378
378
  end
379
379
 
380
+ def layout=(value)
381
+ if value.nil? || value.respond_to?(:call) || value.is_a?(String) || value.is_a?(Symbol)
382
+ @layout = value
383
+ return
384
+ end
385
+
386
+ raise ConfigurationError, "layout must be a String, Symbol, callable, or nil, got #{value.inspect}"
387
+ end
388
+
380
389
  def require_reauthentication=(value)
381
390
  if value.nil?
382
391
  @require_reauthentication = nil
@@ -179,11 +179,14 @@ module Sessions
179
179
  .count
180
180
  return unless count == config[:threshold]
181
181
 
182
+ user = Sessions.user_for_identity(event.identity, scope: event.scope)
183
+
182
184
  Sessions.safely("on_repeated_failed_logins hook") do
183
- Sessions.config.on_repeated_failed_logins.call(
185
+ call_repeated_failed_logins_hook(
184
186
  identity: event.identity,
185
187
  count: count,
186
- event: event
188
+ event: event,
189
+ user: user
187
190
  )
188
191
  end
189
192
  end
@@ -199,6 +202,30 @@ module Sessions
199
202
  normalized.empty? ? nil : normalized
200
203
  end
201
204
 
205
+ def call_repeated_failed_logins_hook(**kwargs)
206
+ hook = Sessions.config.on_repeated_failed_logins
207
+ params = hook_keyword_parameters(hook)
208
+ return hook.call(**kwargs) if params.nil?
209
+ return hook.call(**kwargs) if params.any? { |type, _name| type == :keyrest }
210
+
211
+ accepted = params.select { |type, _name| %i[key keyreq].include?(type) }
212
+ .filter_map { |_type, name| name }
213
+ hook.call(**kwargs.slice(*accepted))
214
+ end
215
+
216
+ # Procs/lambdas/Method objects expose #parameters directly; a plain
217
+ # callable object (a class with #call) only exposes them through
218
+ # #method(:call). nil means "cannot introspect" — dispatch everything
219
+ # and let the hook decide.
220
+ def hook_keyword_parameters(hook)
221
+ return hook.parameters if hook.respond_to?(:parameters)
222
+ return hook.method(:call).parameters if hook.respond_to?(:call)
223
+
224
+ nil
225
+ rescue StandardError
226
+ nil
227
+ end
228
+
202
229
  def context_for(request)
203
230
  params = request.respond_to?(:path_parameters) ? request.path_parameters : nil
204
231
  return nil unless params && params[:controller]
@@ -221,8 +248,9 @@ module Sessions
221
248
  authenticatable
222
249
  end
223
250
 
224
- # The live registry row this event points at nil once it's been
225
- # revoked/logged out (that's the point of the trail).
251
+ # The lifecycle registry row this event points at. In v0.2 an ended row
252
+ # remains available as explicit revocation state; nil means the host later
253
+ # hard-deleted it (for example during account erasure).
226
254
  def session
227
255
  return nil if session_id.nil?
228
256
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sessions
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.2"
5
5
  end
data/lib/sessions.rb CHANGED
@@ -94,6 +94,27 @@ module Sessions
94
94
  config.session_model
95
95
  end
96
96
 
97
+ # Resolve a typed login identity to an account for host hooks. Failed
98
+ # login events still do NOT persist an account link (enumeration safety);
99
+ # this is only an in-process convenience for notification hooks that are
100
+ # already allowed to know whether the account exists.
101
+ def user_for_identity(identity, scope: nil)
102
+ normalized = Sessions::Event.normalize_identity(identity)
103
+ return nil if normalized.blank?
104
+
105
+ safely("user_for_identity") do
106
+ model = user_model_for_identity(scope)
107
+ next nil unless model.respond_to?(:column_names)
108
+
109
+ column = %w[email email_address login username phone].find { |name| model.column_names.include?(name) }
110
+ next nil unless column
111
+
112
+ table = model.quoted_table_name
113
+ quoted_column = model.connection.quote_column_name(column)
114
+ model.where("LOWER(#{table}.#{quoted_column}) = ?", normalized).first
115
+ end
116
+ end
117
+
97
118
  # --- Request-side API -----------------------------------------------------
98
119
 
99
120
  # Label the login that's about to happen on this request — for flows
@@ -312,6 +333,20 @@ module Sessions
312
333
 
313
334
  private
314
335
 
336
+ def user_model_for_identity(scope)
337
+ if scope && defined?(::Devise) && ::Devise.respond_to?(:mappings)
338
+ mapping = ::Devise.mappings[scope.to_sym] || ::Devise.mappings[scope.to_s]
339
+ return mapping.to if mapping.respond_to?(:to)
340
+ end
341
+
342
+ reflection = session_model.reflect_on_association(:user)
343
+ return nil if reflection&.polymorphic?
344
+
345
+ reflection&.klass
346
+ rescue StandardError
347
+ nil
348
+ end
349
+
315
350
  def omakase_current(_request)
316
351
  return nil unless defined?(::Current) && ::Current.respond_to?(:session)
317
352
 
@@ -329,10 +364,22 @@ module Sessions
329
364
  next unless key.to_s.match?(pattern) && value.is_a?(Hash)
330
365
 
331
366
  tracking = Adapters::Warden.parse_tracking_state(value[Adapters::Warden::SESSION_KEY])
332
- next unless tracking && tracking[:mode] == "credential"
367
+ next unless tracking
333
368
 
334
369
  row = session_model.live.find_by(id: tracking[:id])
335
- return row if row.respond_to?(:sessions_token_matches?) && row&.sessions_token_matches?(tracking[:token])
370
+ next unless row
371
+
372
+ case tracking[:mode]
373
+ when "credential"
374
+ return row if row.respond_to?(:sessions_token_matches?) && row.sessions_token_matches?(tracking[:token])
375
+ when "hint"
376
+ # Tokenless remembered reattach: the tracking state is written
377
+ # server-side, but corroborate with the signed browser-continuity
378
+ # cookie before answering "this is the current device" — the same
379
+ # proof the adapter demands before touching or kicking a hint row.
380
+ device_id = Adapters::Warden.device_id_from_request(request)
381
+ return row if device_id.present? && device_id == row.try(:device_id)
382
+ end
336
383
  end
337
384
  nil
338
385
  rescue StandardError
@@ -362,7 +409,13 @@ module Sessions
362
409
 
363
410
  count = 0
364
411
  expired_sessions_scope.find_each do |session|
365
- session.revoke!(reason: :expired)
412
+ # Expiry is timeout POLICY, not a revocation: end the row (writes the
413
+ # `expired` audit event; `expired` kicks on resume) but never rotate
414
+ # the account-wide remember-me credential or fire on_session_revoked.
415
+ # This matches the adapter's inline expiry and Devise's timeoutable,
416
+ # where remember-me revival after a timeout is the host's own
417
+ # rememberable policy — not ours to nuke for every other device.
418
+ session.end!(reason: :expired)
366
419
  count += 1
367
420
  end
368
421
  count
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.2.0
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-06-21 00:00:00.000000000 Z
10
+ date: 2026-07-06 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: actionpack
@@ -73,14 +73,14 @@ dependencies:
73
73
  name: browser
74
74
  requirement: !ruby/object:Gem::Requirement
75
75
  requirements:
76
- - - ">="
76
+ - - "~>"
77
77
  - !ruby/object:Gem::Version
78
78
  version: '6.0'
79
79
  type: :runtime
80
80
  prerelease: false
81
81
  version_requirements: !ruby/object:Gem::Requirement
82
82
  requirements:
83
- - - ">="
83
+ - - "~>"
84
84
  - !ruby/object:Gem::Version
85
85
  version: '6.0'
86
86
  - !ruby/object:Gem::Dependency
@@ -174,6 +174,8 @@ files:
174
174
  - lib/generators/sessions/templates/madmin/session_events_controller.rb
175
175
  - lib/generators/sessions/templates/madmin/session_resource.rb
176
176
  - lib/generators/sessions/templates/madmin/sessions_controller.rb
177
+ - lib/generators/sessions/templates/madmin/user_panel/madmin_user_panel.rb
178
+ - lib/generators/sessions/templates/madmin/user_panel/sessions.html.erb
177
179
  - lib/generators/sessions/templates/session.rb.erb
178
180
  - lib/generators/sessions/templates/sessions_sweep_job.rb
179
181
  - lib/generators/sessions/upgrade_generator.rb