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
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Sessions.configure do |config|
|
|
4
|
+
# ==========================================================================
|
|
5
|
+
# BEHAVIOR
|
|
6
|
+
# ==========================================================================
|
|
7
|
+
#
|
|
8
|
+
# How often `last_seen_at` may be written per session — ONE conditional
|
|
9
|
+
# UPDATE, at most once per window (hot-row safe; this is what powers
|
|
10
|
+
# "Active 3 minutes ago" and the staleness sweep). nil disables touching.
|
|
11
|
+
#
|
|
12
|
+
# config.touch_every = 5.minutes
|
|
13
|
+
#
|
|
14
|
+
# Per-user live-session cap with oldest-eviction (GitLab keeps 100,
|
|
15
|
+
# Discourse 60). Evicted sessions land in the trail as revoked (:pruned).
|
|
16
|
+
# nil = unlimited.
|
|
17
|
+
#
|
|
18
|
+
# config.max_sessions_per_user = 100
|
|
19
|
+
#
|
|
20
|
+
# Opt-in session expiry — both default to nil because a tracking gem must
|
|
21
|
+
# never silently shorten anyone's sessions. When set, expiry is enforced
|
|
22
|
+
# at session resume AND by the SessionsSweepJob. Heads-up for Rails 8 auth
|
|
23
|
+
# apps: the generated cookie lives 20 years, so this sweep is the only
|
|
24
|
+
# real expiry your sessions will ever have.
|
|
25
|
+
#
|
|
26
|
+
# config.idle_timeout = nil # e.g. 1.hour
|
|
27
|
+
# config.max_session_lifetime = nil # e.g. 24.hours
|
|
28
|
+
# config.timeout_preset = :nist_aal2 # sugar: sets both (24h / 1h, NIST 800-63B)
|
|
29
|
+
#
|
|
30
|
+
# Changing a password revokes the user's other sessions (ASVS 3.3.3 —
|
|
31
|
+
# Laravel, Phoenix, and Rails 8.1's own password reset all do this).
|
|
32
|
+
#
|
|
33
|
+
# config.revoke_on_password_change = true
|
|
34
|
+
#
|
|
35
|
+
# Devise mode: revoking a session also rotates the user's remember-me
|
|
36
|
+
# credentials, so a stolen long-lived remember cookie can't quietly revive
|
|
37
|
+
# a revoked device (GitLab semantics; user-wide — other live sessions stay
|
|
38
|
+
# alive but can't auto-revive after they end).
|
|
39
|
+
#
|
|
40
|
+
# config.revoke_remember_me = true
|
|
41
|
+
#
|
|
42
|
+
# Record failed login attempts (with the typed identity, never the
|
|
43
|
+
# password) in the trail.
|
|
44
|
+
#
|
|
45
|
+
# config.track_failed_logins = true
|
|
46
|
+
#
|
|
47
|
+
# Burst detection: fire on_repeated_failed_logins (see HOOKS below) ONCE
|
|
48
|
+
# when an identity crosses the threshold inside the window — never per
|
|
49
|
+
# attempt (that would be notification fatigue and an inbox-flooding
|
|
50
|
+
# vector). Complements :lockable / rate limiting: they stop the attacker,
|
|
51
|
+
# this tells the user. nil disables.
|
|
52
|
+
#
|
|
53
|
+
# config.repeated_failed_logins = { threshold: 5, within: 15.minutes }
|
|
54
|
+
|
|
55
|
+
# ==========================================================================
|
|
56
|
+
# DEVICE INTELLIGENCE
|
|
57
|
+
# ==========================================================================
|
|
58
|
+
#
|
|
59
|
+
# The web UA parser. :browser (bundled, MIT, tiny) covers "Chrome on
|
|
60
|
+
# macOS"-grade names; :device_detector auto-upgrades device naming if your
|
|
61
|
+
# app bundles that gem; or bring your own lambda.
|
|
62
|
+
#
|
|
63
|
+
# config.ua_parser = :browser # :device_detector | ->(ua, headers) { {...} }
|
|
64
|
+
#
|
|
65
|
+
# Advertise Accept-CH so Chromium browsers send high-entropy client hints
|
|
66
|
+
# (real platform versions, Android device models) — Safari/Firefox don't
|
|
67
|
+
# implement client hints and stay UA-only either way.
|
|
68
|
+
#
|
|
69
|
+
# config.request_client_hints = false
|
|
70
|
+
#
|
|
71
|
+
# Hotwire Native apps are detected automatically; if your native shells
|
|
72
|
+
# use a legacy UA prefix (like "MyApp Android 1.0.5 (build 6; …)") on
|
|
73
|
+
# raw HTTP clients, declare the app name so those parse too. (The
|
|
74
|
+
# documented `AppName/1.2.3 (model; OS ver; build N);` convention from the
|
|
75
|
+
# README always parses without configuration.)
|
|
76
|
+
#
|
|
77
|
+
# config.native_app_names = ["MyApp"]
|
|
78
|
+
|
|
79
|
+
# ==========================================================================
|
|
80
|
+
# IP & GEOLOCATION
|
|
81
|
+
# ==========================================================================
|
|
82
|
+
#
|
|
83
|
+
# How to read the client IP. The default honors Rails' trusted_proxies
|
|
84
|
+
# (`request.remote_ip`). Behind Cloudflare, prefer the cloudflare-rails
|
|
85
|
+
# gem (remote_ip just works); reading CF-Connecting-IP directly is only
|
|
86
|
+
# safe when your origin is unreachable except through Cloudflare:
|
|
87
|
+
#
|
|
88
|
+
# config.ip_resolver = ->(request) { request.headers["CF-Connecting-IP"] || request.remote_ip }
|
|
89
|
+
#
|
|
90
|
+
# Privacy hardening: :truncated zeroes the last IPv4 octet / 80 IPv6 bits
|
|
91
|
+
# BEFORE persistence (the Google Analytics precedent) — nothing
|
|
92
|
+
# un-truncated ever touches disk.
|
|
93
|
+
#
|
|
94
|
+
# config.ip_mode = :full # | :truncated
|
|
95
|
+
#
|
|
96
|
+
# Geolocation through the trackdown gem when it's installed (Cloudflare
|
|
97
|
+
# headers resolve synchronously for free; MaxMind lookups run async in
|
|
98
|
+
# Sessions::GeolocateJob). Without trackdown, locations simply stay blank.
|
|
99
|
+
#
|
|
100
|
+
# config.geolocate = :auto # | :off
|
|
101
|
+
#
|
|
102
|
+
# Decimals kept on event coordinates (2 ≈ 1km).
|
|
103
|
+
#
|
|
104
|
+
# config.geo_precision = 2
|
|
105
|
+
|
|
106
|
+
# ==========================================================================
|
|
107
|
+
# RETENTION (the trail is personal data — keep it bounded)
|
|
108
|
+
# ==========================================================================
|
|
109
|
+
#
|
|
110
|
+
# How long sessions_events rows live before SessionsSweepJob purges them.
|
|
111
|
+
# CNIL recommends 6–12 months for security logs. nil keeps them forever
|
|
112
|
+
# (you own the purge).
|
|
113
|
+
#
|
|
114
|
+
# config.events_retention = 12.months
|
|
115
|
+
|
|
116
|
+
# ==========================================================================
|
|
117
|
+
# HOOKS — kwargs, no-op defaults, error-isolated (a broken hook can never
|
|
118
|
+
# break a login)
|
|
119
|
+
# ==========================================================================
|
|
120
|
+
#
|
|
121
|
+
# A login from a device this user has never used before — wire your
|
|
122
|
+
# "Was this you?" email here. Not fired on a user's very first login.
|
|
123
|
+
#
|
|
124
|
+
# PASS THE EVENT to your mailer, not the session: the event is a
|
|
125
|
+
# persisted, GlobalID-able record that survives revocation (the session
|
|
126
|
+
# row may be destroyed before an async job runs) and already carries
|
|
127
|
+
# everything the email needs — event.user, event.device_name,
|
|
128
|
+
# event.location, event.country_flag, event.occurred_at.
|
|
129
|
+
#
|
|
130
|
+
# config.on_new_device = ->(user:, session:, event:) do
|
|
131
|
+
# SecurityMailer.with(event: event).new_device.deliver_later
|
|
132
|
+
# end
|
|
133
|
+
#
|
|
134
|
+
# config.on_session_revoked = ->(session:, by:, reason:) do
|
|
135
|
+
# Rails.logger.info("session revoked (#{reason}) by #{by.inspect}")
|
|
136
|
+
# end
|
|
137
|
+
#
|
|
138
|
+
# Someone crossed the repeated_failed_logins threshold (see BEHAVIOR
|
|
139
|
+
# above). The identity is the email AS TYPED — it may match no account,
|
|
140
|
+
# so resolve it yourself before notifying:
|
|
141
|
+
#
|
|
142
|
+
# config.on_repeated_failed_logins = ->(identity:, count:, event:) do
|
|
143
|
+
# user = User.find_by(email: identity) or next
|
|
144
|
+
# SecurityMailer.with(user: user, event: event).repeated_failed_logins.deliver_later
|
|
145
|
+
# end
|
|
146
|
+
#
|
|
147
|
+
# The catch-all tee: EVERY trail event (logins, failures, logouts,
|
|
148
|
+
# revocations) right after it's recorded. `event.summary` is the
|
|
149
|
+
# audit-shaped projection (device, identity, reasons, ip, country —
|
|
150
|
+
# compacted, no raw blobs), so wiring an audit ledger is one line:
|
|
151
|
+
#
|
|
152
|
+
# config.events = ->(event) do
|
|
153
|
+
# AuditLog.log(event_type: "session.#{event.name}", user: event.user,
|
|
154
|
+
# request: event.request, data: event.summary)
|
|
155
|
+
# end
|
|
156
|
+
|
|
157
|
+
# ==========================================================================
|
|
158
|
+
# INTEGRATION
|
|
159
|
+
# ==========================================================================
|
|
160
|
+
#
|
|
161
|
+
# The controller the devices page inherits from — your layout, helpers,
|
|
162
|
+
# auth filters and locale apply automatically (the Devise/api_keys/chats
|
|
163
|
+
# pattern).
|
|
164
|
+
#
|
|
165
|
+
# config.parent_controller = "::ApplicationController"
|
|
166
|
+
#
|
|
167
|
+
# How the page finds the signed-in user. The resolver chain tries: this
|
|
168
|
+
# method → :current_user → ::Current.session&.user — so Devise AND Rails 8
|
|
169
|
+
# auth work with zero configuration. Override for custom stacks:
|
|
170
|
+
#
|
|
171
|
+
# config.current_user_method = :current_user
|
|
172
|
+
# config.authenticate_method = :authenticate_user!
|
|
173
|
+
#
|
|
174
|
+
# Render the devices page with a specific layout (nil inherits whatever
|
|
175
|
+
# your parent controller uses — set this if your signed-in surfaces use a
|
|
176
|
+
# different layout, e.g. "app"):
|
|
177
|
+
#
|
|
178
|
+
# config.layout = nil
|
|
179
|
+
#
|
|
180
|
+
# Optional sudo gate before destructive actions on the devices page (ASVS
|
|
181
|
+
# 3.3.4's "having re-entered login credentials"). The action runs only
|
|
182
|
+
# when the gate returns TRUTHY without rendering — render/redirect to
|
|
183
|
+
# take over with your password-confirm flow, or return false/nil to
|
|
184
|
+
# block (a bare falsy answers 403; the gate fails closed):
|
|
185
|
+
#
|
|
186
|
+
# config.require_reauthentication = ->(controller) do
|
|
187
|
+
# controller.session[:sudo_until]&.future? ||
|
|
188
|
+
# controller.redirect_to(controller.main_app.confirm_password_path)
|
|
189
|
+
# end
|
|
190
|
+
#
|
|
191
|
+
# The session-of-record model (escape hatch for apps that installed with
|
|
192
|
+
# --model because a legacy Session class was in the way):
|
|
193
|
+
#
|
|
194
|
+
# config.session_class = "Session"
|
|
195
|
+
#
|
|
196
|
+
# Classify custom Warden strategies (substring of the strategy class name
|
|
197
|
+
# → auth method). DatabaseAuthenticatable/Rememberable/
|
|
198
|
+
# MagicLinkAuthenticatable are built in.
|
|
199
|
+
#
|
|
200
|
+
# config.strategy_methods = { "OtpAuthenticatable" => :otp }
|
|
201
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The login trail (sessions gem): append-only record of every login attempt
|
|
4
|
+
# — successful AND failed, with the identity AS TYPED even when no such
|
|
5
|
+
# account exists — plus logouts, revocations and expiries. This is the
|
|
6
|
+
# brute-force / credential-stuffing / account-takeover triage surface:
|
|
7
|
+
#
|
|
8
|
+
# filter "Failed logins" + sort by occurred_at → tonight's attack
|
|
9
|
+
# search an email → everything typed against it
|
|
10
|
+
# search an IP → everything from that address
|
|
11
|
+
#
|
|
12
|
+
# Events are immutable history; there are no destructive actions here. The
|
|
13
|
+
# kill switch lives on the live session (SessionResource).
|
|
14
|
+
class Sessions::EventResource < Madmin::Resource
|
|
15
|
+
model Sessions::Event
|
|
16
|
+
|
|
17
|
+
attribute :id, index: false, form: false
|
|
18
|
+
attribute :event, index: true, form: false, label: "Event"
|
|
19
|
+
attribute :identity, index: true, form: false, label: "Identity (as typed)"
|
|
20
|
+
# Virtual: Event#user is the resolved account (nil for unknown-identity
|
|
21
|
+
# failures — that's the point of the identity column above).
|
|
22
|
+
attribute :user, :string, index: true, form: false, label: "User"
|
|
23
|
+
# Virtual (gem-computed): "Chrome 137 on macOS".
|
|
24
|
+
attribute :device_name, :string, index: true, form: false, label: "Device"
|
|
25
|
+
attribute :failure_reason, index: true, form: false
|
|
26
|
+
attribute :revoked_reason, index: false, form: false
|
|
27
|
+
attribute :auth_method, index: false, form: false
|
|
28
|
+
attribute :auth_provider, index: false, form: false
|
|
29
|
+
attribute :ip_address, index: true, form: false, label: "IP"
|
|
30
|
+
attribute :country_code, index: true, form: false, label: "Country"
|
|
31
|
+
attribute :city, index: false, form: false
|
|
32
|
+
attribute :region, index: false, form: false
|
|
33
|
+
attribute :scope, index: false, form: false
|
|
34
|
+
attribute :session_id, index: false, form: false, label: "Session"
|
|
35
|
+
attribute :user_agent, index: false, form: false
|
|
36
|
+
attribute :browser_name, index: false, form: false
|
|
37
|
+
attribute :os_name, index: false, form: false
|
|
38
|
+
attribute :device_type, index: false, form: false
|
|
39
|
+
attribute :device_model, index: false, form: false
|
|
40
|
+
attribute :app_name, index: false, form: false
|
|
41
|
+
attribute :app_version, index: false, form: false
|
|
42
|
+
attribute :request_id, index: false, form: false
|
|
43
|
+
attribute :context, index: false, form: false
|
|
44
|
+
attribute :metadata, index: false, form: false
|
|
45
|
+
attribute :occurred_at, index: true, form: false, label: "When"
|
|
46
|
+
|
|
47
|
+
# Hidden: raw blobs / geo internals that only add noise next to the
|
|
48
|
+
# parsed columns.
|
|
49
|
+
attribute :auth_detail, show: false, form: false
|
|
50
|
+
attribute :client_hints, show: false, form: false
|
|
51
|
+
attribute :latitude, show: false, form: false
|
|
52
|
+
attribute :longitude, show: false, form: false
|
|
53
|
+
attribute :country_name, show: false, form: false
|
|
54
|
+
attribute :browser_version, show: false, form: false
|
|
55
|
+
attribute :os_version, show: false, form: false
|
|
56
|
+
attribute :app_build, show: false, form: false
|
|
57
|
+
attribute :authenticatable_type, show: false, form: false
|
|
58
|
+
attribute :authenticatable_id, show: false, form: false
|
|
59
|
+
|
|
60
|
+
# Gem scopes — the triage filters.
|
|
61
|
+
scope :logins
|
|
62
|
+
scope :failed_logins
|
|
63
|
+
scope :logouts
|
|
64
|
+
scope :revocations
|
|
65
|
+
scope :expirations
|
|
66
|
+
scope :new_devices
|
|
67
|
+
scope :last_24_hours
|
|
68
|
+
|
|
69
|
+
menu label: "Login activity", parent: "Security"
|
|
70
|
+
|
|
71
|
+
def self.display_name(record)
|
|
72
|
+
"#{record.event} · #{record.identity || record.user.try(:email) || "unknown"}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.default_sort_column = "occurred_at"
|
|
76
|
+
def self.default_sort_direction = "desc"
|
|
77
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Madmin
|
|
4
|
+
# The login trail — read-only by design (append-only history; the
|
|
5
|
+
# destructive verbs live on live sessions, not on their records).
|
|
6
|
+
class SessionEventsController < Madmin::ResourceController
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
# Stock Madmin derives the resource from the controller path
|
|
10
|
+
# ("madmin/session_events" → ::SessionEventResource); ours is namespaced
|
|
11
|
+
# under Sessions::. Overriding resource_name keeps this self-contained
|
|
12
|
+
# on stock Madmin — no host patches required.
|
|
13
|
+
def resource_name
|
|
14
|
+
"Sessions::EventResource"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def scoped_resources
|
|
18
|
+
super.includes(:authenticatable)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Live device registry (sessions gem): one row = one signed-in device,
|
|
4
|
+
# destroyed on logout/revocation — so this index IS the set of sessions that
|
|
5
|
+
# can act on your app right now. The append-only history lives in
|
|
6
|
+
# Sessions::Event (see Sessions::EventResource).
|
|
7
|
+
class SessionResource < Madmin::Resource
|
|
8
|
+
model <%= session_class %>
|
|
9
|
+
|
|
10
|
+
attribute :id, index: true, form: false, label: "ID"
|
|
11
|
+
attribute :user, index: true, form: false, label: "User"
|
|
12
|
+
# Virtual (gem-computed): "Chrome 137 on macOS",
|
|
13
|
+
# "MyApp 2.4.1 on Pixel 8 (Android 16)".
|
|
14
|
+
attribute :device_name, :string, index: true, form: false, label: "Device"
|
|
15
|
+
attribute :auth_method, index: true, form: false, label: "Via"
|
|
16
|
+
attribute :auth_provider, index: false, form: false
|
|
17
|
+
attribute :ip_address, index: true, form: false, label: "IP (login)"
|
|
18
|
+
attribute :last_seen_ip, index: false, form: false, label: "IP (last seen)"
|
|
19
|
+
attribute :country_code, index: true, form: false, label: "Country"
|
|
20
|
+
attribute :city, index: false, form: false
|
|
21
|
+
attribute :scope, index: false, form: false
|
|
22
|
+
attribute :device_type, index: false, form: false
|
|
23
|
+
attribute :browser_name, index: false, form: false
|
|
24
|
+
attribute :browser_version, index: false, form: false
|
|
25
|
+
attribute :os_name, index: false, form: false
|
|
26
|
+
attribute :os_version, index: false, form: false
|
|
27
|
+
attribute :device_model, index: false, form: false
|
|
28
|
+
attribute :app_name, index: false, form: false
|
|
29
|
+
attribute :app_version, index: false, form: false
|
|
30
|
+
attribute :app_build, index: false, form: false
|
|
31
|
+
attribute :user_agent, index: false, form: false
|
|
32
|
+
attribute :last_seen_at, index: true, form: false, label: "Last seen"
|
|
33
|
+
attribute :created_at, index: true, form: false, label: "Signed in"
|
|
34
|
+
attribute :updated_at, show: false, form: false
|
|
35
|
+
|
|
36
|
+
# NEVER rendered: the token digest is a credential hash, and the raw
|
|
37
|
+
# header blobs are noise next to the parsed columns above.
|
|
38
|
+
attribute :token_digest, show: false, form: false
|
|
39
|
+
attribute :auth_detail, show: false, form: false
|
|
40
|
+
attribute :client_hints, show: false, form: false
|
|
41
|
+
|
|
42
|
+
# Gem scopes: active = last activity within 30 days.
|
|
43
|
+
scope :active
|
|
44
|
+
scope :inactive
|
|
45
|
+
|
|
46
|
+
menu label: "Sessions", parent: "Security"
|
|
47
|
+
|
|
48
|
+
def self.display_name(record)
|
|
49
|
+
"#{record.device_name} — #{record.user.try(:email) || record.user_id}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.default_sort_column = "created_at"
|
|
53
|
+
def self.default_sort_direction = "desc"
|
|
54
|
+
|
|
55
|
+
# Remote logout: destroys the row (the device is signed out on its next
|
|
56
|
+
# request), writes the `revoked` trail event attributed to the admin, and
|
|
57
|
+
# rotates the user's remember-me credentials in Devise mode.
|
|
58
|
+
member_action do
|
|
59
|
+
button_to "Revoke session",
|
|
60
|
+
main_app.revoke_madmin_session_path(@record),
|
|
61
|
+
method: :post,
|
|
62
|
+
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",
|
|
63
|
+
data: { turbo_confirm: "Revoke this session? The device will be signed out on its next request." }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Madmin
|
|
4
|
+
class SessionsController < Madmin::ResourceController
|
|
5
|
+
# Admin remote logout. `revoke!` destroys the row (the device is kicked
|
|
6
|
+
# on its next request — the cookie session and, in Devise mode, any
|
|
7
|
+
# remember-me revival), and writes the immutable `revoked` trail event
|
|
8
|
+
# with `by:` attribution.
|
|
9
|
+
def revoke
|
|
10
|
+
session_row = <%= session_class %>.find(params[:id])
|
|
11
|
+
device = session_row.device_name
|
|
12
|
+
|
|
13
|
+
session_row.revoke!(reason: :admin_revoked, by: current_user)
|
|
14
|
+
flash[:notice] = "Session revoked (#{device}). The device will be signed out on its next request."
|
|
15
|
+
|
|
16
|
+
redirect_back fallback_location: main_app.madmin_sessions_path
|
|
17
|
+
rescue ActiveRecord::RecordNotFound
|
|
18
|
+
flash[:alert] = "That session no longer exists (probably already revoked)."
|
|
19
|
+
redirect_back fallback_location: main_app.madmin_sessions_path
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# The index is a triage surface — always show who owns each row without
|
|
25
|
+
# N+1ing the user column.
|
|
26
|
+
def scoped_resources
|
|
27
|
+
super.includes(:user)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Your session-of-record model — deliberately a 3-line shell: ALL the gem's
|
|
4
|
+
# behavior (device names, revocation, scopes, the trail) lives in the
|
|
5
|
+
# Sessions::Model concern, so this file never goes stale across gem updates.
|
|
6
|
+
# It's also exactly the model `rails generate authentication` would create,
|
|
7
|
+
# which keeps a future move to Rails' built-in auth a no-op for this table.
|
|
8
|
+
class <%= model_name %> < ApplicationRecord
|
|
9
|
+
<% if model_name.underscore.pluralize != table_name -%>
|
|
10
|
+
self.table_name = "<%= table_name %>"
|
|
11
|
+
|
|
12
|
+
<% end -%>
|
|
13
|
+
include Sessions::Model
|
|
14
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The sessions maintenance pass — schedule it daily (see config/recurring.yml
|
|
4
|
+
# below if you're on Solid Queue). It purges trail rows past
|
|
5
|
+
# `config.events_retention`, evicts per-user overflow beyond
|
|
6
|
+
# `config.max_sessions_per_user`, and (only if you opted into timeouts)
|
|
7
|
+
# expires idle/over-age sessions.
|
|
8
|
+
#
|
|
9
|
+
# # config/recurring.yml
|
|
10
|
+
# production:
|
|
11
|
+
# sessions_sweep:
|
|
12
|
+
# class: SessionsSweepJob
|
|
13
|
+
# schedule: every day at 4am
|
|
14
|
+
class SessionsSweepJob < ApplicationJob
|
|
15
|
+
queue_as :default
|
|
16
|
+
|
|
17
|
+
def perform
|
|
18
|
+
Sessions.sweep!
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
|
|
5
|
+
module Sessions
|
|
6
|
+
module Generators
|
|
7
|
+
# `rails generate sessions:views` — eject the engine's overridable
|
|
8
|
+
# templates into the HOST app so they can be restyled. This is the
|
|
9
|
+
# Devise move (`rails g devise:views`), and it works for the same boring
|
|
10
|
+
# Rails reason: the host app's `app/views` sits AHEAD of any engine's
|
|
11
|
+
# view paths in the lookup chain, so a file copied to e.g.
|
|
12
|
+
# `app/views/sessions/_device.html.erb` SHADOWS the gem's bundled
|
|
13
|
+
# default automatically — no config, no registration. Delete your copy
|
|
14
|
+
# and the gem's default comes back. Upgrade the gem and your ejected
|
|
15
|
+
# copies are untouched (re-run only if you WANT the new defaults).
|
|
16
|
+
class ViewsGenerator < Rails::Generators::Base
|
|
17
|
+
source_root File.expand_path("../../../app/views/sessions", __dir__)
|
|
18
|
+
|
|
19
|
+
desc "Copy sessions' overridable views into your app so you can restyle them."
|
|
20
|
+
|
|
21
|
+
def copy_views
|
|
22
|
+
directory ".", "app/views/sessions"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def show_styling_tip
|
|
26
|
+
say "\n🎨 Views copied to app/views/sessions/. They render with the gem's"
|
|
27
|
+
say " bundled sessions.css by default; restyle freely — if your app uses"
|
|
28
|
+
say " Tailwind, classes you add here are picked up by your build"
|
|
29
|
+
say " automatically (the files now live in app/views)."
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sessions
|
|
4
|
+
module Adapters
|
|
5
|
+
# The Rails 8 omakase auth adapter — zero app-code changes.
|
|
6
|
+
#
|
|
7
|
+
# Installed from `config.to_prepare` (so it re-applies to freshly
|
|
8
|
+
# reloaded constants in development) and entirely capability-detected:
|
|
9
|
+
# nothing happens unless the app actually has the generated
|
|
10
|
+
# authentication code. Three attachment points, each name-stable since
|
|
11
|
+
# Rails 8.0 (the Authentication concern is byte-identical from 8.0.5
|
|
12
|
+
# through 8.1.3 to main — → docs/research/03-rails-core.md):
|
|
13
|
+
#
|
|
14
|
+
# 1. The Session MODEL gets Sessions::Model included. Its callbacks
|
|
15
|
+
# observe 100% of the generated lifecycle: `start_new_session_for`
|
|
16
|
+
# uses create!, `terminate_session` uses destroy, and 8.1's
|
|
17
|
+
# password reset uses destroy_all (which instantiates and runs
|
|
18
|
+
# callbacks) — so logins and revocations are captured with zero
|
|
19
|
+
# controller coupling.
|
|
20
|
+
#
|
|
21
|
+
# 2. ApplicationController gets ControllerHooks PREPENDED — the
|
|
22
|
+
# prepend sits in front of the included Authentication concern in
|
|
23
|
+
# the ancestor chain, so `super`-wrapping `resume_session`
|
|
24
|
+
# (throttled touch + opt-in expiry) and `terminate_session`
|
|
25
|
+
# (labeling the destroy as a logout) is clean.
|
|
26
|
+
#
|
|
27
|
+
# 3. The generated SessionsController#create gets FailedLoginHooks
|
|
28
|
+
# prepended: `authenticate_by` is deliberately silent on failure
|
|
29
|
+
# (no hook, no notification), so we observe the controller seam —
|
|
30
|
+
# after `super`, no session + a credentials POST = a failed login.
|
|
31
|
+
# Rails 8.1's `rate_limit.action_controller` notification adds the
|
|
32
|
+
# brute-force-threshold signal for free.
|
|
33
|
+
module Omakase
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
def install!
|
|
37
|
+
Sessions.safely("omakase.install") do
|
|
38
|
+
decorate_session_model!
|
|
39
|
+
prepend_controller_hooks!
|
|
40
|
+
prepend_failed_login_hooks!
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# `Session.include Sessions::Model` when the host has a Rails-8-shaped
|
|
45
|
+
# session model (the Devise-mode shell model includes it explicitly;
|
|
46
|
+
# this is a no-op there).
|
|
47
|
+
def decorate_session_model!
|
|
48
|
+
klass = session_class
|
|
49
|
+
return unless klass
|
|
50
|
+
return if klass.include?(Sessions::Model)
|
|
51
|
+
|
|
52
|
+
klass.include(Sessions::Model)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def prepend_controller_hooks!
|
|
56
|
+
return unless omakase_controller?
|
|
57
|
+
|
|
58
|
+
::ApplicationController.prepend(ControllerHooks)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def prepend_failed_login_hooks!
|
|
62
|
+
return unless omakase_controller?
|
|
63
|
+
return unless defined?(::SessionsController)
|
|
64
|
+
return unless ::SessionsController.method_defined?(:create)
|
|
65
|
+
|
|
66
|
+
::SessionsController.prepend(FailedLoginHooks)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The duck test: the generated Authentication concern defines these
|
|
70
|
+
# private methods on ApplicationController. Capability-based, so a
|
|
71
|
+
# host that renamed or removed its auth code simply isn't touched.
|
|
72
|
+
def omakase_controller?
|
|
73
|
+
defined?(::ApplicationController) &&
|
|
74
|
+
::ApplicationController.private_method_defined?(:start_new_session_for) &&
|
|
75
|
+
::ApplicationController.private_method_defined?(:resume_session)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def session_class
|
|
79
|
+
klass = Sessions.config.session_class.safe_constantize
|
|
80
|
+
return nil unless klass.is_a?(Class)
|
|
81
|
+
return nil unless defined?(::ActiveRecord::Base) && klass < ::ActiveRecord::Base
|
|
82
|
+
# The Rails 8 base columns prove the shape; last_seen_at proves the
|
|
83
|
+
# install migration ran (decorating a half-migrated table would give
|
|
84
|
+
# candy methods nothing to stand on).
|
|
85
|
+
return nil unless klass.table_exists? &&
|
|
86
|
+
(%w[ip_address user_agent last_seen_at] - klass.column_names).empty?
|
|
87
|
+
|
|
88
|
+
klass
|
|
89
|
+
rescue StandardError
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Rails 8.1+ instruments rate-limit hits with a payload carrying the
|
|
94
|
+
# request — a free brute-force signal. Subscribed once per process
|
|
95
|
+
# (from the engine initializer, not to_prepare); on Rails ≤ 8.0 the
|
|
96
|
+
# notification never fires and this is inert.
|
|
97
|
+
def subscribe_rate_limit_notifications!
|
|
98
|
+
return if @rate_limit_subscribed
|
|
99
|
+
|
|
100
|
+
@rate_limit_subscribed = true
|
|
101
|
+
ActiveSupport::Notifications.subscribe("rate_limit.action_controller") do |*_args, payload|
|
|
102
|
+
record_rate_limited(payload)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def record_rate_limited(payload)
|
|
107
|
+
Sessions.safely("omakase.rate_limit") do
|
|
108
|
+
next unless Sessions.config.track_failed_logins
|
|
109
|
+
|
|
110
|
+
request = payload[:request]
|
|
111
|
+
next unless request
|
|
112
|
+
|
|
113
|
+
# Only the sessions controller: a rate-limited LOGIN burst is
|
|
114
|
+
# failed-login activity; throttles elsewhere (password resets,
|
|
115
|
+
# API endpoints) are not — recording them here would put
|
|
116
|
+
# non-logins in the failed_login vocabulary.
|
|
117
|
+
controller = request.path_parameters[:controller].to_s.split("/").last
|
|
118
|
+
next unless controller == "sessions"
|
|
119
|
+
|
|
120
|
+
Sessions::Event.record_failure(
|
|
121
|
+
request,
|
|
122
|
+
reason: :rate_limited,
|
|
123
|
+
metadata: { count: payload[:count], limit: payload[:to] }.compact
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Prepended in front of the generated Authentication concern.
|
|
129
|
+
module ControllerHooks
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
# After the host resolves the session row, enforce opt-in expiry and
|
|
133
|
+
# apply the throttled last_seen_at touch. Both are error-isolated;
|
|
134
|
+
# an expired session is destroyed (instant revocation semantics) and
|
|
135
|
+
# the request proceeds unauthenticated.
|
|
136
|
+
def resume_session
|
|
137
|
+
session = super
|
|
138
|
+
return session unless session.respond_to?(:sessions_expired?)
|
|
139
|
+
|
|
140
|
+
if session.sessions_expired?
|
|
141
|
+
Sessions.safely("omakase.expire") { session.revoke!(reason: :expired) }
|
|
142
|
+
::Current.session = nil if defined?(::Current) && ::Current.respond_to?(:session=)
|
|
143
|
+
nil
|
|
144
|
+
else
|
|
145
|
+
Sessions.safely("omakase.touch") { session.touch_last_seen!(request) }
|
|
146
|
+
session
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Label the upcoming destroy as a user sign-out so the trail says
|
|
151
|
+
# "logout", not "revoked".
|
|
152
|
+
def terminate_session
|
|
153
|
+
Sessions.safely("omakase.terminate") do
|
|
154
|
+
session = defined?(::Current) ? ::Current.try(:session) : nil
|
|
155
|
+
session.revocation_reason = :logout if session.respond_to?(:revocation_reason=)
|
|
156
|
+
end
|
|
157
|
+
super
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Prepended onto the generated SessionsController.
|
|
162
|
+
module FailedLoginHooks
|
|
163
|
+
# The generated create either calls start_new_session_for (success —
|
|
164
|
+
# Current.session is set) or redirects with an alert (failure —
|
|
165
|
+
# nothing recorded anywhere). We add the missing failure record.
|
|
166
|
+
def create
|
|
167
|
+
super
|
|
168
|
+
Sessions.safely("omakase.failed_login") do
|
|
169
|
+
next unless Sessions.config.track_failed_logins
|
|
170
|
+
next if defined?(::Current) && ::Current.try(:session)
|
|
171
|
+
next unless request.post?
|
|
172
|
+
# Two-phase 2FA controllers redirect to their challenge with the
|
|
173
|
+
# password VALIDATED and no session yet — `Sessions.skip!` is
|
|
174
|
+
# their one-line way to say "not a failure" (see lib/sessions.rb).
|
|
175
|
+
next if request.env[Sessions::SKIP_ENV_KEY]
|
|
176
|
+
|
|
177
|
+
identity = sessions_attempted_identity
|
|
178
|
+
next unless identity || params[:password].present?
|
|
179
|
+
|
|
180
|
+
Sessions::Event.record_failure(request, identity: identity, reason: :invalid_credentials)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
private
|
|
185
|
+
|
|
186
|
+
# The generated form posts `email_address`; common hand-rolled
|
|
187
|
+
# variants are accepted too. The password itself is NEVER read
|
|
188
|
+
# beyond presence.
|
|
189
|
+
def sessions_attempted_identity
|
|
190
|
+
%i[email_address email username login].lazy.map { |key| params[key] }.find(&:present?)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|