sessions 0.2.0 → 0.3.0

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: b7cca6c52bb926f1a07896ba44ec9cf3abba216dd8d49793febc1a405b27e6e1
4
+ data.tar.gz: 00770fa40d62a4e305df2b8a8530519873368fe097359d64d92fc8a19b522897
5
5
  SHA512:
6
- metadata.gz: 978aea94d42c6259b8add5117692fbfd28ef071b5c9a078ec45900173bc76041481cba36433d7ce6eaf2d6dafa2d7cf1365e92c2b55a6c0946f19ca212d3c8dd
7
- data.tar.gz: 4d956731a73bbc7750f67161f6a69acc9e7befa96c0ad8b5e02bfb7ccedbac4002cc2fa39b43ab0643c07a062c0d3e84315757d72ccac0a291ca1a0ad3cb09a2
6
+ metadata.gz: a3c27ee309a2d4a74c78e05b573068af4d5736cdaccf23e19f36565d58349ad550a344da601b1ce71c601e917c2c311405756b2af6cd7f314aeedc07502251c1
7
+ data.tar.gz: 4bd16a0366a8cdd01f69b426af63959d58f6e847b722e12f696ab196d3c9da3a540cec6ff6a7155040a51a823a9ba029f75828d04ffbb36f682b6bd9ed4687a8
data/Appraisals CHANGED
@@ -7,16 +7,22 @@
7
7
  # fine on 7.1 because the concern only uses 7.1-era APIs).
8
8
  appraise "rails-7.1" do
9
9
  gem "rails", "~> 7.1.0"
10
+ # json 3 removed the quirks_mode option that ActiveSupport before 8.1 passes.
11
+ gem "json", "< 3"
10
12
  end
11
13
 
12
14
  appraise "rails-7.2" do
13
15
  gem "rails", "~> 7.2.0"
16
+ # json 3 removed the quirks_mode option that ActiveSupport before 8.1 passes.
17
+ gem "json", "< 3"
14
18
  end
15
19
 
16
20
  # The version that shipped `rails generate authentication` — the substrate
17
21
  # this gem decorates.
18
22
  appraise "rails-8.0" do
19
23
  gem "rails", "~> 8.0.0"
24
+ # json 3 removed the quirks_mode option that ActiveSupport before 8.1 passes.
25
+ gem "json", "< 3"
20
26
  end
21
27
 
22
28
  # The latest Rails — also adds the rate_limit.action_controller notification
data/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 (2026-10-18)
4
+
5
+ Adds re-authentication assertions. This release is additive: it needs no migration, and existing installs change nothing until they call the new API.
6
+
7
+ - **Record a fresh re-authentication on the session row.** Call `session.reauthenticated!(method: :password)` after your own credential check succeeds. The gem never checks credentials. The state lives in `auth_detail`, so no migration is needed. `session.reauthenticated_within?(5.minutes)` tells whether it is recent. `session.reauthenticated_at` and `session.reauthentication_method` read it back. An ended or expired session is never recent. Both methods reload the row under a lock, so a revocation in another process counts.
8
+ - **Issue a short-lived signed assertion of it.** `session.reauthentication_assertion(audience:, purpose:)` returns a signed token for one audience and one purpose. It raises `Sessions::Reauthentication::NotRecent` when the re-authentication is not recent.
9
+ - **Verify the assertion in another service.** `Sessions::Reauthentication.verify(token, audience:, key:, subject:, purpose:, max_age:)` returns an `Assertion` or `nil`. The other service needs only the shared key and `require "sessions/reauthentication"`: no database, no cookie, no models. `verify` refuses another key, audience, subject or purpose, a changed or expired token, a timestamp more than 30 seconds in the future, and a re-authentication older than `max_age`.
10
+ - **Subjects carry the owner class.** A subject is `"<owner class>:<id>"`, so owners of two classes with the same id never match. A stored polymorphic type is resolved to its full class name, also when `store_full_class_name` is false.
11
+ - **Replay protection is possible.** Each assertion has a unique `jti` and an `expires_at`. To refuse replays, record each accepted `jti` until `expires_at`.
12
+ - **Opt-in controller helpers.** `include Sessions::ReauthenticationHelpers` gives `record_sessions_reauthentication!(owner, method:)`, `sessions_reauthenticated?(owner)` and `sessions_reauthentication_assertion(owner, audience:, purpose:)`. They act only on a current session of the named owner. Use `scope:` for apps with more than one Warden scope.
13
+ - **New configuration.** `reauthentication_key` (a String of 32 bytes or more, or a callable), `reauthentication_window` (default 5 minutes) and `reauthentication_assertion_ttl` (default 1 minute).
14
+
15
+ ## 0.2.2 (2026-07-06)
16
+
17
+ Closes the revocation-semantics gaps found auditing the 0.2.x lifecycle model against production data.
18
+
19
+ - **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).
20
+ - **`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.
21
+ - **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.
22
+ - **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.
23
+
24
+ ## 0.2.1 (2026-07-03)
25
+
26
+ - Prevent fresh-install and adopted-table migrations from creating the `ended_by` polymorphic index twice.
27
+ - Preserve custom string/ULID primary-key types in the 0.2 lifecycle upgrade migration.
28
+ - Make generated Madmin revocation controls lifecycle-aware so ended rows cannot report a second successful revoke.
29
+ - Correct the 0.2 documentation for `event.session`: it resolves retained lifecycle rows after logout/revocation and becomes `nil` only after hard deletion.
30
+ - Document that Devise's `bypass_sign_in` skips the Warden callbacks required to register a new session.
31
+ - Bound the device parser dependency to the tested `browser` 6.x API line.
32
+ - Let `config.layout` be a callable (`->(controller) { ... }`) for hosts that switch layouts between web and native shells.
33
+ - Pass `user:` (resolved best-effort, never persisted on the failed event) to `on_repeated_failed_logins` when the hook opts into the keyword.
34
+ - 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.
35
+ - Add `rails generate sessions:madmin --user-panel` for the repeated per-user admin security panel concern/view.
36
+
3
37
  ## 0.2.0 (2026-06-21)
4
38
 
5
39
  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,55 @@ 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`.
367
+
368
+ ## 🔐 Re-authentication assertions ("prove you just typed your password")
369
+
370
+ Some actions deserve a fresh password: approving a payment, changing a payout account, confirming something another service will act on. `sessions` records that fresh re-authentication on the session row and can hand out a short-lived, signed **assertion** of it that another service verifies with a shared key — no database, no cookie, no models on the other side.
371
+
372
+ ```ruby
373
+ # config/initializers/sessions.rb
374
+ Sessions.configure do |config|
375
+ config.reauthentication_key = Rails.application.credentials.reauthentication_key # >= 32 bytes, shared with the verifier
376
+ end
377
+
378
+ # Your controller, after YOUR check succeeded (the gem never checks credentials):
379
+ class ReauthenticationsController < ApplicationController
380
+ include Sessions::ReauthenticationHelpers
381
+
382
+ def create
383
+ return render(:new, status: :unprocessable_entity) unless current_user.valid_password?(params[:password])
384
+
385
+ record_sessions_reauthentication!(current_user, method: :password)
386
+ token = sessions_reauthentication_assertion(current_user, audience: "payments", purpose: "payout:42")
387
+ redirect_to payments_service_url(assertion: token), allow_other_host: true
388
+ end
389
+ end
390
+ ```
391
+
392
+ The other service needs only `require "sessions/reauthentication"` and the key:
393
+
394
+ ```ruby
395
+ assertion = Sessions::Reauthentication.verify(token, audience: "payments", key: ENV["REAUTH_KEY"],
396
+ subject: "User:#{user_id}", purpose: "payout:42", max_age: 5.minutes)
397
+ assertion&.method # => "password"
398
+ assertion&.jti # unique per assertion
399
+ assertion&.expires_at # to refuse replays, check-and-insert each accepted jti and keep it until this time
400
+ ```
401
+
402
+ The helpers take the owner whose credential you checked and act only on a current session of that owner (`scope:` picks the Warden scope when you sign in more than one). The subject of an assertion is the owner's class and id (`"User:42"`), so two owner classes with the same id never match; `verify` accepts the record or that string as `subject:`.
403
+
404
+ On the model: `session.reauthenticated!(method: :password)`, `session.reauthenticated_within?(5.minutes)`, `session.reauthenticated_at`, and `session.reauthentication_assertion(audience:, purpose:)` (raises `Sessions::Reauthentication::NotRecent` without a recent re-authentication). An ended or expired session is never recent, and both methods reload the row under a lock first, so a revocation in another process counts. The state lives in `auth_detail`, so no migration is needed.
405
+
406
+ What an assertion is not: a session or a bearer login. It expires after `reauthentication_assertion_ttl` (default one minute), it names one audience and one purpose, and `verify` returns `nil` for anything else — another key, audience, subject or purpose, a tampered or expired token, a timestamp more than 30 seconds in the future, or a re-authentication older than `max_age`.
357
407
 
358
408
  ## 🧹 Retention & the sweep
359
409
 
@@ -418,8 +468,11 @@ Sessions.configure do |config|
418
468
  config.parent_controller = "::ApplicationController"
419
469
  config.current_user_method = :current_user # chain: this → current_user → Current.session&.user
420
470
  config.authenticate_method = :authenticate_user!
421
- config.layout = nil # nil inherits the parent controller's layout
471
+ config.layout = nil # nil, "app", or ->(controller) { ... }
422
472
  config.require_reauthentication = nil # ->(controller) { ... } sudo gate
473
+ config.reauthentication_key = nil # shared secret (>= 32 bytes) for re-authentication assertions
474
+ config.reauthentication_window = 5.minutes # how recent a re-authentication must be to assert it
475
+ config.reauthentication_assertion_ttl = 1.minute # how long an issued assertion stays valid
423
476
  config.session_class = "Session"
424
477
  config.strategy_methods = {} # { "OtpAuthenticatable" => :otp }
425
478
  end
@@ -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? %>
@@ -4,6 +4,7 @@ source "https://rubygems.org"
4
4
 
5
5
  gem "rake", "~> 13.0"
6
6
  gem "rails", "~> 7.1.0"
7
+ gem "json", "< 3"
7
8
 
8
9
  group :development do
9
10
  gem "appraisal"
@@ -4,6 +4,7 @@ source "https://rubygems.org"
4
4
 
5
5
  gem "rake", "~> 13.0"
6
6
  gem "rails", "~> 7.2.0"
7
+ gem "json", "< 3"
7
8
 
8
9
  group :development do
9
10
  gem "appraisal"
@@ -4,6 +4,7 @@ source "https://rubygems.org"
4
4
 
5
5
  gem "rake", "~> 13.0"
6
6
  gem "rails", "~> 8.0.0"
7
+ gem "json", "< 3"
7
8
 
8
9
  group :development do
9
10
  gem "appraisal"
@@ -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").
@@ -192,6 +192,12 @@ module Sessions
192
192
  # never through to the destructive action).
193
193
  attr_reader :require_reauthentication
194
194
 
195
+ # Re-authentication assertions (Sessions::Reauthentication): the shared
196
+ # secret that signs them (a String of at least 32 bytes, or a callable
197
+ # returning one; nil disables issuing), how recent a re-authentication
198
+ # must be to issue one, and how long an issued assertion stays valid.
199
+ attr_reader :reauthentication_key, :reauthentication_window, :reauthentication_assertion_ttl
200
+
195
201
  # The host's session-of-record model, as a string. "Session" matches
196
202
  # both the Rails 8 generator and the model our install generator writes
197
203
  # in Devise mode. Escape hatch for apps with a conflicting legacy
@@ -239,6 +245,9 @@ module Sessions
239
245
  @authenticate_method = :authenticate_user!
240
246
  @layout = nil
241
247
  @require_reauthentication = nil
248
+ @reauthentication_key = nil
249
+ @reauthentication_window = 5.minutes
250
+ @reauthentication_assertion_ttl = 1.minute
242
251
  @session_class = "Session"
243
252
  @strategy_methods = {}
244
253
  end
@@ -377,6 +386,15 @@ module Sessions
377
386
  @parent_controller = name
378
387
  end
379
388
 
389
+ def layout=(value)
390
+ if value.nil? || value.respond_to?(:call) || value.is_a?(String) || value.is_a?(Symbol)
391
+ @layout = value
392
+ return
393
+ end
394
+
395
+ raise ConfigurationError, "layout must be a String, Symbol, callable, or nil, got #{value.inspect}"
396
+ end
397
+
380
398
  def require_reauthentication=(value)
381
399
  if value.nil?
382
400
  @require_reauthentication = nil
@@ -386,6 +404,31 @@ module Sessions
386
404
  @require_reauthentication = ensure_callable(value, "require_reauthentication")
387
405
  end
388
406
 
407
+ def reauthentication_key=(value)
408
+ if value.nil? || value.respond_to?(:call) || (value.is_a?(String) && value.bytesize >= 32)
409
+ @reauthentication_key = value
410
+ return
411
+ end
412
+
413
+ raise ConfigurationError, "reauthentication_key must be a String of at least 32 bytes, a callable, or nil"
414
+ end
415
+
416
+ # The signing key, resolved from a callable on use (so it can read credentials lazily).
417
+ def resolved_reauthentication_key
418
+ key = @reauthentication_key.respond_to?(:call) ? @reauthentication_key.call : @reauthentication_key
419
+ raise ConfigurationError, "config.reauthentication_key is not set" if key.to_s.empty?
420
+
421
+ key
422
+ end
423
+
424
+ def reauthentication_window=(value)
425
+ @reauthentication_window = ensure_positive_duration(value, "reauthentication_window")
426
+ end
427
+
428
+ def reauthentication_assertion_ttl=(value)
429
+ @reauthentication_assertion_ttl = ensure_positive_duration(value, "reauthentication_assertion_ttl")
430
+ end
431
+
389
432
  def session_class=(value)
390
433
  name = value.is_a?(Class) ? value.name : value.to_s
391
434
  raise ConfigurationError, "session_class can't be blank" if name.strip.empty?
@@ -431,6 +474,12 @@ module Sessions
431
474
  value
432
475
  end
433
476
 
477
+ def ensure_positive_duration(value, name)
478
+ return value if value.is_a?(Numeric) && value.positive?
479
+
480
+ raise ConfigurationError, "#{name} must be a positive duration (for example 5.minutes), got #{value.inspect}"
481
+ end
482
+
434
483
  def ensure_callable(value, name)
435
484
  unless value.respond_to?(:call)
436
485
  raise ConfigurationError, "#{name} must respond to #call (a proc/lambda), got #{value.inspect}"
@@ -144,6 +144,97 @@ module Sessions
144
144
  update!(auth_detail: detail.merge("second_factor" => kind.to_s))
145
145
  end
146
146
 
147
+ # --- Re-authentication ----------------------------------------------------
148
+
149
+ # Record that the person re-authenticated on THIS session just now (a
150
+ # password re-entry, a passkey or a second factor). Call it after your
151
+ # own check succeeded; the gem never checks credentials itself:
152
+ #
153
+ # if current_user.valid_password?(params[:password])
154
+ # Sessions.current(request)&.reauthenticated!(method: :password)
155
+ # end
156
+ #
157
+ # Stored in `auth_detail` like `second_factor!`, so no migration is needed.
158
+ def reauthenticated!(method: :password, at: Time.current)
159
+ unless self.class.column_names.include?("auth_detail")
160
+ raise Sessions::Error, "re-authentication needs the auth_detail column (rails generate sessions:install)"
161
+ end
162
+
163
+ with_lock do # reloads the row: a revocation by another process counts
164
+ raise Sessions::Error, "an ended or expired session cannot be re-authenticated" unless reauthenticatable?
165
+
166
+ detail = (try(:auth_detail) || {}).to_h
167
+ update!(auth_detail: detail.merge("reauthenticated_at" => at.utc.iso8601(6),
168
+ "reauthentication_method" => method.to_s))
169
+ end
170
+ end
171
+
172
+ # When the person last re-authenticated on this session, or nil.
173
+ def reauthenticated_at
174
+ value = (try(:auth_detail) || {}).to_h["reauthenticated_at"]
175
+ value && Time.iso8601(value)
176
+ rescue ArgumentError
177
+ nil
178
+ end
179
+
180
+ def reauthentication_method = (try(:auth_detail) || {}).to_h["reauthentication_method"]
181
+
182
+ # True when this live, unexpired session re-authenticated within the
183
+ # duration (default: config.reauthentication_window), on this row as stored.
184
+ def reauthenticated_within?(duration = Sessions.config.reauthentication_window)
185
+ at = reauthenticated_at
186
+ reauthenticatable? && at.present? && at > duration.ago && at <= Time.current
187
+ end
188
+
189
+ # A short-lived signed assertion of the last re-authentication, for one
190
+ # audience (and optionally one purpose). Another service verifies it with
191
+ # Sessions::Reauthentication.verify and the shared key. Raises
192
+ # Sessions::Reauthentication::NotRecent unless the re-authentication is
193
+ # within config.reauthentication_window. The row is reloaded under a lock
194
+ # first, so an assertion never outlives a revocation it could see.
195
+ def reauthentication_assertion(audience:, purpose: nil, within: Sessions.config.reauthentication_window)
196
+ with_lock do
197
+ unless reauthenticated_within?(within)
198
+ raise Sessions::Reauthentication::NotRecent, "no re-authentication within #{within.inspect}"
199
+ end
200
+
201
+ config = Sessions.config
202
+ Sessions::Reauthentication.issue(
203
+ subject: reauthentication_subject, session_id: id, method: reauthentication_method,
204
+ authenticated_at: reauthenticated_at, audience: audience, purpose: purpose,
205
+ key: config.resolved_reauthentication_key, expires_in: config.reauthentication_assertion_ttl
206
+ )
207
+ end
208
+ end
209
+
210
+ # The owner's canonical subject ("User:42"), with the owner class even
211
+ # when the owner record is not loaded. A stored polymorphic type is
212
+ # resolved to its class first, so the subject is the full class name
213
+ # also when `store_full_class_name` is false; it then matches
214
+ # Sessions::Reauthentication.subject_for(owner).
215
+ def reauthentication_subject
216
+ stored_type = try(:user_type).presence
217
+ owner_class = if stored_type
218
+ sessions_polymorphic_owner_class(stored_type)
219
+ else
220
+ self.class.reflect_on_association(:user)&.klass
221
+ end
222
+ owner_type = owner_class.respond_to?(:base_class) ? owner_class.base_class.name : stored_type
223
+ "#{owner_type}:#{user_id}"
224
+ end
225
+
226
+ private
227
+
228
+ def reauthenticatable? = !ended? && !sessions_expired?
229
+
230
+ def sessions_polymorphic_owner_class(stored_type)
231
+ self.class.polymorphic_class_for(stored_type)
232
+ rescue NameError, ArgumentError
233
+ nil
234
+ end
235
+
236
+ public
237
+
147
238
  # --- Revocation -----------------------------------------------------------
148
239
 
149
240
  # End this registry row without deleting it. This is the core v0.2
@@ -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
 
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "time"
6
+ require "active_support"
7
+ require "active_support/message_verifier"
8
+ require "active_support/core_ext/time/calculations"
9
+ require "active_support/core_ext/numeric/time" # 5.minutes for max_age: and fresh?(within:)
10
+ require "active_support/security_utils"
11
+ require_relative "errors"
12
+
13
+ module Sessions
14
+ # Short-lived, signed proof that a person re-entered their password (or
15
+ # passed another re-authentication) on a given session a moment ago.
16
+ #
17
+ # The host records the re-authentication on the session row and issues an
18
+ # assertion for one audience; another service that holds the shared key
19
+ # verifies it without a database, without a session cookie, and without
20
+ # this app's models:
21
+ #
22
+ # # The host, right after it checked the password:
23
+ # session = Sessions.current(request)
24
+ # session.reauthenticated!(method: :password)
25
+ # token = session.reauthentication_assertion(audience: "billing", purpose: "refund:42")
26
+ #
27
+ # # The other service (only `require "sessions/reauthentication"` is needed):
28
+ # assertion = Sessions::Reauthentication.verify(token, audience: "billing", key: ENV["REAUTH_KEY"],
29
+ # subject: "User:#{user_id}", purpose: "refund:42")
30
+ # assertion&.fresh?(within: 5.minutes) # => true
31
+ #
32
+ # An assertion is a signed statement, not a session: it expires quickly
33
+ # (`reauthentication_assertion_ttl`), names its audience and purpose, and
34
+ # carries a unique `jti` so a verifier that must refuse replays can keep a
35
+ # short list of the ones it accepted.
36
+ module Reauthentication
37
+ # The claims of a verified assertion. `subject` is "<owner class>:<id>".
38
+ Assertion = Data.define(:subject, :session_id, :method, :authenticated_at, :audience, :purpose, :jti, :issued_at,
39
+ :expires_at) do
40
+ # True when the re-authentication happened within the duration.
41
+ def fresh?(within:) = authenticated_at > Time.now - within
42
+ end
43
+
44
+ DIGEST = "SHA256"
45
+ CLOCK_SKEW = 30 # seconds of tolerance for the issuer's clock running ahead
46
+
47
+ # The session has no re-authentication recent enough to assert.
48
+ class NotRecent < Sessions::Error; end
49
+
50
+ module_function
51
+
52
+ # The canonical subject of an owner record ("User:42"), so owners of two
53
+ # classes with the same id never share a subject. A String passes through.
54
+ def subject_for(owner)
55
+ return owner.to_s if owner.is_a?(String) || !owner.respond_to?(:id)
56
+
57
+ klass = owner.class.respond_to?(:base_class) ? owner.class.base_class : owner.class
58
+ "#{klass.name}:#{owner.id}"
59
+ end
60
+
61
+ # A signed assertion. `key` is the shared secret (at least 32 bytes).
62
+ def issue(subject:, session_id:, method:, authenticated_at:, audience:, key:, purpose: nil, expires_in: 60)
63
+ now = Time.now.utc
64
+ claims = { "sub" => subject_for(subject), "sid" => session_id.to_s, "method" => method.to_s,
65
+ "auth_at" => authenticated_at.utc.iso8601(6), "aud" => audience.to_s, "purpose" => purpose&.to_s,
66
+ "jti" => SecureRandom.uuid, "iat" => now.iso8601(6), "exp" => (now + expires_in).iso8601(6) }
67
+ verifier(key).generate(claims, expires_in: expires_in, purpose: signing_purpose(audience))
68
+ end
69
+
70
+ # The Assertion of a token, or nil when the token is forged, expired, for
71
+ # another audience, purpose or subject, dated in the future beyond
72
+ # CLOCK_SKEW, or (with max_age) of a re-authentication older than max_age.
73
+ # A verifier that must refuse replays records each accepted `jti` until
74
+ # its `expires_at` (an atomic check-and-insert).
75
+ def verify(token, audience:, key:, subject: nil, purpose: nil, max_age: nil)
76
+ return nil if token.to_s.empty?
77
+
78
+ claims = verifier(key).verified(token.to_s, purpose: signing_purpose(audience))
79
+ return nil unless claims.is_a?(Hash) && claims["aud"] == audience.to_s
80
+ return nil if subject && !secure_equal?(claims["sub"], subject_for(subject))
81
+ return nil if purpose && !secure_equal?(claims["purpose"].to_s, purpose.to_s)
82
+
83
+ assertion = Assertion.new(subject: claims["sub"], session_id: claims["sid"], method: claims["method"],
84
+ authenticated_at: Time.iso8601(claims["auth_at"]), audience: claims["aud"],
85
+ purpose: claims["purpose"], jti: claims["jti"], issued_at: Time.iso8601(claims["iat"]),
86
+ expires_at: Time.iso8601(claims["exp"]))
87
+ latest = Time.now + CLOCK_SKEW
88
+ return nil if assertion.authenticated_at > latest || assertion.issued_at > latest
89
+ return nil if assertion.authenticated_at > assertion.issued_at
90
+ return nil if max_age && !assertion.fresh?(within: max_age)
91
+
92
+ assertion
93
+ rescue ArgumentError, TypeError, KeyError
94
+ nil
95
+ end
96
+
97
+ def verifier(key)
98
+ raise Sessions::ConfigurationError, "a reauthentication key needs at least 32 bytes" if key.to_s.bytesize < 32
99
+
100
+ ActiveSupport::MessageVerifier.new(key.to_s, digest: DIGEST, serializer: JSON)
101
+ end
102
+
103
+ def signing_purpose(audience) = "sessions/reauthentication/#{audience}"
104
+
105
+ def secure_equal?(left, right)
106
+ left.to_s.bytesize == right.to_s.bytesize && ActiveSupport::SecurityUtils.secure_compare(left.to_s, right.to_s)
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sessions
4
+ # Controller helpers for re-authentication assertions. Opt in where you
5
+ # check a password again, and name whose password you checked:
6
+ #
7
+ # class ReauthenticationsController < ApplicationController
8
+ # include Sessions::ReauthenticationHelpers
9
+ #
10
+ # def create
11
+ # if current_user.valid_password?(params[:password])
12
+ # record_sessions_reauthentication!(current_user, method: :password)
13
+ # token = sessions_reauthentication_assertion(current_user, audience: "approvals")
14
+ # end
15
+ # end
16
+ # end
17
+ #
18
+ # Every helper takes the owner whose credential the controller checked and
19
+ # acts only on a current session of that owner (in `scope:` when the app
20
+ # signs in more than one Warden scope).
21
+ module ReauthenticationHelpers
22
+ private
23
+
24
+ # Records the re-authentication on the owner's current session. Returns the session, or nil.
25
+ def record_sessions_reauthentication!(owner, method: :password, scope: nil)
26
+ sessions_reauthentication_session(owner, scope)&.tap { |session| session.reauthenticated!(method: method) }
27
+ end
28
+
29
+ # True when the owner's current session re-authenticated within the window.
30
+ def sessions_reauthenticated?(owner, within: Sessions.config.reauthentication_window, scope: nil)
31
+ sessions_reauthentication_session(owner, scope)&.reauthenticated_within?(within) || false
32
+ end
33
+
34
+ # An assertion of the owner's recent re-authentication, or nil.
35
+ def sessions_reauthentication_assertion(owner, audience:, purpose: nil, scope: nil)
36
+ sessions_reauthentication_session(owner, scope)&.reauthentication_assertion(audience: audience, purpose: purpose)
37
+ rescue Sessions::Reauthentication::NotRecent
38
+ nil
39
+ end
40
+
41
+ def sessions_reauthentication_session(owner, scope)
42
+ session = Sessions.current(request, scope: scope)
43
+ return nil unless session && owner
44
+ return nil unless session.reauthentication_subject == Sessions::Reauthentication.subject_for(owner)
45
+
46
+ session
47
+ end
48
+ end
49
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sessions
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/sessions.rb CHANGED
@@ -13,6 +13,8 @@ require "active_support/security_utils"
13
13
  require_relative "sessions/version"
14
14
  require_relative "sessions/errors"
15
15
  require_relative "sessions/configuration"
16
+ require_relative "sessions/reauthentication"
17
+ require_relative "sessions/reauthentication_helpers"
16
18
  require_relative "sessions/current"
17
19
  require_relative "sessions/end_reason"
18
20
  require_relative "sessions/ip_address"
@@ -94,6 +96,27 @@ module Sessions
94
96
  config.session_model
95
97
  end
96
98
 
99
+ # Resolve a typed login identity to an account for host hooks. Failed
100
+ # login events still do NOT persist an account link (enumeration safety);
101
+ # this is only an in-process convenience for notification hooks that are
102
+ # already allowed to know whether the account exists.
103
+ def user_for_identity(identity, scope: nil)
104
+ normalized = Sessions::Event.normalize_identity(identity)
105
+ return nil if normalized.blank?
106
+
107
+ safely("user_for_identity") do
108
+ model = user_model_for_identity(scope)
109
+ next nil unless model.respond_to?(:column_names)
110
+
111
+ column = %w[email email_address login username phone].find { |name| model.column_names.include?(name) }
112
+ next nil unless column
113
+
114
+ table = model.quoted_table_name
115
+ quoted_column = model.connection.quote_column_name(column)
116
+ model.where("LOWER(#{table}.#{quoted_column}) = ?", normalized).first
117
+ end
118
+ end
119
+
97
120
  # --- Request-side API -----------------------------------------------------
98
121
 
99
122
  # Label the login that's about to happen on this request — for flows
@@ -312,6 +335,20 @@ module Sessions
312
335
 
313
336
  private
314
337
 
338
+ def user_model_for_identity(scope)
339
+ if scope && defined?(::Devise) && ::Devise.respond_to?(:mappings)
340
+ mapping = ::Devise.mappings[scope.to_sym] || ::Devise.mappings[scope.to_s]
341
+ return mapping.to if mapping.respond_to?(:to)
342
+ end
343
+
344
+ reflection = session_model.reflect_on_association(:user)
345
+ return nil if reflection&.polymorphic?
346
+
347
+ reflection&.klass
348
+ rescue StandardError
349
+ nil
350
+ end
351
+
315
352
  def omakase_current(_request)
316
353
  return nil unless defined?(::Current) && ::Current.respond_to?(:session)
317
354
 
@@ -329,10 +366,22 @@ module Sessions
329
366
  next unless key.to_s.match?(pattern) && value.is_a?(Hash)
330
367
 
331
368
  tracking = Adapters::Warden.parse_tracking_state(value[Adapters::Warden::SESSION_KEY])
332
- next unless tracking && tracking[:mode] == "credential"
369
+ next unless tracking
333
370
 
334
371
  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])
372
+ next unless row
373
+
374
+ case tracking[:mode]
375
+ when "credential"
376
+ return row if row.respond_to?(:sessions_token_matches?) && row.sessions_token_matches?(tracking[:token])
377
+ when "hint"
378
+ # Tokenless remembered reattach: the tracking state is written
379
+ # server-side, but corroborate with the signed browser-continuity
380
+ # cookie before answering "this is the current device" — the same
381
+ # proof the adapter demands before touching or kicking a hint row.
382
+ device_id = Adapters::Warden.device_id_from_request(request)
383
+ return row if device_id.present? && device_id == row.try(:device_id)
384
+ end
336
385
  end
337
386
  nil
338
387
  rescue StandardError
@@ -362,7 +411,13 @@ module Sessions
362
411
 
363
412
  count = 0
364
413
  expired_sessions_scope.find_each do |session|
365
- session.revoke!(reason: :expired)
414
+ # Expiry is timeout POLICY, not a revocation: end the row (writes the
415
+ # `expired` audit event; `expired` kicks on resume) but never rotate
416
+ # the account-wide remember-me credential or fire on_session_revoked.
417
+ # This matches the adapter's inline expiry and Devise's timeoutable,
418
+ # where remember-me revival after a timeout is the host's own
419
+ # rememberable policy — not ours to nuke for every other device.
420
+ session.end!(reason: :expired)
366
421
  count += 1
367
422
  end
368
423
  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.3.0
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-10-17 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
@@ -198,6 +200,8 @@ files:
198
200
  - lib/sessions/models/concerns/has_sessions.rb
199
201
  - lib/sessions/models/concerns/model.rb
200
202
  - lib/sessions/models/event.rb
203
+ - lib/sessions/reauthentication.rb
204
+ - lib/sessions/reauthentication_helpers.rb
201
205
  - lib/sessions/version.rb
202
206
  homepage: https://github.com/rameerez/sessions
203
207
  licenses: