sessions 0.1.3 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +35 -2
- data/README.md +26 -16
- data/app/controllers/sessions/application_controller.rb +4 -3
- data/app/helpers/sessions/engine_helper.rb +23 -0
- data/app/views/sessions/_devices.html.erb +2 -2
- data/app/views/sessions/_history.html.erb +2 -2
- data/docs/PRD.md +52 -52
- data/docs/research/{01-carhey.md → 01-host-app.md} +23 -23
- data/docs/research/02-ecosystem.md +1 -1
- data/docs/research/07-device-detection.md +13 -13
- data/lib/generators/sessions/install_generator.rb +1 -1
- data/lib/generators/sessions/madmin_generator.rb +23 -7
- data/lib/generators/sessions/templates/add_lifecycle_to_sessions.rb.erb +37 -0
- data/lib/generators/sessions/templates/add_sessions_columns.rb.erb +28 -0
- data/lib/generators/sessions/templates/create_sessions.rb.erb +20 -5
- data/lib/generators/sessions/templates/create_sessions_events.rb.erb +4 -4
- data/lib/generators/sessions/templates/initializer.rb +9 -8
- data/lib/generators/sessions/templates/madmin/session_resource.rb +24 -13
- data/lib/generators/sessions/templates/madmin/sessions_controller.rb +11 -7
- data/lib/generators/sessions/templates/madmin/user_panel/madmin_user_panel.rb +43 -0
- data/lib/generators/sessions/templates/madmin/user_panel/sessions.html.erb +133 -0
- data/lib/generators/sessions/upgrade_generator.rb +4 -1
- data/lib/sessions/adapters/omakase.rb +62 -18
- data/lib/sessions/adapters/warden.rb +172 -48
- data/lib/sessions/configuration.rb +14 -4
- data/lib/sessions/current.rb +4 -4
- data/lib/sessions/end_reason.rb +67 -0
- data/lib/sessions/models/concerns/has_sessions.rb +3 -3
- data/lib/sessions/models/concerns/model.rb +124 -59
- data/lib/sessions/models/event.rb +56 -21
- data/lib/sessions/version.rb +1 -1
- data/lib/sessions.rb +68 -14
- metadata +9 -5
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Device intelligence: UA parsing, Hotwire Native, client hints, IP capture
|
|
2
2
|
|
|
3
|
-
Researched 2026-06-11. Code citations are from read-only clones under `/tmp/sessions-research/` (browser, device_detector, hotwire-native-ios, hotwire-native-android, turbo-rails, rails-stable @ v8.1.3) and the local apps under `/
|
|
3
|
+
Researched 2026-06-11. Code citations are from read-only clones under `/tmp/sessions-research/` (browser, device_detector, hotwire-native-ios, hotwire-native-android, turbo-rails, rails-stable @ v8.1.3) and the local apps under `/path/to/repos/`. Web citations carry URL + fetch date.
|
|
4
4
|
|
|
5
5
|
## Top findings
|
|
6
6
|
|
|
@@ -11,7 +11,7 @@ Researched 2026-06-11. Code citations are from read-only clones under `/tmp/sess
|
|
|
11
11
|
- **Android WebView is explicitly excluded from Chrome's UA reduction** ("We don't have current plans for User-Agent Reduction on iOS and Android WebView" — chromium.org/updates/ua-reduction, fetched 2026-06-10). So Hotwire Native **Android** UAs still carry real device model + real Android version. Hotwire Native **iOS** UAs carry real iOS version but never the hardware model.
|
|
12
12
|
- **Web Chrome UAs are husks since 2023**: frozen `Windows NT 10.0`, `Intel Mac OS X 10_15_7`, `Linux; Android 10; K`, minor versions `0.0.0`. Real data moved to UA Client Hints — which **only Chromium ships; Safari and Firefox still refuse as of June 2026**.
|
|
13
13
|
- **iPadOS masquerades as macOS by default** since iPadOS 13 — server-side, an iPad on Safari is byte-identical to a Mac. No fix without JS.
|
|
14
|
-
- **Our four local apps customize the UA prefix today but none embeds app version or (iOS) device model** — the gem should ship a recommended prefix convention;
|
|
14
|
+
- **Our four local apps customize the UA prefix today but none embeds app version or (iOS) device model** — the gem should ship a recommended prefix convention; HostApp's native HTTP client already proves the pattern (`"HostApp Android 1.0.5 (build 6; Android 14; sdk 34; Pixel 7)"`).
|
|
15
15
|
- **`request.remote_ip` behind Cloudflare returns a Cloudflare edge IP** unless CF ranges are added to `trusted_proxies` (which *replaces* the private-range defaults) — document `cloudflare-rails` or an `ip_resolver` hook. Portable IP column: `string limit: 45`; `inet` is Postgres-only.
|
|
16
16
|
|
|
17
17
|
## A. Ruby UA parsers: `browser` vs `device_detector`
|
|
@@ -56,7 +56,7 @@ Researched 2026-06-11. Code citations are from read-only clones under `/tmp/sess
|
|
|
56
56
|
1. **Always persist the raw UA in a `text` column** (no 255 limit) plus the relevant raw Client-Hint headers when present. Parsing is a *projection* that can be re-run as parsers/conventions improve. Validated by uniform prior art.
|
|
57
57
|
2. **Hard dependency on `browser`** as the default web parser: MIT, zero-dep, tiny, Rails-aware, good enough for "Chrome 137 on macOS" + bot flagging. A drop-in gem needs device intel working with zero setup; an adapter-only design would gut the first-run experience.
|
|
58
58
|
3. **Optional `device_detector` adapter** (auto-upgrade if the host app bundles it): better device names on legacy/Android UAs, native Client-Hints handling, much bigger bot DB. Don't hard-depend: LGPL, 1.5 MB data, 2-years-stale releases.
|
|
59
|
-
4. **Built-in native-app UA parser that runs first** (before any web parser): recognizes `Hotwire Native iOS|Android`, `Turbo Native`, the recommended prefix convention below, and
|
|
59
|
+
4. **Built-in native-app UA parser that runs first** (before any web parser): recognizes `Hotwire Native iOS|Android`, `Turbo Native`, the recommended prefix convention below, and HostApp's existing shapes. This is the gem's actual moat; no third-party parser does it.
|
|
60
60
|
5. Expose `config.ua_parser = :browser | :device_detector | ->(ua, headers) { DeviceInfo.new(...) }` for escape hatches, and stamp rows with parser identity/version if cheap (optional).
|
|
61
61
|
|
|
62
62
|
## B. Hotwire Native user agents
|
|
@@ -125,17 +125,17 @@ Substring contract: `"Turbo Native"` or `"Hotwire Native"` anywhere in the UA. T
|
|
|
125
125
|
|
|
126
126
|
| App | Prefix set | Where | Effective UA shape |
|
|
127
127
|
|---|---|---|---|
|
|
128
|
-
|
|
|
128
|
+
| hostapp-ios | `"HostApp iOS; RailsFast Native iOS;"` (`{App}` from `CFBundleDisplayName`) | `RailsFast/Core/AppConfiguration.swift:10-12`, applied `RailsFast/App/AppDelegate.swift:103` | `Mozilla/5.0 (iPhone; CPU iPhone OS x_y like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) HostApp iOS; RailsFast Native iOS; Hotwire Native iOS; Turbo Native iOS; bridge-components: […]` |
|
|
129
129
|
| railsfast-ios | `"RailsFast iOS; RailsFast Native iOS;"` | `RailsFast/Core/AppConfiguration.swift:10-12`, `RailsFast/App/AppDelegate.swift:93-99` | same shape, `RailsFast` tokens |
|
|
130
|
-
|
|
|
130
|
+
| hostapp-android | `"HostApp Android;"` | `app/src/main/java/com/hostapp/android/HostAppApplication.kt:169` | `HostApp Android; Hotwire Native Android; Turbo Native Android; bridge-components: […]; Mozilla/5.0 (Linux; Android NN; <Model> Build/…; wv) … Chrome/NNN.0.0.0 Mobile Safari/537.36` |
|
|
131
131
|
| railsfast-android | `"${BuildConfig.APPLICATION_NAME} Android; RailsFast Native Android;"` | `app/src/main/java/com/railsfast/android/RailsFastApplication.kt:45-46` | same shape with two leading brand segments |
|
|
132
132
|
|
|
133
133
|
So today: **no webview UA carries the app version anywhere, and iOS UAs carry no device model.** Android model/OS arrive free via the WebView default UA.
|
|
134
134
|
|
|
135
|
-
However,
|
|
135
|
+
However, HostApp's *native* (URLSession/OkHttp) calls already use a richer convention the gem should accept as prior art:
|
|
136
136
|
|
|
137
|
-
- iOS: `"\(applicationName) iOS \(version) (build \(build); iOS \(osVersion); \(resolvedModel))"` → e.g. `
|
|
138
|
-
- Android: `"
|
|
137
|
+
- iOS: `"\(applicationName) iOS \(version) (build \(build); iOS \(osVersion); \(resolvedModel))"` → e.g. `HostApp iOS 1.0.5 (build 6; iOS 19.5; iPhone15,2)` (`hostapp-ios/RailsFast/Core/NativeHttpClient.swift:61-71`), plus headers `X-Client-Platform/-Version/-Build/-OS` (`NativeHttpClient.swift:13-17`).
|
|
138
|
+
- Android: `"HostApp Android $versionName (build $versionCode; Android $osRelease; sdk $sdkInt; $device)"` → e.g. `HostApp Android 1.0.5 (build 6; Android 14; sdk 34; Pixel 7)` (`hostapp-android/app/src/main/java/com/hostapp/android/ClientHeaders.kt:66-76`; header names `:25-29`).
|
|
139
139
|
|
|
140
140
|
### Recommended UA convention for the gem's README
|
|
141
141
|
|
|
@@ -143,11 +143,11 @@ Use an RFC 9110-style product token as the `applicationUserAgentPrefix`, ending
|
|
|
143
143
|
|
|
144
144
|
```text
|
|
145
145
|
<AppName>/<version> (<model>; <os> <os_version>; build <build>);
|
|
146
|
-
e.g.
|
|
147
|
-
e.g.
|
|
146
|
+
e.g. HostApp/2.4.1 (iPhone15,2; iOS 19.5; build 241);
|
|
147
|
+
e.g. HostApp/2.4.1 (Pixel 8; Android 16; build 241);
|
|
148
148
|
```
|
|
149
149
|
|
|
150
|
-
Parse rule (gem-side, tolerant): `%r{(?<app>[\w .-]+)/(?<version>\d[\w.]*) \((?<fields>[^)]*)\)}` with semicolon-split, order-insensitive fields; also accept
|
|
150
|
+
Parse rule (gem-side, tolerant): `%r{(?<app>[\w .-]+)/(?<version>\d[\w.]*) \((?<fields>[^)]*)\)}` with semicolon-split, order-insensitive fields; also accept HostApp's space-separated legacy `"HostApp iOS 1.0.5 (build 6; …)"`. Everything else (Hotwire markers, WebView UA) stays intact, so `hotwire_native_app?` and bridge components keep working.
|
|
151
151
|
|
|
152
152
|
README client snippets:
|
|
153
153
|
|
|
@@ -156,7 +156,7 @@ README client snippets:
|
|
|
156
156
|
```swift
|
|
157
157
|
var u = utsname(); uname(&u)
|
|
158
158
|
let model = withUnsafeBytes(of: &u.machine) { String(decoding: $0.prefix(while: { $0 != 0 }), as: UTF8.self) }
|
|
159
|
-
Hotwire.config.applicationUserAgentPrefix = "
|
|
159
|
+
Hotwire.config.applicationUserAgentPrefix = "HostApp/\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0") (\(model); iOS \(UIDevice.current.systemVersion));"
|
|
160
160
|
```
|
|
161
161
|
|
|
162
162
|
(`model` is `"iPhone15,2"` on device, `"arm64"` on Simulator — fine for production traffic.)
|
|
@@ -165,7 +165,7 @@ Hotwire.config.applicationUserAgentPrefix = "CarHey/\(Bundle.main.infoDictionary
|
|
|
165
165
|
|
|
166
166
|
```kotlin
|
|
167
167
|
Hotwire.config.applicationUserAgentPrefix =
|
|
168
|
-
"
|
|
168
|
+
"HostApp/${BuildConfig.VERSION_NAME} " +
|
|
169
169
|
"(${Build.MODEL}; Android ${Build.VERSION.RELEASE}; build ${BuildConfig.VERSION_CODE});"
|
|
170
170
|
```
|
|
171
171
|
|
|
@@ -181,7 +181,7 @@ module Sessions
|
|
|
181
181
|
say " schedule: every day at 4am"
|
|
182
182
|
|
|
183
183
|
say "\nEvery login now lands on the devices page and in the trail:"
|
|
184
|
-
say " current_user.sessions.
|
|
184
|
+
say " current_user.sessions.live # live devices, revocable"
|
|
185
185
|
say " current_user.session_history # the trail — logins, failures, revocations"
|
|
186
186
|
say "\nEvery session, every device, every login — tracked. 🔐✨\n", :green
|
|
187
187
|
end
|
|
@@ -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
|
|
|
@@ -28,7 +31,7 @@ module Sessions
|
|
|
28
31
|
|
|
29
32
|
This generator produces Madmin resources for the session registry and
|
|
30
33
|
the login trail. For other admin frameworks, build on the same
|
|
31
|
-
primitives it uses: Session.
|
|
34
|
+
primitives it uses: Session.live / session.revoke! /
|
|
32
35
|
Sessions::Event scopes (failed_logins, last_24_hours, …).
|
|
33
36
|
MSG
|
|
34
37
|
end
|
|
@@ -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,13 +80,19 @@ 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
|
-
say "\nRevoking from the index
|
|
78
|
-
say "on its very next request, and the revocation lands in the trail
|
|
79
|
-
say "admin attribution. 🔐\n", :green
|
|
93
|
+
say "\nRevoking from the index ends the row in place: that device is signed out"
|
|
94
|
+
say "on its very next matching request, and the revocation lands in the trail"
|
|
95
|
+
say "with admin attribution. 🔐\n", :green
|
|
80
96
|
end
|
|
81
97
|
|
|
82
98
|
private
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# v0.2 upgrade: session rows are lifecycle records now. A live device is
|
|
4
|
+
# `ended_at: nil`; explicit ending state lives on the row instead of being
|
|
5
|
+
# inferred from a missing row plus a sessions_events tombstone.
|
|
6
|
+
class AddSessionsLifecycleTo<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_version %>
|
|
7
|
+
def change
|
|
8
|
+
change_table :<%= table_name %>, bulk: true do |t|
|
|
9
|
+
t.datetime :ended_at unless column_exists?(:<%= table_name %>, :ended_at)
|
|
10
|
+
t.string :ended_reason unless column_exists?(:<%= table_name %>, :ended_reason)
|
|
11
|
+
t.string :ended_by_type unless column_exists?(:<%= table_name %>, :ended_by_type)
|
|
12
|
+
t.send(reference_column_type, :ended_by_id) unless column_exists?(:<%= table_name %>, :ended_by_id)
|
|
13
|
+
t.send(json_column_type, :ended_metadata) unless column_exists?(:<%= table_name %>, :ended_metadata)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
add_index :<%= table_name %>, :ended_at unless index_exists?(:<%= table_name %>, :ended_at)
|
|
17
|
+
add_index :<%= table_name %>, :ended_reason unless index_exists?(:<%= table_name %>, :ended_reason)
|
|
18
|
+
add_index :<%= table_name %>, %i[ended_by_type ended_by_id] unless index_exists?(:<%= table_name %>, %i[ended_by_type ended_by_id])
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def reference_column_type
|
|
24
|
+
config = Rails.configuration.generators
|
|
25
|
+
case config.options[config.orm][:primary_key_type]
|
|
26
|
+
when nil, :bigserial then :bigint
|
|
27
|
+
when :serial then :integer
|
|
28
|
+
else config.options[config.orm][:primary_key_type]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def json_column_type
|
|
33
|
+
return :jsonb if connection.adapter_name.match?(/postg/i)
|
|
34
|
+
|
|
35
|
+
:json
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -64,6 +64,16 @@ class AddSessionsColumnsTo<%= table_name.camelize %> < ActiveRecord::Migration<%
|
|
|
64
64
|
# Session.sweep recommendation always needed.
|
|
65
65
|
t.datetime :last_seen_at
|
|
66
66
|
t.string :last_seen_ip, limit: 45 # refreshed with the touch (roaming devices)
|
|
67
|
+
|
|
68
|
+
# Lifecycle state. `ended_at: nil` is the live-device set; ended rows
|
|
69
|
+
# remain as auditable history. Events are the trail, not the source of
|
|
70
|
+
# truth for whether a session should still be accepted.
|
|
71
|
+
t.datetime :ended_at
|
|
72
|
+
t.string :ended_reason
|
|
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
|
|
76
|
+
t.send(json_column_type, :ended_metadata)
|
|
67
77
|
end
|
|
68
78
|
|
|
69
79
|
add_index :<%= table_name %>, :device_id
|
|
@@ -73,10 +83,28 @@ class AddSessionsColumnsTo<%= table_name.camelize %> < ActiveRecord::Migration<%
|
|
|
73
83
|
add_index :<%= table_name %>, :auth_provider
|
|
74
84
|
add_index :<%= table_name %>, :country_code
|
|
75
85
|
add_index :<%= table_name %>, :last_seen_at
|
|
86
|
+
add_index :<%= table_name %>, :ended_at
|
|
87
|
+
add_index :<%= table_name %>, :ended_reason
|
|
88
|
+
add_index :<%= table_name %>, %i[ended_by_type ended_by_id]
|
|
76
89
|
end
|
|
77
90
|
|
|
78
91
|
private
|
|
79
92
|
|
|
93
|
+
def foreign_key_type
|
|
94
|
+
config = Rails.configuration.generators
|
|
95
|
+
reference_column_type(config.options[config.orm][:primary_key_type])
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Reference columns hold VALUES of the PK type, so serial pseudo-types map to
|
|
99
|
+
# their plain integer equivalents.
|
|
100
|
+
def reference_column_type(setting)
|
|
101
|
+
case setting
|
|
102
|
+
when nil, :bigserial then :bigint
|
|
103
|
+
when :serial then :integer
|
|
104
|
+
else setting
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
80
108
|
# :jsonb on PostgreSQL, :json elsewhere — same adaptive pattern as the
|
|
81
109
|
# rest of the gem ecosystem (chats, api_keys, …). match? (not equality):
|
|
82
110
|
# PostGIS apps report adapter_name "PostGIS" and are PostgreSQL too.
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# The Rails-8-shaped sessions table for a Devise app: one row = one
|
|
4
|
-
#
|
|
5
|
-
#
|
|
3
|
+
# The Rails-8-shaped sessions table for a Devise app: one row = one signed-in
|
|
4
|
+
# device lifecycle (`ended_at: nil` means live; history lives in
|
|
5
|
+
# sessions_events). Deliberately the SAME base shape
|
|
6
6
|
# `rails generate authentication` creates — so if you ever migrate from
|
|
7
7
|
# Devise to Rails auth, your sessions table is already waiting.
|
|
8
8
|
class Create<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_version %>
|
|
@@ -23,8 +23,8 @@ class Create<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_ve
|
|
|
23
23
|
|
|
24
24
|
# Devise/Warden mode: SHA-256 of a random token whose raw value lives
|
|
25
25
|
# ONLY in the user's own session (OWASP: never persist raw session
|
|
26
|
-
# identifiers). The Warden adapter validates it on every request
|
|
27
|
-
#
|
|
26
|
+
# identifiers). The Warden adapter validates it on every request; only
|
|
27
|
+
# a matching token plus explicit lifecycle end may sign that device out.
|
|
28
28
|
t.string :token_digest
|
|
29
29
|
|
|
30
30
|
# The Warden scope ("user") — multi-scope Devise apps.
|
|
@@ -72,6 +72,18 @@ class Create<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_ve
|
|
|
72
72
|
t.datetime :last_seen_at
|
|
73
73
|
t.string :last_seen_ip, limit: 45
|
|
74
74
|
|
|
75
|
+
# Lifecycle state. v0.2 keeps rows as the source of truth instead of
|
|
76
|
+
# deleting them and asking events to act as tombstones. `ended_at: nil`
|
|
77
|
+
# is the live-device set; explicit ended_reason values are the only
|
|
78
|
+
# thing a Devise/Warden request is allowed to enforce.
|
|
79
|
+
t.datetime :ended_at
|
|
80
|
+
t.string :ended_reason
|
|
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
|
|
85
|
+
t.send(json_column_type, :ended_metadata)
|
|
86
|
+
|
|
75
87
|
t.timestamps
|
|
76
88
|
end
|
|
77
89
|
|
|
@@ -82,6 +94,9 @@ class Create<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_ve
|
|
|
82
94
|
add_index :<%= table_name %>, :auth_provider
|
|
83
95
|
add_index :<%= table_name %>, :country_code
|
|
84
96
|
add_index :<%= table_name %>, :last_seen_at
|
|
97
|
+
add_index :<%= table_name %>, :ended_at
|
|
98
|
+
add_index :<%= table_name %>, :ended_reason
|
|
99
|
+
add_index :<%= table_name %>, %i[ended_by_type ended_by_id]
|
|
85
100
|
end
|
|
86
101
|
|
|
87
102
|
private
|
|
@@ -23,10 +23,10 @@ class CreateSessionsEvents < ActiveRecord::Migration<%= migration_version %>
|
|
|
23
23
|
t.references :authenticatable, polymorphic: true, type: foreign_key_type, index: false
|
|
24
24
|
t.string :scope
|
|
25
25
|
|
|
26
|
-
# The trail ↔ registry linkage. A plain column, NO foreign key:
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
# the live session it created.
|
|
26
|
+
# The trail ↔ registry linkage. A plain column, NO foreign key:
|
|
27
|
+
# lifecycle rows are normally ended in place, but account-erasure and
|
|
28
|
+
# legacy host deletes may still remove them. A suspicious login here
|
|
29
|
+
# is one lookup away from revoking the live session it created.
|
|
30
30
|
t.send(session_id_column_type, :session_id)
|
|
31
31
|
|
|
32
32
|
t.string :identity # email-as-typed (normalized), even for unknown accounts
|
|
@@ -122,8 +122,8 @@ Sessions.configure do |config|
|
|
|
122
122
|
# "Was this you?" email here. Not fired on a user's very first login.
|
|
123
123
|
#
|
|
124
124
|
# PASS THE EVENT to your mailer, not the session: the event is a
|
|
125
|
-
# persisted, GlobalID-able record that survives revocation
|
|
126
|
-
#
|
|
125
|
+
# persisted, GlobalID-able record that survives revocation and account
|
|
126
|
+
# erasure cleanup, and already carries
|
|
127
127
|
# everything the email needs — event.user, event.device_name,
|
|
128
128
|
# event.location, event.country_flag, event.occurred_at.
|
|
129
129
|
#
|
|
@@ -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
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
3
|
+
# Device lifecycle registry (sessions gem): one row = one signed-in device.
|
|
4
|
+
# `ended_at: nil` is the live set that can act on your app right now; ended
|
|
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
|
|
|
@@ -30,6 +31,8 @@ class SessionResource < Madmin::Resource
|
|
|
30
31
|
attribute :app_build, index: false, form: false
|
|
31
32
|
attribute :user_agent, index: false, form: false
|
|
32
33
|
attribute :last_seen_at, index: true, form: false, label: "Last seen"
|
|
34
|
+
attribute :ended_at, index: true, form: false, label: "Ended"
|
|
35
|
+
attribute :ended_reason, index: true, form: false, label: "Ended because"
|
|
33
36
|
attribute :created_at, index: true, form: false, label: "Signed in"
|
|
34
37
|
attribute :updated_at, show: false, form: false
|
|
35
38
|
|
|
@@ -39,8 +42,14 @@ class SessionResource < Madmin::Resource
|
|
|
39
42
|
attribute :adoption_key, show: false, form: false
|
|
40
43
|
attribute :auth_detail, show: false, form: false
|
|
41
44
|
attribute :client_hints, show: false, form: false
|
|
45
|
+
attribute :ended_by_type, show: false, form: false
|
|
46
|
+
attribute :ended_by_id, show: false, form: false
|
|
47
|
+
attribute :ended_metadata, show: false, form: false
|
|
42
48
|
|
|
43
|
-
# Gem scopes:
|
|
49
|
+
# Gem scopes: live/ended are lifecycle state; active/inactive are UI
|
|
50
|
+
# grouping within the live set (last activity within 30 days).
|
|
51
|
+
scope :live
|
|
52
|
+
scope :ended
|
|
44
53
|
scope :active
|
|
45
54
|
scope :inactive
|
|
46
55
|
|
|
@@ -53,14 +62,16 @@ class SessionResource < Madmin::Resource
|
|
|
53
62
|
def self.default_sort_column = "created_at"
|
|
54
63
|
def self.default_sort_direction = "desc"
|
|
55
64
|
|
|
56
|
-
# Remote logout:
|
|
57
|
-
#
|
|
58
|
-
#
|
|
65
|
+
# Remote logout: ends the row in place, writes the `revoked` trail event
|
|
66
|
+
# attributed to the admin, and rotates the user's remember-me credentials
|
|
67
|
+
# in Devise mode. The device is signed out on its next matching request.
|
|
59
68
|
member_action do
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
65
76
|
end
|
|
66
77
|
end
|
|
@@ -2,20 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
module Madmin
|
|
4
4
|
class SessionsController < Madmin::ResourceController
|
|
5
|
-
# Admin remote logout. `revoke!`
|
|
6
|
-
# on its next request — the cookie session and, in Devise
|
|
7
|
-
# remember-me revival), and writes the immutable `revoked`
|
|
8
|
-
# with `by:` attribution.
|
|
5
|
+
# Admin remote logout. `revoke!` ends the row in place (the device is
|
|
6
|
+
# kicked on its next matching request — the cookie session and, in Devise
|
|
7
|
+
# mode, any remember-me revival), and writes the immutable `revoked`
|
|
8
|
+
# trail event with `by:` attribution.
|
|
9
9
|
def revoke
|
|
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>
|