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
@@ -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