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 +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +18 -8
- data/app/controllers/sessions/application_controller.rb +2 -1
- 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/lib/generators/sessions/madmin_generator.rb +19 -3
- data/lib/generators/sessions/templates/add_lifecycle_to_sessions.rb.erb +3 -4
- data/lib/generators/sessions/templates/add_sessions_columns.rb.erb +3 -1
- data/lib/generators/sessions/templates/create_sessions.rb.erb +4 -1
- data/lib/generators/sessions/templates/initializer.rb +7 -6
- data/lib/generators/sessions/templates/madmin/session_resource.rb +10 -7
- data/lib/generators/sessions/templates/madmin/sessions_controller.rb +7 -3
- 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/sessions/adapters/warden.rb +13 -4
- data/lib/sessions/configuration.rb +10 -1
- data/lib/sessions/models/event.rb +32 -4
- data/lib/sessions/version.rb +1 -1
- data/lib/sessions.rb +56 -3
- metadata +6 -4
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,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
|
|
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
|
|
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
|
|
|
@@ -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.
|
|
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
|
|
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
|
|
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? %>
|
|
@@ -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),
|
|
74
|
-
say "
|
|
75
|
-
say "
|
|
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 :
|
|
27
|
-
when :
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
140
|
-
#
|
|
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
|
-
#
|
|
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
|
|
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
|
|
6
|
-
#
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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.
|
|
14
|
-
|
|
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
|
|
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 & 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
|
|
206
|
-
#
|
|
207
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
225
|
-
#
|
|
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
|
|
data/lib/sessions/version.rb
CHANGED
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
|
|
367
|
+
next unless tracking
|
|
333
368
|
|
|
334
369
|
row = session_model.live.find_by(id: tracking[:id])
|
|
335
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|