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.
- checksums.yaml +7 -0
- data/.rubocop.yml +61 -0
- data/.simplecov +54 -0
- data/AGENTS.md +5 -0
- data/Appraisals +26 -0
- data/CHANGELOG.md +26 -0
- data/CLAUDE.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +454 -0
- data/Rakefile +32 -0
- data/app/assets/stylesheets/sessions.css +50 -0
- data/app/controllers/sessions/application_controller.rb +159 -0
- data/app/controllers/sessions/devices_controller.rb +48 -0
- data/app/helpers/sessions/engine_helper.rb +126 -0
- data/app/views/sessions/_device.html.erb +40 -0
- data/app/views/sessions/_devices.html.erb +34 -0
- data/app/views/sessions/_event.html.erb +13 -0
- data/app/views/sessions/_history.html.erb +20 -0
- data/app/views/sessions/devices/history.html.erb +5 -0
- data/app/views/sessions/devices/index.html.erb +15 -0
- data/config/locales/en.yml +59 -0
- data/config/locales/es.yml +59 -0
- data/config/routes.rb +17 -0
- data/docs/PRD.md +743 -0
- data/docs/research/01-carhey.md +250 -0
- data/docs/research/02-ecosystem.md +261 -0
- data/docs/research/03-rails-core.md +220 -0
- data/docs/research/04-devise-warden.md +249 -0
- data/docs/research/05-oauth.md +193 -0
- data/docs/research/06-prior-art.md +312 -0
- data/docs/research/07-device-detection.md +250 -0
- data/docs/research/08-rails8-landscape.md +216 -0
- data/docs/research/09-market-security.md +450 -0
- data/gemfiles/rails_7.1.gemfile +34 -0
- data/gemfiles/rails_7.2.gemfile +34 -0
- data/gemfiles/rails_8.0.gemfile +34 -0
- data/gemfiles/rails_8.1.gemfile +34 -0
- data/lib/generators/sessions/install_generator.rb +230 -0
- data/lib/generators/sessions/madmin_generator.rb +95 -0
- data/lib/generators/sessions/templates/add_sessions_columns.rb.erb +81 -0
- data/lib/generators/sessions/templates/create_sessions.rb.erb +101 -0
- data/lib/generators/sessions/templates/create_sessions_events.rb.erb +108 -0
- data/lib/generators/sessions/templates/initializer.rb +201 -0
- data/lib/generators/sessions/templates/madmin/event_resource.rb +77 -0
- data/lib/generators/sessions/templates/madmin/session_events_controller.rb +21 -0
- data/lib/generators/sessions/templates/madmin/session_resource.rb +65 -0
- data/lib/generators/sessions/templates/madmin/sessions_controller.rb +30 -0
- data/lib/generators/sessions/templates/session.rb.erb +14 -0
- data/lib/generators/sessions/templates/sessions_sweep_job.rb +20 -0
- data/lib/generators/sessions/views_generator.rb +33 -0
- data/lib/sessions/adapters/omakase.rb +195 -0
- data/lib/sessions/adapters/omniauth.rb +64 -0
- data/lib/sessions/adapters/warden.rb +293 -0
- data/lib/sessions/classifier.rb +208 -0
- data/lib/sessions/configuration.rb +441 -0
- data/lib/sessions/current.rb +20 -0
- data/lib/sessions/device.rb +411 -0
- data/lib/sessions/engine.rb +120 -0
- data/lib/sessions/errors.rb +24 -0
- data/lib/sessions/geolocation.rb +111 -0
- data/lib/sessions/ip_address.rb +56 -0
- data/lib/sessions/jobs/geolocate_job.rb +58 -0
- data/lib/sessions/macros.rb +26 -0
- data/lib/sessions/middleware.rb +41 -0
- data/lib/sessions/models/concerns/device_display.rb +134 -0
- data/lib/sessions/models/concerns/has_sessions.rb +116 -0
- data/lib/sessions/models/concerns/model.rb +513 -0
- data/lib/sessions/models/event.rb +293 -0
- data/lib/sessions/version.rb +5 -0
- data/lib/sessions.rb +423 -0
- metadata +225 -0
data/README.md
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
# π `sessions` - GitHub-style device management & login tracking for Rails
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/rb/sessions) [](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; }
|