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 +4 -4
- data/Appraisals +6 -0
- data/CHANGELOG.md +34 -0
- data/README.md +61 -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/gemfiles/rails_7.1.gemfile +1 -0
- data/gemfiles/rails_7.2.gemfile +1 -0
- data/gemfiles/rails_8.0.gemfile +1 -0
- 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 +50 -1
- data/lib/sessions/models/concerns/model.rb +91 -0
- data/lib/sessions/models/event.rb +32 -4
- data/lib/sessions/reauthentication.rb +109 -0
- data/lib/sessions/reauthentication_helpers.rb +49 -0
- data/lib/sessions/version.rb +1 -1
- data/lib/sessions.rb +58 -3
- metadata +8 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b7cca6c52bb926f1a07896ba44ec9cf3abba216dd8d49793febc1a405b27e6e1
|
|
4
|
+
data.tar.gz: 00770fa40d62a4e305df2b8a8530519873368fe097359d64d92fc8a19b522897
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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,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.
|
|
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
|
|
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
|
|
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? %>
|
data/gemfiles/rails_7.1.gemfile
CHANGED
data/gemfiles/rails_7.2.gemfile
CHANGED
data/gemfiles/rails_8.0.gemfile
CHANGED
|
@@ -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").
|
|
@@ -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
|
-
|
|
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
|
|
|
@@ -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
|
data/lib/sessions/version.rb
CHANGED
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
|
|
369
|
+
next unless tracking
|
|
333
370
|
|
|
334
371
|
row = session_model.live.find_by(id: tracking[:id])
|
|
335
|
-
|
|
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
|
-
|
|
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.
|
|
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-
|
|
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:
|