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,441 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sessions
4
+ # All of the gem's knobs, with delightful defaults: a fresh `Configuration`
5
+ # is fully working out of the box on both Rails 8 omakase auth and a
6
+ # classic Devise + `User` app — without touching a single setting.
7
+ #
8
+ # Three design rules, shared across the gem ecosystem (chats, moderate,
9
+ # api_keys, …):
10
+ #
11
+ # 1. Class names are stored as STRINGS and constantized lazily, so the
12
+ # initializer can reference app classes before they're loaded and
13
+ # everything survives Zeitwerk reloads.
14
+ # 2. Hooks are PROCS with no-op defaults and are error-isolated at the
15
+ # call site — the gem runs standalone and lights up when the host
16
+ # wires goodmail / noticed / its own AuditLog in. A broken hook can
17
+ # never break a login.
18
+ # 3. Validating setters fail at boot with a plain-English message, not
19
+ # at 3am with a NoMethodError.
20
+ class Configuration
21
+ IP_MODES = %i[full truncated].freeze
22
+ UA_PARSERS = %i[browser device_detector].freeze
23
+ GEOLOCATE_MODES = %i[auto off].freeze
24
+
25
+ # NIST SP 800-63B-4 reauthentication ceilings, exposed as one-line
26
+ # presets (§2.2.3: AAL2 ≤ 24h absolute / ≤ 1h inactivity; §2.3.3: AAL3
27
+ # ≤ 12h / ≤ 15min). `timeout_preset = :nist_aal2` is sugar for setting
28
+ # idle_timeout + max_session_lifetime to the matching pair.
29
+ TIMEOUT_PRESETS = {
30
+ nist_aal2: { idle: 1.hour, lifetime: 24.hours },
31
+ nist_aal3: { idle: 15.minutes, lifetime: 12.hours }
32
+ }.freeze
33
+
34
+ # --- Behavior -------------------------------------------------------------
35
+
36
+ # How often `last_seen_at` may be written, per session. The touch is ONE
37
+ # conditional UPDATE (hot-row-safe, callback-free) and at most one write
38
+ # per session per window — authie's touch-every-request and
39
+ # devise-security's per-request update_column are the documented
40
+ # anti-patterns this throttle exists to avoid. `nil` disables touching
41
+ # entirely (your devices page then shows sign-in-time data only).
42
+ attr_reader :touch_every
43
+
44
+ # Per-user live-session cap with oldest-eviction (GitLab keeps 100,
45
+ # Discourse 60). Evicted rows get a `revoked` event with reason
46
+ # `:pruned`. `nil` = unlimited.
47
+ attr_reader :max_sessions_per_user
48
+
49
+ # Opt-in session expiry. BOTH default to nil — a tracking gem must never
50
+ # silently shorten anyone's sessions. When set, expiry is enforced
51
+ # inline at session resume (both adapters) and by the generated
52
+ # SessionsSweepJob. `timeout_preset = :nist_aal2` sets both in one line.
53
+ attr_reader :idle_timeout
54
+ attr_reader :max_session_lifetime
55
+
56
+ # Terminate other sessions when the user's password changes (ASVS 3.3.3
57
+ # / 7.4.3; Laravel's logoutOtherDevices and Phoenix's token nuke are the
58
+ # cross-framework precedent; Rails 8.1's own password reset already
59
+ # destroy_alls). Wired by `has_sessions` via an after_update on the
60
+ # password digest column, so it works on both auth stacks.
61
+ attr_accessor :revoke_on_password_change
62
+
63
+ # Devise mode only: revoking a session also rotates the user's
64
+ # remember-me credentials (`forget_me!`), closing the
65
+ # stolen-remember-cookie revival hole (GitLab semantics: other devices
66
+ # keep their live sessions but cannot auto-revive after those end).
67
+ attr_accessor :revoke_remember_me
68
+
69
+ # Record failed login attempts (the `failed_login` trail). On by
70
+ # default; flip off if you only want the live device registry.
71
+ attr_accessor :track_failed_logins
72
+
73
+ # Burst detection for failed logins: when set to
74
+ # `{ threshold: 5, within: 15.minutes }`, the on_repeated_failed_logins
75
+ # hook fires ONCE when an identity crosses `threshold` failed attempts
76
+ # inside the window — never per attempt (per-attempt alerts are both
77
+ # notification fatigue and an abuse vector: an attacker could spam a
78
+ # victim's inbox by hammering the form). nil (the default) disables
79
+ # detection entirely.
80
+ attr_reader :repeated_failed_logins
81
+
82
+ # --- Device intelligence --------------------------------------------------
83
+
84
+ # Which web UA parser projects raw user agents into device columns:
85
+ # :browser — the bundled default (MIT, zero-dep, tiny)
86
+ # :device_detector — auto-upgrade if your app bundles the
87
+ # device_detector gem (better Android device
88
+ # names, Client-Hints-native — but LGPL and 1.5 MB
89
+ # of data, which is why it's not the default)
90
+ # a lambda — ->(user_agent, headers) { { browser_name: …, … } }
91
+ attr_reader :ua_parser
92
+
93
+ # Set `Accept-CH` on responses so Chromium browsers send high-entropy
94
+ # client hints (real platform versions, Android device models) on
95
+ # subsequent requests — login POSTs are rarely first-navigations, so
96
+ # hints are reliably present exactly when sessions get created.
97
+ # Safari/Firefox don't implement client hints; they stay UA-only.
98
+ attr_accessor :request_client_hints
99
+
100
+ # Extra app-name prefixes to recognize in native user agents, for apps
101
+ # using a legacy convention like
102
+ # "MyApp Android 1.0.5 (build 6; Android 14; sdk 34; Pixel 7)".
103
+ # The documented `AppName/1.2.3 (model; OS version; build N);` prefix
104
+ # convention is always recognized without configuration.
105
+ attr_reader :native_app_names
106
+
107
+ # --- IP & geo ---------------------------------------------------------------
108
+
109
+ # How to extract the client IP from a request. The default
110
+ # (`request.remote_ip`) honors Rails' trusted_proxies middleware; apps
111
+ # behind Cloudflare without cloudflare-rails can point this at
112
+ # CF-Connecting-IP (see the README's "Behind Cloudflare" section).
113
+ attr_reader :ip_resolver
114
+
115
+ # :full stores the address as-is; :truncated zeroes the last IPv4 octet
116
+ # / the last 80 IPv6 bits BEFORE persistence (the Google Analytics
117
+ # anonymization precedent) — nothing un-truncated ever touches disk.
118
+ attr_reader :ip_mode
119
+
120
+ # :auto geolocates through the trackdown gem when it's installed
121
+ # (Cloudflare headers synchronously — free; MaxMind asynchronously in
122
+ # Sessions::GeolocateJob); :off disables geolocation entirely. Without
123
+ # trackdown, geo columns simply stay nil and the UI omits location.
124
+ attr_reader :geolocate
125
+
126
+ # Decimal places kept on event latitude/longitude (2 ≈ 1km — privacy
127
+ # now, impossible-travel math later).
128
+ attr_reader :geo_precision
129
+
130
+ # --- Retention --------------------------------------------------------------
131
+
132
+ # How long `sessions_events` rows are kept before the sweep job purges
133
+ # them. CNIL recommends 6–12 months for security logs; default 12.
134
+ # `nil` keeps events forever (you own the purge).
135
+ attr_reader :events_retention
136
+
137
+ # --- Hooks (kwargs, no-op defaults, error-isolated — never break login) ----
138
+
139
+ # ->(user:, session:, event:) — fired when a login doesn't match any
140
+ # device this user has signed in from before. Wire your "Was this you?"
141
+ # email here (goodmail / noticed recipes in the README). Not fired on a
142
+ # user's very first session (nobody wants a new-device alert on signup).
143
+ attr_reader :on_new_device
144
+
145
+ # ->(session:, by:, reason:) — fired after a session is revoked.
146
+ attr_reader :on_session_revoked
147
+
148
+ # ->(identity:, count:, event:) — fired when an identity crosses the
149
+ # repeated_failed_logins threshold (see above). The identity is the
150
+ # email AS TYPED (it may match no account — resolve it yourself if you
151
+ # want to notify the owner); `event` is the failed_login that tripped
152
+ # the threshold, carrying IP, location and device.
153
+ attr_reader :on_repeated_failed_logins
154
+
155
+ # ->(event) — catch-all tee receiving every Sessions::Event after it's
156
+ # recorded: logins, failures, logouts, revocations. One line wires your
157
+ # AuditLog / Telegrama / analytics.
158
+ attr_reader :events
159
+
160
+ # --- Integration ------------------------------------------------------------
161
+
162
+ # The controller the engine's devices page inherits from. Pointing this
163
+ # at your ApplicationController (the default) gives the page your
164
+ # layout, helpers, auth filters and locale for free — the same pattern
165
+ # Devise, api_keys and chats use.
166
+ attr_reader :parent_controller
167
+
168
+ # How the engine finds the signed-in user. The resolver chain tries, in
169
+ # order: this method → :current_user → ::Current.session&.user — so
170
+ # Devise AND Rails 8 omakase auth work with zero configuration.
171
+ attr_accessor :current_user_method
172
+
173
+ # The before_action that requires authentication (:authenticate_user!
174
+ # works with Devise out of the box; omakase hosts already enforce
175
+ # `require_authentication` through the inherited concern, so the engine
176
+ # detects that and needs nothing).
177
+ attr_accessor :authenticate_method
178
+
179
+ # Optional explicit layout for the devices page. nil (default) inherits
180
+ # whatever layout the parent controller resolves — usually the host's
181
+ # `application` layout. Set it when your signed-in surfaces render with
182
+ # a different one (e.g. "app").
183
+ attr_accessor :layout
184
+
185
+ # ->(controller) — optional sudo gate run before destructive actions on
186
+ # the devices page (ASVS 3.3.4's "having re-entered login credentials").
187
+ # nil (default) means no extra gate; wire your password-confirm flow
188
+ # here. The action runs only when the gate returns TRUTHY without
189
+ # rendering: render/redirect to take over the response, or return
190
+ # false/nil to block (a bare falsy gets a 403 — the gate fails closed,
191
+ # never through to the destructive action).
192
+ attr_reader :require_reauthentication
193
+
194
+ # The host's session-of-record model, as a string. "Session" matches
195
+ # both the Rails 8 generator and the model our install generator writes
196
+ # in Devise mode. Escape hatch for apps with a conflicting legacy
197
+ # Session class (e.g. activerecord-session_store).
198
+ attr_reader :session_class
199
+
200
+ # Maps Warden strategy classes to auth methods for classification, on
201
+ # top of the built-ins (DatabaseAuthenticatable → :password,
202
+ # Rememberable → :password, MagicLinkAuthenticatable → :magic_link).
203
+ # Keys are class-name substrings, values are method symbols:
204
+ # config.strategy_methods = { "OtpAuthenticatable" => :otp }
205
+ attr_reader :strategy_methods
206
+
207
+ def initialize
208
+ @touch_every = 5.minutes
209
+ @max_sessions_per_user = 100
210
+ @idle_timeout = nil
211
+ @max_session_lifetime = nil
212
+ @revoke_on_password_change = true
213
+ @revoke_remember_me = true
214
+ @track_failed_logins = true
215
+ @repeated_failed_logins = nil
216
+
217
+ @ua_parser = :browser
218
+ @request_client_hints = false
219
+ @native_app_names = []
220
+
221
+ # remote_ip (ActionDispatch — honors trusted_proxies) with a fallback
222
+ # to Rack's #ip for plain-Warden stacks where the request isn't an
223
+ # ActionDispatch::Request.
224
+ @ip_resolver = ->(request) { request.respond_to?(:remote_ip) ? request.remote_ip : request.ip }
225
+ @ip_mode = :full
226
+ @geolocate = :auto
227
+ @geo_precision = 2
228
+
229
+ @events_retention = 12.months
230
+
231
+ @on_new_device = ->(user:, session:, event:) {}
232
+ @on_session_revoked = ->(session:, by:, reason:) {}
233
+ @on_repeated_failed_logins = ->(identity:, count:, event:) {}
234
+ @events = ->(_event) {}
235
+
236
+ @parent_controller = "::ApplicationController"
237
+ @current_user_method = :current_user
238
+ @authenticate_method = :authenticate_user!
239
+ @layout = nil
240
+ @require_reauthentication = nil
241
+ @session_class = "Session"
242
+ @strategy_methods = {}
243
+ end
244
+
245
+ # --- Validating setters ---------------------------------------------------
246
+
247
+ def touch_every=(value)
248
+ @touch_every = ensure_duration_or_nil(value, "touch_every")
249
+ end
250
+
251
+ def max_sessions_per_user=(value)
252
+ if value.nil?
253
+ @max_sessions_per_user = nil
254
+ return
255
+ end
256
+
257
+ unless value.is_a?(Integer) && value.positive?
258
+ raise ConfigurationError, "max_sessions_per_user must be a positive Integer or nil, got #{value.inspect}"
259
+ end
260
+
261
+ @max_sessions_per_user = value
262
+ end
263
+
264
+ def idle_timeout=(value)
265
+ @idle_timeout = ensure_duration_or_nil(value, "idle_timeout")
266
+ end
267
+
268
+ def max_session_lifetime=(value)
269
+ @max_session_lifetime = ensure_duration_or_nil(value, "max_session_lifetime")
270
+ end
271
+
272
+ # Sugar: `config.timeout_preset = :nist_aal2` sets both timeouts to the
273
+ # named NIST pair in one line.
274
+ def timeout_preset=(name)
275
+ preset = TIMEOUT_PRESETS[name&.to_sym]
276
+ unless preset
277
+ raise ConfigurationError,
278
+ "timeout_preset must be one of #{TIMEOUT_PRESETS.keys.inspect}, got #{name.inspect}"
279
+ end
280
+
281
+ @idle_timeout = preset[:idle]
282
+ @max_session_lifetime = preset[:lifetime]
283
+ end
284
+
285
+ def ua_parser=(value)
286
+ if value.respond_to?(:call)
287
+ @ua_parser = value
288
+ return
289
+ end
290
+
291
+ normalized = value&.to_sym
292
+ unless UA_PARSERS.include?(normalized)
293
+ raise ConfigurationError,
294
+ "ua_parser must be one of #{UA_PARSERS.inspect} or a lambda, got #{value.inspect}"
295
+ end
296
+
297
+ @ua_parser = normalized
298
+ end
299
+
300
+ def native_app_names=(value)
301
+ names = Array(value).map(&:to_s).reject { |name| name.strip.empty? }
302
+ @native_app_names = names
303
+ end
304
+
305
+ def ip_resolver=(value)
306
+ @ip_resolver = ensure_callable(value, "ip_resolver")
307
+ end
308
+
309
+ def ip_mode=(value)
310
+ normalized = value&.to_sym
311
+ unless IP_MODES.include?(normalized)
312
+ raise ConfigurationError, "ip_mode must be one of #{IP_MODES.inspect}, got #{value.inspect}"
313
+ end
314
+
315
+ @ip_mode = normalized
316
+ end
317
+
318
+ def geolocate=(value)
319
+ normalized = value == false ? :off : value&.to_sym
320
+ unless GEOLOCATE_MODES.include?(normalized)
321
+ raise ConfigurationError, "geolocate must be :auto or :off, got #{value.inspect}"
322
+ end
323
+
324
+ @geolocate = normalized
325
+ end
326
+
327
+ def geo_precision=(value)
328
+ unless value.is_a?(Integer) && value >= 0
329
+ raise ConfigurationError, "geo_precision must be a non-negative Integer, got #{value.inspect}"
330
+ end
331
+
332
+ @geo_precision = value
333
+ end
334
+
335
+ def events_retention=(value)
336
+ @events_retention = ensure_duration_or_nil(value, "events_retention")
337
+ end
338
+
339
+ def on_new_device=(value)
340
+ @on_new_device = ensure_callable(value, "on_new_device")
341
+ end
342
+
343
+ def on_session_revoked=(value)
344
+ @on_session_revoked = ensure_callable(value, "on_session_revoked")
345
+ end
346
+
347
+ def on_repeated_failed_logins=(value)
348
+ @on_repeated_failed_logins = ensure_callable(value, "on_repeated_failed_logins")
349
+ end
350
+
351
+ def repeated_failed_logins=(value)
352
+ if value.nil?
353
+ @repeated_failed_logins = nil
354
+ return
355
+ end
356
+
357
+ hash = value.to_h.symbolize_keys
358
+ unless hash[:threshold].is_a?(Integer) && hash[:threshold].positive? &&
359
+ hash[:within].respond_to?(:ago)
360
+ raise ConfigurationError,
361
+ "repeated_failed_logins must be nil or { threshold: Integer, within: duration }, " \
362
+ "got #{value.inspect}"
363
+ end
364
+
365
+ @repeated_failed_logins = hash
366
+ end
367
+
368
+ def events=(value)
369
+ @events = ensure_callable(value, "events")
370
+ end
371
+
372
+ def parent_controller=(value)
373
+ name = value.is_a?(Class) ? value.name : value.to_s
374
+ raise ConfigurationError, "parent_controller can't be blank" if name.strip.empty?
375
+
376
+ @parent_controller = name
377
+ end
378
+
379
+ def require_reauthentication=(value)
380
+ if value.nil?
381
+ @require_reauthentication = nil
382
+ return
383
+ end
384
+
385
+ @require_reauthentication = ensure_callable(value, "require_reauthentication")
386
+ end
387
+
388
+ def session_class=(value)
389
+ name = value.is_a?(Class) ? value.name : value.to_s
390
+ raise ConfigurationError, "session_class can't be blank" if name.strip.empty?
391
+
392
+ @session_class = name
393
+ end
394
+
395
+ def strategy_methods=(value)
396
+ unless value.respond_to?(:to_h)
397
+ raise ConfigurationError, "strategy_methods must be a Hash of { 'StrategyClassName' => :method }"
398
+ end
399
+
400
+ @strategy_methods = value.to_h.transform_keys(&:to_s).transform_values(&:to_sym)
401
+ end
402
+
403
+ # Cross-field validation, run at the end of `Sessions.configure`.
404
+ def validate!
405
+ if idle_timeout && max_session_lifetime && idle_timeout > max_session_lifetime
406
+ raise ConfigurationError,
407
+ "idle_timeout (#{idle_timeout.inspect}) can't exceed max_session_lifetime " \
408
+ "(#{max_session_lifetime.inspect})"
409
+ end
410
+
411
+ true
412
+ end
413
+
414
+ # The constantized session-of-record class (resolved lazily — see class
415
+ # comment).
416
+ def session_model
417
+ session_class.constantize
418
+ end
419
+
420
+ private
421
+
422
+ def ensure_duration_or_nil(value, name)
423
+ return nil if value.nil?
424
+
425
+ unless value.respond_to?(:from_now) && value.respond_to?(:ago)
426
+ raise ConfigurationError,
427
+ "#{name} must be a duration (like 5.minutes) or nil, got #{value.inspect}"
428
+ end
429
+
430
+ value
431
+ end
432
+
433
+ def ensure_callable(value, name)
434
+ unless value.respond_to?(:call)
435
+ raise ConfigurationError, "#{name} must respond to #call (a proc/lambda), got #{value.inspect}"
436
+ end
437
+
438
+ value
439
+ end
440
+ end
441
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/current_attributes"
4
+
5
+ module Sessions
6
+ # Per-request state, set by Sessions::Middleware and reset automatically by
7
+ # the Rails executor (the middleware sits after ActionDispatch::Executor in
8
+ # the stack, so CurrentAttributes' executor-driven clear_all covers it).
9
+ #
10
+ # Why it exists: the omakase adapter records logins from MODEL callbacks
11
+ # (Session#after_create_commit — the only seam that captures 100% of the
12
+ # generated lifecycle, including 8.1's password-reset destroy_all), and
13
+ # model callbacks have no request. This carries the request reference
14
+ # across that gap. Background jobs and console code simply see nil and the
15
+ # pipeline degrades gracefully (rows parse from their own stored columns).
16
+ class Current < ActiveSupport::CurrentAttributes
17
+ # The ActionDispatch::Request being served, if any.
18
+ attribute :request
19
+ end
20
+ end