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