sessions 0.1.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.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +61 -0
  3. data/.simplecov +54 -0
  4. data/AGENTS.md +5 -0
  5. data/Appraisals +26 -0
  6. data/CHANGELOG.md +26 -0
  7. data/CLAUDE.md +5 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +454 -0
  10. data/Rakefile +32 -0
  11. data/app/assets/stylesheets/sessions.css +50 -0
  12. data/app/controllers/sessions/application_controller.rb +159 -0
  13. data/app/controllers/sessions/devices_controller.rb +48 -0
  14. data/app/helpers/sessions/engine_helper.rb +126 -0
  15. data/app/views/sessions/_device.html.erb +40 -0
  16. data/app/views/sessions/_devices.html.erb +34 -0
  17. data/app/views/sessions/_event.html.erb +13 -0
  18. data/app/views/sessions/_history.html.erb +20 -0
  19. data/app/views/sessions/devices/history.html.erb +5 -0
  20. data/app/views/sessions/devices/index.html.erb +15 -0
  21. data/config/locales/en.yml +59 -0
  22. data/config/locales/es.yml +59 -0
  23. data/config/routes.rb +17 -0
  24. data/docs/PRD.md +743 -0
  25. data/docs/research/01-carhey.md +250 -0
  26. data/docs/research/02-ecosystem.md +261 -0
  27. data/docs/research/03-rails-core.md +220 -0
  28. data/docs/research/04-devise-warden.md +249 -0
  29. data/docs/research/05-oauth.md +193 -0
  30. data/docs/research/06-prior-art.md +312 -0
  31. data/docs/research/07-device-detection.md +250 -0
  32. data/docs/research/08-rails8-landscape.md +216 -0
  33. data/docs/research/09-market-security.md +450 -0
  34. data/gemfiles/rails_7.1.gemfile +34 -0
  35. data/gemfiles/rails_7.2.gemfile +34 -0
  36. data/gemfiles/rails_8.0.gemfile +34 -0
  37. data/gemfiles/rails_8.1.gemfile +34 -0
  38. data/lib/generators/sessions/install_generator.rb +230 -0
  39. data/lib/generators/sessions/madmin_generator.rb +95 -0
  40. data/lib/generators/sessions/templates/add_sessions_columns.rb.erb +81 -0
  41. data/lib/generators/sessions/templates/create_sessions.rb.erb +101 -0
  42. data/lib/generators/sessions/templates/create_sessions_events.rb.erb +108 -0
  43. data/lib/generators/sessions/templates/initializer.rb +201 -0
  44. data/lib/generators/sessions/templates/madmin/event_resource.rb +77 -0
  45. data/lib/generators/sessions/templates/madmin/session_events_controller.rb +21 -0
  46. data/lib/generators/sessions/templates/madmin/session_resource.rb +65 -0
  47. data/lib/generators/sessions/templates/madmin/sessions_controller.rb +30 -0
  48. data/lib/generators/sessions/templates/session.rb.erb +14 -0
  49. data/lib/generators/sessions/templates/sessions_sweep_job.rb +20 -0
  50. data/lib/generators/sessions/views_generator.rb +33 -0
  51. data/lib/sessions/adapters/omakase.rb +195 -0
  52. data/lib/sessions/adapters/omniauth.rb +64 -0
  53. data/lib/sessions/adapters/warden.rb +293 -0
  54. data/lib/sessions/classifier.rb +208 -0
  55. data/lib/sessions/configuration.rb +441 -0
  56. data/lib/sessions/current.rb +20 -0
  57. data/lib/sessions/device.rb +411 -0
  58. data/lib/sessions/engine.rb +120 -0
  59. data/lib/sessions/errors.rb +24 -0
  60. data/lib/sessions/geolocation.rb +111 -0
  61. data/lib/sessions/ip_address.rb +56 -0
  62. data/lib/sessions/jobs/geolocate_job.rb +58 -0
  63. data/lib/sessions/macros.rb +26 -0
  64. data/lib/sessions/middleware.rb +41 -0
  65. data/lib/sessions/models/concerns/device_display.rb +134 -0
  66. data/lib/sessions/models/concerns/has_sessions.rb +116 -0
  67. data/lib/sessions/models/concerns/model.rb +513 -0
  68. data/lib/sessions/models/event.rb +293 -0
  69. data/lib/sessions/version.rb +5 -0
  70. data/lib/sessions.rb +423 -0
  71. metadata +225 -0
data/README.md ADDED
@@ -0,0 +1,454 @@
1
+ # πŸ” `sessions` - GitHub-style device management & login tracking for Rails
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/sessions.svg)](https://badge.fury.io/rb/sessions) [![Build Status](https://github.com/rameerez/sessions/workflows/Tests/badge.svg)](https://github.com/rameerez/sessions/actions)
4
+
5
+ > [!TIP]
6
+ > **πŸš€ Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com/?ref=sessions)**, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks. Go [check it out](https://railsfast.com/?ref=sessions)!
7
+
8
+ `sessions` is the missing session layer for Rails: a **"Your devices" page** like GitHub's (every active session, "log out of that device", "sign out everywhere else") plus an **audit trail of every login attempt** β€” successful *and failed* β€” with parsed device names, IP geolocation, and the auth method that started each session.
9
+
10
+ It **decorates the session storage your app already has** instead of replacing it. On Rails 8+ omakase auth (`rails generate authentication`) it enriches the `sessions` table the generator already created β€” Rails captures `ip_address` and `user_agent` on every session and then never looks at them again; this gem is the product on top of that data. On Devise, it turns the proven one-session-per-user revocation trick into true per-device remote logout via Warden hooks. Either way: one `bundle add`, one generator, one `has_sessions`.
11
+
12
+ And it's built for how people actually sign in now: password, OAuth (Google, Apple, GitHub… any OmniAuth provider β€” including *failed* OAuth attempts), Google One Tap, passkeys, magic links β€” plus first-class [Hotwire Native](https://native.hotwired.dev) awareness, so a session shows up as "MyApp 2.4.1 on Pixel 8 (Android 16)", not as a WebView mystery string.
13
+
14
+ ## πŸ‘¨β€πŸ’» Example
15
+
16
+ ```ruby
17
+ current_user.sessions.active # every live device, most recent first
18
+
19
+ session = current_user.sessions.first
20
+ session.device_name # => "Chrome 137 on macOS"
21
+ # => "MyApp 2.4.1 on iPhone15,2 (iOS 19.5)"
22
+ session.location # => "Madrid, Spain" (via the trackdown gem)
23
+ session.country_flag # => "πŸ‡ͺπŸ‡Έ"
24
+ session.last_seen_at # => 3 minutes ago (throttled touch)
25
+ session.current? # => true for the request's own session
26
+ session.hotwire_native? # session.native_ios? / session.native_android? / session.web?
27
+ session.auth_method # => "oauth" Β· session.auth_provider # => "google"
28
+
29
+ session.revoke! # remote logout β€” that device is signed out on its next request
30
+ current_user.revoke_other_sessions! # GitHub's "sign out everywhere else"
31
+ current_user.revoke_all_sessions! # the account-takeover hammer
32
+
33
+ current_user.session_history.recent # the trail, identity-matched failures included
34
+ current_user.session_history.failed_logins.last_24_hours
35
+
36
+ # Admin / fraud triage β€” scopes are the product:
37
+ Sessions::Event.failed_logins.last_24_hours.group(:ip_address).count
38
+ Sessions::Event.for_identity("victim@example.com") # ATO investigation
39
+ Sessions::Event.by_country("RU").logins
40
+ ```
41
+
42
+ And the drop-in devices page:
43
+
44
+ ```
45
+ Your devices
46
+ ────────────────────────────────────────────────────────────
47
+ πŸ–₯ Chrome 137 on macOS β€” This device
48
+ πŸ‡ͺπŸ‡Έ Madrid, Spain Β· Active now Β· Signed in May 2 via Google
49
+
50
+ πŸ“± MyApp 2.4.1 on iPhone15,2 (iOS 19.5) [Log out]
51
+ πŸ‡ͺπŸ‡Έ Madrid, Spain Β· Active 3 minutes ago Β· Signed in Apr 28
52
+
53
+ [ Sign out of all other sessions ]
54
+
55
+ Login history
56
+ ────────────────────────────────────────────────────────────
57
+ βœ“ Signed in Β· Chrome on macOS Β· Madrid, Spain Β· today 09:12
58
+ βœ— Failed sign-in attempt (wrong credentials) Β· yesterday 23:48
59
+ ⊘ Session revoked (you signed out everywhere) · Apr 30
60
+ ```
61
+
62
+ ## Quickstart
63
+
64
+ ```ruby
65
+ # Gemfile
66
+ gem "sessions"
67
+ ```
68
+
69
+ ```bash
70
+ bundle install
71
+ rails generate sessions:install # detects Rails 8 auth vs Devise, writes the right migrations
72
+ rails db:migrate
73
+ ```
74
+
75
+ ```ruby
76
+ # app/models/user.rb
77
+ class User < ApplicationRecord
78
+ has_sessions
79
+ end
80
+ ```
81
+
82
+ ```ruby
83
+ # config/routes.rb β€” Rails 8 auth apps:
84
+ mount Sessions::Engine => "/settings/sessions"
85
+
86
+ # Devise apps β€” wrap the mount in your auth:
87
+ authenticate :user do
88
+ mount Sessions::Engine => "/settings/sessions"
89
+ end
90
+ ```
91
+
92
+ That's it. Every sign-in from now on lands on the devices page and in the trail β€” on Rails 8 auth there is literally nothing else to wire (the gem decorates the generated `Session` model automatically; your app code stays untouched).
93
+
94
+ ## What `sessions` does (and doesn't) do
95
+
96
+ **Does:**
97
+
98
+ - **Live device registry** β€” one row per signed-in device on the (Rails-8-shaped) `sessions` table, enriched with parsed device intelligence, geolocation, auth method, and a throttled `last_seen_at`.
99
+ - **Remote revocation that actually works** β€” destroy the row, and that device is logged out on its very next request, on both auth stacks. Revoking a Devise session also rotates remember-me credentials so a stolen long-lived cookie can't quietly revive it.
100
+ - **Append-only login trail** β€” logins, *failed* logins (with the typed identity, even for accounts that don't exist), logouts, revocations, expirations. Each trail row links to the live session it created: a suspicious login is one lookup away from the kill switch.
101
+ - **Every 2026 login method** β€” password and OAuth classify automatically (OmniAuth failures get captured too, via a composed `on_failure`); One Tap / passkeys / magic links / SSO take one `Sessions.tag` line.
102
+ - **Hotwire Native device intelligence** β€” platform, OS version, and (on Android) device model work with zero setup; add the [UA prefix convention](#-hotwire-native) for app versions and iOS hardware models.
103
+ - **Security hygiene as defaults** β€” revoke-on-password-change (OWASP ASVS 3.3.3), per-user session caps with oldest-eviction, opt-in idle/absolute timeouts with NIST presets, bounded trail retention with a generated sweep job.
104
+
105
+ **Doesn't:**
106
+
107
+ - **Authentication itself** β€” passwords, 2FA, lockout, sign-up. That's Rails auth / [Devise](https://github.com/heartcombo/devise) / rodauth; `sessions` observes whichever you chose and never replaces it.
108
+ - **Rate limiting** β€” Rails 8's `rate_limit` already guards the generated login (and this gem records when it trips); use rack-attack for more.
109
+ - **Send emails** β€” the `on_new_device` hook hands you the moment; your mailer ([goodmail](https://github.com/rameerez/goodmail), noticed) sends the "Was this you?" email.
110
+ - **API/token auth tracking** β€” that's [`api_keys`](https://github.com/rameerez/api_keys)' lane. Token-authenticated requests (Warden `store: false`) are deliberately never tracked as sessions.
111
+ - **Browser fingerprinting** β€” no canvas/WebGL/font probing, no probabilistic identifiers derived from UA/IP/client hints, ever (that's consent-gated under ePrivacy and would poison the drop-in pitch). Device identity is server-observed UA + IP **plus one honest first-party cookie**: the signed, random `sessions_device_id` browser-continuity cookie β€” minted only at login, never on anonymous visitors β€” that powers device dedup and the "Last used" badge. It's documented in full under [Security & privacy posture](#-security--privacy-posture).
112
+
113
+ ## πŸ–₯ The "Your devices" page
114
+
115
+ Three ways to ship it, pick your layer:
116
+
117
+ 1. **Mount the engine** (the quickstart) β€” a complete page: device list with "This device" badge, per-row Log out, "Sign out of all other sessions", login history. Semantic `sessions-*` classes with minimal styles; looks decent unstyled inside any Tailwind app. All copy through i18n (English + Spanish shipped).
118
+ 2. **Render the partials inside your own settings page** β€” no mount needed for display:
119
+
120
+ ```erb
121
+ <%= render "sessions/devices", user: current_user %>
122
+ <%= render "sessions/history", user: current_user, limit: 10 %>
123
+ ```
124
+
125
+ (Revoke buttons render when the engine is mounted; without it you get the read-only registry.)
126
+ 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).
127
+
128
+ The engine inherits from your `ApplicationController` (configurable via `config.parent_controller`), so your layout, auth and locale apply automatically. The current session is resolved on both stacks; destructive actions can be gated behind your own sudo/password-confirm flow with `config.require_reauthentication`. One heads-up: if your app layout leans heavily on host route helpers, isolated-engine rendering means those resolve through `main_app.*` β€” rendering the partials in your own page (layer 2) sidesteps the whole topic.
129
+
130
+ 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).
131
+
132
+ ## πŸ•΅οΈ The trail: every login attempt, kept honest
133
+
134
+ `Sessions::Event` is an append-only table written through an error-isolated pipeline:
135
+
136
+ ```ruby
137
+ Sessions::Event.logins / .failed_logins / .logouts / .revocations / .expirations
138
+ Sessions::Event.recent.last_days(90).for_ip("203.0.113.7")
139
+ event.session # the live row it created β€” nil once revoked (that's the point)
140
+ event.new_device? # flagged when the login matched no prior device
141
+ ```
142
+
143
+ Failed attempts record the **identity as typed** (normalized) even when no such account exists β€” brute-force and credential-stuffing triage needs exactly that β€” but they never link to an account (no enumeration oracle), never store the password, and store the auth stack's failure reason verbatim (Devise paranoid mode stays `:invalid`).
144
+
145
+ Tee every event into your own audit system with one line β€” `event.summary` is the audit-shaped projection (device, identity, reasons, ip, country; compacted, no raw blobs):
146
+
147
+ ```ruby
148
+ config.events = ->(event) do
149
+ AuditLog.log(event_type: "session.#{event.name}", user: event.user,
150
+ request: event.request, data: event.summary)
151
+ end
152
+ ```
153
+
154
+ And for custom UIs, events and sessions share the display vocabulary so you never re-derive it: `event.label` / `event.reason` / `event.reason_label` (localized), `event.device_name`, `event.source_line` (the location-first one-liner β€” "πŸ‡ͺπŸ‡Έ Madrid, Spain Β· IP 83.45.112.7 Β· Firefox 139 on Windows" β€” ready for security emails and notification bodies; pass `ip: false` for compact rows), `session.active_now?`, plus `sessions_device_icon_name(session)` / `sessions_event_icon_name(event)` view helpers (Heroicons-vocabulary names for whatever icon system you use).
155
+
156
+ ## πŸ“± Hotwire Native
157
+
158
+ Detection works out of the box (`Hotwire Native` UA marker β€” same contract as turbo-rails' `hotwire_native_app?`): platform, real OS version, and on Android the real device model (WebViews are exempt from Chrome's UA reduction). To also get **app version and iOS hardware model**, set the documented prefix convention in your shells:
159
+
160
+ ```swift
161
+ // iOS β€” AppDelegate, before creating the Navigator
162
+ var u = utsname(); uname(&u)
163
+ let model = withUnsafeBytes(of: &u.machine) { String(decoding: $0.prefix(while: { $0 != 0 }), as: UTF8.self) }
164
+ Hotwire.config.applicationUserAgentPrefix =
165
+ "MyApp/\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0") (\(model); iOS \(UIDevice.current.systemVersion));"
166
+ ```
167
+
168
+ ```kotlin
169
+ // Android β€” Application.onCreate, before any HotwireActivity
170
+ Hotwire.config.applicationUserAgentPrefix =
171
+ "MyApp/${BuildConfig.VERSION_NAME} (${Build.MODEL}; Android ${Build.VERSION.RELEASE}; build ${BuildConfig.VERSION_CODE});"
172
+ ```
173
+
174
+ Sessions now read "MyApp/2.4.1 (iPhone15,2; iOS 19.5; build 241)". Validated `X-Client-Platform/-Version/-Build/-OS` headers are honored too, and legacy prefixes (like `"MyApp Android 1.0.5 (build 6; Android 14; sdk 34; Pixel 7)"`) parse once you declare them: `config.native_app_names = ["MyApp"]`.
175
+
176
+ One identity rule the gem follows: a native device is **one cookie jar**, not one user agent β€” WebView navigations and native HTTP calls share the session, so they're one device row, not two.
177
+
178
+ ## 🏷 Auth methods: how each session started
179
+
180
+ Password and OAuth logins classify automatically (so do devise-passwordless magic links and remember-me re-auths). Flows that can't self-identify take one line *before* signing the user in:
181
+
182
+ ```ruby
183
+ # Google One Tap endpoint:
184
+ Sessions.tag(request, method: :google_one_tap, detail: { select_by: params[:select_by] })
185
+
186
+ # Passkey verification:
187
+ Sessions.tag(request, method: :passkey, detail: { user_verified: credential.user_verified? })
188
+ ```
189
+
190
+ …and custom failure paths (a native-app sign-in branch that renders 422s, a passkey `SignCountVerificationError` β€” a possible cloning signal) get the manual seam:
191
+
192
+ ```ruby
193
+ Sessions.record_failed_attempt(request, scope: :user, identity: params[:email],
194
+ reason: :invalid_password)
195
+ ```
196
+
197
+ Custom Warden strategies map with `config.strategy_methods = { "OtpAuthenticatable" => :otp }`. Everything else is `unknown` β€” the gem never guesses.
198
+
199
+ ### Two-factor flows (TOTP apps, security keys, Touch ID)
200
+
201
+ 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:
202
+
203
+ - **[devise-two-factor](https://github.com/devise-two-factor/devise-two-factor)** (GitLab/Mastodon-style TOTP + backup codes): **fully automatic.** It's single-phase β€” its strategy subclasses Devise's `DatabaseAuthenticatable` and consumes `params[scope][:otp_attempt]` in the same request as the password (its `strategies/two_factor_authenticatable.rb`), so Warden signs in exactly once. The gem classifies `password` and stamps `auth_detail: { second_factor: "totp" }` (or `"backup_code"` for `TwoFactorBackupable` wins) when a second factor was actually used.
204
+ - **[devise-otp](https://github.com/wmlele/devise-otp)**: two-phase. Its replaced `database_authenticatable` strategy `redirect!`s OTP-enabled users to a challenge instead of `success!` (no session yet β€” nothing recorded, correctly), and the challenge's `OtpCredentialsController#update` then calls plain `sign_in` with **no Warden strategy** β€” the row records, but classifies `unknown` (the gem never guesses). One initializer block labels it:
205
+
206
+ ```ruby
207
+ Rails.application.config.to_prepare do
208
+ DeviseOtp::Devise::OtpCredentialsController.before_action only: :update do
209
+ Sessions.tag(request, method: :password, detail: { second_factor: "totp" })
210
+ end
211
+ end
212
+ ```
213
+ - **[authentication-zero](https://github.com/lazaronixon/authentication-zero) `--two-factor`** (the generator Rails 8's authentication was modeled on): its `sessions` table is Rails-8-shaped, so the install generator adopts it and the model concern tracks every `user.sessions.create!` β€” which its challenge controllers call only **after** the second factor verifies (password-phase requests stash a signed `challenge_token` and create nothing). Tag each `create` so rows don't classify `unknown`:
214
+
215
+ ```ruby
216
+ Sessions.tag(request, method: :password) # SessionsController (password-only branch)
217
+ Sessions.skip!(request) # …and its challenge-redirect branch: the password
218
+ # was RIGHT β€” without this, the no-session outcome
219
+ # would read as a failed login
220
+ Sessions.tag(request, method: :password, detail: { second_factor: "totp" }) # Challenge::TotpsController
221
+ Sessions.tag(request, method: :password, detail: { second_factor: "webauthn" }) # Challenge::SecurityKeysController
222
+ Sessions.tag(request, method: :password, detail: { second_factor: "recovery_code" }) # Challenge::RecoveryCodesController
223
+ ```
224
+ - **[webauthn-rails](https://github.com/cedarcode/webauthn-rails) second-factor mode** (YubiKeys, Touch ID, Windows Hello β€” all WebAuthn authenticators): the session starts in `SecondFactorAuthenticationsController#create` after verification β€” tag it there:
225
+
226
+ ```ruby
227
+ Sessions.tag(request, method: :password, detail: { second_factor: "webauthn" })
228
+ start_new_session_for user
229
+ ```
230
+ - **[devise-passkeys](https://github.com/ruby-passkeys/devise-passkeys) / [warden-webauthn](https://github.com/ruby-passkeys/warden-webauthn)** (passkey-**first**, passwordless): **fully automatic.** That's not a second factor, it's the method β€” their `PasskeyAuthenticatable` / `Warden::WebAuthn::Strategy` strategies classify as `passkey` by name. devise-passkeys' sudo confirm (`reauthenticate`, a `sign_in` with `event: :passkey_reauthentication`) is recognized as a reauthentication of the live session β€” never a duplicate device row.
231
+ - **[rotp](https://github.com/mdp/rotp) / [active_model_otp](https://github.com/heapsource/active_model_otp)** (the DIY primitives β€” pure TOTP math / model mixin, no strategies, no controllers): your controllers own the flow, so label it at the seam that fits. Verifying **before** creating the session: `Sessions.tag(request, method: :password, detail: { second_factor: "totp" })`. A **post-login step-up gate** (session already live, OTP unlocks sensitive areas): `Sessions.current(request)&.second_factor!("totp")`.
232
+ - **Email/SMS login codes**: `Sessions.tag(request, method: :otp)`.
233
+
234
+ Either way, `session.second_factor?` / `session.second_factor` (also on events) answer "was this login 2FA-protected?" β€” useful for step-up gates and admin triage. Failed second-factor attempts surface through the same seams as everything else: devise-two-factor failures land in the trail automatically (Warden failure, message verbatim); WebAuthn rescues should call `Sessions.record_failed_attempt(request, reason: e.class.name, method: :password, detail: { second_factor: "webauthn" })` β€” a `SignCountVerificationError` there is a possible credential-cloning signal worth alerting on.
235
+
236
+ ### The "Last used" badge (no JavaScript required)
237
+
238
+ The conversion classic β€” a little "Last used" pill next to the sign-in button this browser used last time. Most implementations reach for localStorage and a sprinkle of JS; `sessions` answers it **server-side** with one lookup, because the signed browser-continuity cookie (the same one that deduplicates devices) survives logout by design:
239
+
240
+ ```erb
241
+ <% last_login = Sessions.last_login(request) %>
242
+
243
+ <%= button_to "Sign in with Google", ... %>
244
+ <% if last_login&.auth_method == "oauth" && last_login.auth_provider == "google" %>
245
+ <span class="badge">Last used</span>
246
+ <% end %>
247
+
248
+ <%= button_to "Sign in with passkey", ... %>
249
+ <% if last_login&.auth_method == "passkey" %>
250
+ <span class="badge">Last used</span>
251
+ <% end %>
252
+ ```
253
+
254
+ `last_login` returns the most recent login **event** from this browser (or nil for browsers that never signed in, cleared cookies, or tampered values β€” the cookie is signed), so you also get `auth_method_label` for copy and `occurred_at` for "last used 2 days ago". It's device-scoped, not account-scoped β€” it reflects whoever last signed in from this browser, which is exactly what a signed-out login page can honestly know β€” and it's read-only: it never mints the cookie.
255
+
256
+ > [!NOTE]
257
+ > If you fragment- or page-cache your login page, render the badge outside the cached fragment β€” it's per-browser by nature.
258
+
259
+ ### Repeated failed attempts ("someone is trying to get in")
260
+
261
+ Per-attempt alerts are notification fatigue *and* an abuse vector (an attacker hammering the form would flood the victim's inbox), so the gem ships **threshold-crossing** detection instead β€” the hook fires exactly once when an identity crosses the line inside the window:
262
+
263
+ ```ruby
264
+ config.repeated_failed_logins = { threshold: 5, within: 15.minutes }
265
+ config.on_repeated_failed_logins = ->(identity:, count:, event:) do
266
+ user = User.find_by(email: identity) or next # identity is AS TYPED β€” may match no account
267
+ SecurityMailer.with(user: user, event: event).repeated_failed_logins.deliver_later
268
+ end
269
+ ```
270
+
271
+ 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*.
272
+
273
+ ## 🌍 Geolocation (via `trackdown`, soft dependency)
274
+
275
+ If the [`trackdown`](https://github.com/rameerez/trackdown) gem is in your bundle, sessions and events get country/city automatically: behind Cloudflare the answer is read synchronously from request headers (free); with a MaxMind database, lookups run asynchronously in `Sessions::GeolocateJob` so logins never wait on geo. No trackdown β†’ locations stay blank and the UI omits them cleanly.
276
+
277
+ > [!IMPORTANT]
278
+ > Locations are approximate by nature (they come from the IP) and the page labels them as such. Coordinates are only stored on trail events, precision-reduced (~1km) by default.
279
+
280
+ **Behind Cloudflare?** `request.remote_ip` returns a Cloudflare edge IP unless CF ranges are trusted. Best: add [cloudflare-rails](https://github.com/modosc/cloudflare-rails) and everything just works. Alternatively set `config.ip_resolver = ->(request) { request.headers["CF-Connecting-IP"] || request.remote_ip }` β€” but only if your origin is unreachable except through Cloudflare.
281
+
282
+ ## πŸ”” The "Was this you?" moment
283
+
284
+ When a login matches no device the user has signed in from before (coarse, server-observed matching β€” never fingerprinting), the gem hands you the moment; you send the email:
285
+
286
+ ```ruby
287
+ config.on_new_device = ->(user:, session:, event:) do
288
+ SecurityMailer.with(event: event).new_device.deliver_later
289
+ end
290
+ ```
291
+
292
+ Pass the **event** to your mailer, not the session: the event is a persisted, GlobalID-able record that survives revocation (the session row may already be destroyed by the time an async job runs), and it carries everything the email needs β€” `event.user`, `event.device_name`, `event.location`, `event.country_flag`, `event.source_line`, `event.occurred_at`:
293
+
294
+ ```ruby
295
+ class SecurityMailer < ApplicationMailer
296
+ def new_device
297
+ @event = params.fetch(:event)
298
+ mail to: @event.user.email, subject: "New sign-in from #{@event.device_name}. Was this you?"
299
+ end
300
+ end
301
+ ```
302
+
303
+ A user's very first login doesn't fire it (nobody wants a security alert on signup), and like every hook in this gem it's error-isolated: a broken mailer can never break a login.
304
+
305
+ **In-app notifications too?** Don't fan out from the hook β€” that's your notification system's job. With [noticed](https://github.com/excid3/noticed), one notifier owns every channel (feed row, push, email) and the hook stays one line:
306
+
307
+ ```ruby
308
+ config.on_new_device = ->(user:, session:, event:) do
309
+ NewDeviceNotifier.with(record: event, event: event).deliver(user)
310
+ end
311
+
312
+ class NewDeviceNotifier < Noticed::Event
313
+ deliver_by :email do |config|
314
+ config.mailer = "SecurityMailer"
315
+ config.method = :new_device
316
+ config.if = -> { recipient.email.present? }
317
+ end
318
+
319
+ notification_methods do
320
+ # NOT `def event` β€” Noticed::Notification delegates #record to its own
321
+ # `event` association (the Noticed::Event row); shadowing it recurses.
322
+ def session_event = record
323
+ def title = "New sign-in"
324
+ def body = "New sign-in from #{session_event&.source_line(ip: false)}. Was this you?"
325
+ def url = "/settings/sessions"
326
+ end
327
+ end
328
+ ```
329
+
330
+ The trail event is the `record:` β€” persisted and GlobalID-safe, so delivery jobs render fine even after the session row is revoked, and the feed preloads it without N+1s. (X and Google run exactly this pattern: a new-device login lands in the notifications tab of every other device you're still signed in on.)
331
+
332
+ ## πŸ›  Admin: triage surfaces in one command
333
+
334
+ Scopes are the admin product β€” `Sessions::Event.failed_logins.last_24_hours.group(:ip_address).count` works in any console or admin framework. If your app uses [madmin](https://github.com/excid3/madmin):
335
+
336
+ ```bash
337
+ rails generate sessions:madmin
338
+ ```
339
+
340
+ …generates the two resources (the live registry with a per-row **Revoke session** action, and the login trail with its triage scopes as filters) plus their controllers, with madmin's two namespacing footguns pre-solved. The generated files use only stock madmin APIs and are yours to restyle. For a per-user security panel (devices + trail on the user's show page), load `user.sessions.by_recency` and `user.session_events.recent` in a member action β€” including the user's *failed* attempts by matching `Sessions::Event.where(identity: Sessions::Event.normalize_identity(user.email))` (failures never link to accounts; matching the signed-in user's own identity is the safe way to show them).
341
+
342
+ ## 🧹 Retention & the sweep
343
+
344
+ The install generator drops a `SessionsSweepJob` into `app/jobs/` β€” schedule it daily:
345
+
346
+ ```yaml
347
+ # config/recurring.yml (Solid Queue)
348
+ production:
349
+ sessions_sweep:
350
+ class: SessionsSweepJob
351
+ schedule: every day at 4am
352
+ ```
353
+
354
+ It purges trail rows past `config.events_retention` (12 months by default β€” CNIL's recommendation for security logs), evicts per-user overflow beyond `config.max_sessions_per_user` (100, GitLab's number), and β€” only if you opted into `config.idle_timeout` / `config.max_session_lifetime` (or `config.timeout_preset = :nist_aal2`) β€” expires stale sessions. Worth knowing: the Rails 8 auth cookie lives 20 years, so this sweep is the only real session expiry most omakase apps will ever have.
355
+
356
+ ## πŸ” Security & privacy posture
357
+
358
+ - **Tracking never breaks login.** Every adapter path, parser, geo lookup and hook is error-isolated; the test suite includes a chaos test that detonates every pipeline stage at once and asserts sign-in still works.
359
+ - **Tracking outages fail OPEN.** A revoked session is a row that's *gone*; an *errored* lookup (sessions table unreachable, a migration mid-deploy, a timeout) is an outage β€” the request proceeds untracked instead of logging anyone out. Kicks are scope-precise, too: revoking a user session never touches an admin scope riding the same rack session, or your cart/locale data.
360
+ - **The trail rejects rewrites.** Normal Active Record mutations on events are blocked β€” `update`/`destroy` raise `ActiveRecord::ReadOnlyRecord`. The callback-bypassing APIs (`update_columns`, `delete_all`) remain available, because the gem's own sanctioned paths use them: async geo backfill, `Sessions.forget`'s GDPR scrub, the retention sweep. Append-only at the model-contract level β€” not a database constraint, and a host determined to rewrite history still can.
361
+ - **No usable credential is ever persisted.** Devise-mode session tokens are random 32-byte values stored as SHA-256 digests; the raw token lives only in the user's own session. Rails-8-mode rows store nothing secret (the signed cookie is the credential). Nothing secret is ever logged.
362
+ - **Revocation is server-side and immediate** (checked on the very next request, both stacks) β€” OWASP ASVS 7.4.1; "view and terminate any or all currently active sessions" is literally ASVS 3.3.4 / 7.5.2, the requirement this gem exists to satisfy.
363
+ - **IPs and UAs are personal data** (GDPR Recital 30), processed under the network-security legitimate interest (Recital 49 / Art. 6(1)(f)). The gem ships bounded retention, optional IP truncation *before persistence* (`config.ip_mode = :truncated` β€” zeroes the last IPv4 octet / 80 IPv6 bits, the Google Analytics precedent), data minimization (no bodies, no referrers), and `Sessions.forget(user)` for erasure requests.
364
+ - **The browser-continuity cookie, stated plainly.** `sessions_device_id` is a signed random UUID β€” no fingerprint material in it β€” set **only when someone signs in** (anonymous visitors never get one), with a 5-year lifetime that **survives logout by design**: that's what lets a re-login replace its old device row instead of stacking duplicates, and what powers the signed-out "Last used" badge. It identifies the *browser installation*, not the person β€” two accounts sharing a browser share it (each keeps their own rows). The id is stored on session rows and login events, so it lives under the same retention sweep as everything else; `Sessions.forget(user)` removes the user's rows and trail, and clearing browser cookies orphans the id entirely (an id with no rows resolves to nothing). It's a security-purpose first-party cookie (session integrity/dedup), which most ePrivacy readings treat as strictly-necessary rather than consent-gated β€” but it exists, it persists, and your privacy policy should mention it.
365
+ - Want encryption at rest? `Session.encrypts :ip_address, deterministic: true` (deterministic keeps equality queries working β€” the documented Rails tradeoff) and non-deterministic for `user_agent`.
366
+
367
+ ## Configuration reference
368
+
369
+ Everything lives in one annotated initializer (`config/initializers/sessions.rb`, written by the install generator). The defaults work untouched:
370
+
371
+ ```ruby
372
+ Sessions.configure do |config|
373
+ # β€” Behavior β€”
374
+ config.touch_every = 5.minutes # last_seen_at throttle (nil = never touch)
375
+ config.max_sessions_per_user = 100 # oldest-eviction; nil = unlimited
376
+ config.idle_timeout = nil # opt-in expiry…
377
+ config.max_session_lifetime = nil # …or config.timeout_preset = :nist_aal2
378
+ config.revoke_on_password_change = true # ASVS 3.3.3
379
+ config.revoke_remember_me = true # Devise: revoke also rotates remember-me
380
+ config.track_failed_logins = true
381
+
382
+ # β€” Device intelligence β€”
383
+ config.ua_parser = :browser # :device_detector | ->(ua, headers) { {...} }
384
+ config.request_client_hints = false # Accept-CH for real platform versions / Android models
385
+ config.native_app_names = [] # legacy native UA prefixes to recognize
386
+
387
+ # β€” IP & geo β€”
388
+ config.ip_resolver = ->(request) { request.remote_ip }
389
+ config.ip_mode = :full # | :truncated (anonymize before persistence)
390
+ config.geolocate = :auto # trackdown when present | :off
391
+ config.geo_precision = 2 # lat/lng decimals on events (~1km)
392
+
393
+ # β€” Retention β€”
394
+ config.events_retention = 12.months # trail purge horizon (nil = keep forever)
395
+
396
+ # β€” Hooks (kwargs, no-op defaults, error-isolated) β€”
397
+ config.on_new_device = ->(user:, session:, event:) {}
398
+ config.on_session_revoked = ->(session:, by:, reason:) {}
399
+ config.events = ->(event) {} # catch-all tee β†’ AuditLog / analytics
400
+
401
+ # β€” Integration β€”
402
+ config.parent_controller = "::ApplicationController"
403
+ config.current_user_method = :current_user # chain: this β†’ current_user β†’ Current.session&.user
404
+ config.authenticate_method = :authenticate_user!
405
+ config.layout = nil # nil inherits the parent controller's layout
406
+ config.require_reauthentication = nil # ->(controller) { ... } sudo gate
407
+ config.session_class = "Session"
408
+ config.strategy_methods = {} # { "OtpAuthenticatable" => :otp }
409
+ end
410
+ ```
411
+
412
+ ## 🧱 Why the models?
413
+
414
+ Two primitives, linked β€” **rows are active sessions; events are history**:
415
+
416
+ - **`sessions`** (the registry β€” *your* table, Rails-8-shaped on both stacks): one row = one signed-in device. Destroyed on logout/revocation/expiry, which is what makes revocation instant β€” both adapters resolve the row on every request, so a missing row *is* a remote logout. No soft-delete state machine.
417
+ - **`sessions_events`** (the trail β€” gem-owned, append-only): what happened and from where, surviving the rows it describes. Its `session_id` is a plain column with no foreign key *on purpose*: history must outlive the registry.
418
+
419
+ On Rails 8 auth, the gem **adopts** the generated table and model: one migration adds columns (the `add_devise_to_users` precedent), and the 2-line `Session` model is decorated via a concern at boot β€” your generated code stays byte-identical. On Devise, the install generator creates the same Rails-8-shaped table and a 3-line shell model β€” so if you ever migrate Devise β†’ Rails auth, your sessions table is already exactly where Rails expects it.
420
+
421
+ ## Why this gem exists
422
+
423
+ Rails 8's authentication generator creates a database-backed `sessions` table with `ip_address` and `user_agent` on every row β€” and then ships zero UI, no session listing, no per-device revocation, no failed-login log. The Rails security guide literally recommends a `Session.sweep` based on `updated_at` *that the generated code can never satisfy*, because nothing ever touches a session row after creation. Devise stores even less: two sign-in slots on the users table, overwritten on every login, with cookie sessions that are unenumerable and unrevocable server-side β€” people have been asking for a decade.
424
+
425
+ Meanwhile Laravel ships "Browser Sessions" in its starter kit, Phoenix's `mix phx.gen.auth` tracks every session token in a table, OWASP ASVS makes "view and terminate your sessions" a Level-2 requirement, and GitLab, Mastodon and Discourse have each independently hand-rolled (and maintain, forever) this exact feature set. Every serious Rails app eventually rebuilds the same thing: a sessions page, a login trail, a revocation mechanism, a device parser. `sessions` is that rebuild, done once, done right β€” on top of the auth you already own, never instead of it.
426
+
427
+ ## Database support
428
+
429
+ PostgreSQL (including PostGIS), MySQL, and SQLite. The migrations adapt automatically: they honor your app's configured primary key type (**uuid or bigint** β€” same detection `rails g model` uses) and pick `jsonb` on Postgres / `json` elsewhere, resolved at migration run time so one migration file survives a dev-SQLite/prod-Postgres split. Works on Rails 7.1+ and shines on the Rails 8 omakase.
430
+
431
+ ## Testing
432
+
433
+ The gem is tested with Minitest against a real dummy host app whose auth files are **vendored verbatim from `rails generate authentication`** β€” so the adapter's duck-detection and prepends run against the actual generated shapes, and upstream template drift breaks CI here instead of login there. The Warden adapter runs against a real `Warden::Manager` rack stack (the exact hook ABI Devise rides), and a chaos test detonates every hook and pipeline stage at once to prove sign-in survives.
434
+
435
+ ```bash
436
+ bundle exec rake test # full suite
437
+ bundle exec appraisal install # then test across Rails versions:
438
+ bundle exec appraisal rails-7.1 rake test
439
+ bundle exec appraisal rails-8.1 rake test
440
+ ```
441
+
442
+ **Testing your own app with the engine mounted** β€” one Rails gotcha worth knowing: in an integration test, after a request to any engine route, the test session keeps that request's `url_options` (including the engine's mount point as `script_name`), so *host* route helpers called next generate prefixed paths (`settings_path` β†’ `/settings/sessions/settings`). Use literal paths (`get "/settings"`) after driving engine routes β€” real requests are unaffected (script_name resolves per request).
443
+
444
+ ## Development
445
+
446
+ After checking out the repo, run `bundle install`, then `bundle exec rake test`. The dummy app lives in `test/dummy` and mounts the engine at `/settings/sessions` exactly like a real host.
447
+
448
+ ## Contributing
449
+
450
+ Bug reports and pull requests are welcome on GitHub at https://github.com/rameerez/sessions. Our code of conduct is: just be nice and make your mom proud of what you do and post online.
451
+
452
+ ## License
453
+
454
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "bundler/setup"
5
+ rescue LoadError
6
+ puts "You must `gem install bundler` and `bundle install` to run rake tasks"
7
+ end
8
+
9
+ require "bundler/gem_tasks"
10
+
11
+ require "rdoc/task"
12
+
13
+ RDoc::Task.new(:rdoc) do |rdoc|
14
+ rdoc.rdoc_dir = "rdoc"
15
+ rdoc.title = "Sessions"
16
+ rdoc.options << "--line-numbers"
17
+ rdoc.rdoc_files.include("README.md")
18
+ rdoc.rdoc_files.include("lib/**/*.rb")
19
+ end
20
+
21
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
22
+ load "rails/tasks/engine.rake"
23
+
24
+ require "rake/testtask"
25
+
26
+ Rake::TestTask.new(:test) do |t|
27
+ t.libs << "test"
28
+ t.pattern = "test/**/*_test.rb"
29
+ t.verbose = false
30
+ end
31
+
32
+ task default: :test
@@ -0,0 +1,50 @@
1
+ /* sessions β€” minimal, semantic defaults for the devices page.
2
+ Every element carries a `sessions-*` class; restyle freely (Tailwind
3
+ hosts usually just eject the views with `rails g sessions:views`). */
4
+
5
+ .sessions-page { max-width: 40rem; margin: 0 auto; }
6
+ .sessions-title { font-size: 1.5rem; margin-bottom: 1rem; }
7
+ .sessions-subtitle { font-size: 1.125rem; margin: 0; }
8
+
9
+ .sessions-device-list, .sessions-event-list { list-style: none; margin: 0; padding: 0; }
10
+
11
+ .sessions-device {
12
+ display: flex; align-items: center; gap: 0.75rem;
13
+ padding: 0.75rem 0.5rem; border-bottom: 1px solid rgba(128, 128, 128, 0.25);
14
+ }
15
+ .sessions-device-current { background: rgba(128, 128, 128, 0.07); border-radius: 0.5rem; }
16
+ .sessions-device-icon { font-size: 1.5rem; line-height: 1; }
17
+ .sessions-device-info { flex: 1; min-width: 0; }
18
+ .sessions-device-name { margin: 0; font-weight: 600; }
19
+ .sessions-device-meta { margin: 0.15rem 0 0; font-size: 0.85rem; opacity: 0.75; }
20
+
21
+ .sessions-badge {
22
+ display: inline-block; flex-shrink: 0; padding: 0.25rem 0.65rem;
23
+ font-size: 0.7rem; font-weight: 600; border-radius: 999px; white-space: nowrap;
24
+ background: rgba(46, 160, 67, 0.15); color: #2ea043;
25
+ }
26
+
27
+ .sessions-method-pill {
28
+ display: inline-block; margin-left: 0.35rem; padding: 0.1rem 0.5rem;
29
+ font-size: 0.68rem; font-weight: 500; border-radius: 999px; vertical-align: middle;
30
+ background: rgba(128, 128, 128, 0.12); color: rgba(60, 60, 60, 0.85);
31
+ }
32
+
33
+ .sessions-button {
34
+ padding: 0.35rem 0.85rem; font-size: 0.85rem; border-radius: 0.4rem;
35
+ border: 1px solid rgba(128, 128, 128, 0.4); background: transparent; cursor: pointer;
36
+ }
37
+ .sessions-button-revoke:hover { border-color: #d1242f; color: #d1242f; }
38
+ .sessions-revoke-others { margin-top: 1rem; text-align: center; }
39
+
40
+ .sessions-history { margin-top: 2.5rem; }
41
+ .sessions-history-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 0.5rem; }
42
+ .sessions-history-link { font-size: 0.85rem; }
43
+
44
+ .sessions-event { padding: 0.45rem 0.5rem; font-size: 0.875rem; border-bottom: 1px solid rgba(128, 128, 128, 0.15); }
45
+ .sessions-event-icon { display: inline-block; width: 1.25rem; }
46
+ .sessions-event-failed_login .sessions-event-icon { color: #d1242f; }
47
+ .sessions-event-login .sessions-event-icon { color: #2ea043; }
48
+ .sessions-event-reason, .sessions-event-device, .sessions-event-location, .sessions-event-time { opacity: 0.75; }
49
+
50
+ .sessions-empty { opacity: 0.7; padding: 1rem 0.5rem; }