Class: Sessions::Configuration

Inherits:
Object
  • Object
show all
Defined in:
lib/sessions/configuration.rb

Overview

All of the gem’s knobs, with delightful defaults: a fresh ‘Configuration` is fully working out of the box on both Rails 8 omakase auth and a classic Devise + `User` app — without touching a single setting.

Three design rules, shared across the gem ecosystem (chats, moderate, api_keys, …):

1. Class names are stored as STRINGS and constantized lazily, so the
   initializer can reference app classes before they're loaded and
   everything survives Zeitwerk reloads.
2. Hooks are PROCS with no-op defaults and are error-isolated at the
   call site — the gem runs standalone and lights up when the host
   wires goodmail / noticed / its own AuditLog in. A broken hook can
   never break a login.
3. Validating setters fail at boot with a plain-English message, not
   at 3am with a NoMethodError.

Constant Summary collapse

IP_MODES =
%i[full truncated].freeze
UA_PARSERS =
%i[browser device_detector].freeze
GEOLOCATE_MODES =
%i[auto off].freeze
TIMEOUT_PRESETS =

NIST SP 800-63B-4 reauthentication ceilings, exposed as one-line presets (§2.2.3: AAL2 ≤ 24h absolute / ≤ 1h inactivity; §2.3.3: AAL3 ≤ 12h / ≤ 15min). ‘timeout_preset = :nist_aal2` is sugar for setting idle_timeout + max_session_lifetime to the matching pair.

{
  nist_aal2: { idle: 1.hour, lifetime: 24.hours },
  nist_aal3: { idle: 15.minutes, lifetime: 12.hours }
}.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeConfiguration

Returns a new instance of Configuration.



207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
# File 'lib/sessions/configuration.rb', line 207

def initialize
  @touch_every = 5.minutes
  @max_sessions_per_user = 100
  @idle_timeout = nil
  @max_session_lifetime = nil
  @revoke_on_password_change = true
  @revoke_remember_me = true
  @track_failed_logins = true
  @repeated_failed_logins = nil

  @ua_parser = :browser
  @request_client_hints = false
  @native_app_names = []

  # remote_ip (ActionDispatch — honors trusted_proxies) with a fallback
  # to Rack's #ip for plain-Warden stacks where the request isn't an
  # ActionDispatch::Request.
  @ip_resolver = ->(request) { request.respond_to?(:remote_ip) ? request.remote_ip : request.ip }
  @ip_mode = :full
  @geolocate = :auto
  @geo_precision = 2

  @events_retention = 12.months

  @on_new_device = ->(user:, session:, event:) {}
  @on_session_revoked = ->(session:, by:, reason:) {}
  @on_repeated_failed_logins = ->(identity:, count:, event:) {}
  @events = ->(_event) {}

  @parent_controller = "::ApplicationController"
  @current_user_method = :current_user
  @authenticate_method = :authenticate_user!
  @layout = nil
  @require_reauthentication = nil
  @session_class = "Session"
  @strategy_methods = {}
end

Instance Attribute Details

#authenticate_methodObject

The before_action that requires authentication (:authenticate_user! works with Devise out of the box; omakase hosts already enforce ‘require_authentication` through the inherited concern, so the engine detects that and needs nothing).



177
178
179
# File 'lib/sessions/configuration.rb', line 177

def authenticate_method
  @authenticate_method
end

#current_user_methodObject

How the engine finds the signed-in user. The resolver chain tries, in order: this method → :current_user → ::Current.session&.user — so Devise AND Rails 8 omakase auth work with zero configuration.



171
172
173
# File 'lib/sessions/configuration.rb', line 171

def current_user_method
  @current_user_method
end

#eventsObject

->(event) — catch-all tee receiving every Sessions::Event after it’s recorded: logins, failures, logouts, revocations. One line wires your AuditLog / Telegrama / analytics.



158
159
160
# File 'lib/sessions/configuration.rb', line 158

def events
  @events
end

#events_retentionObject

How long ‘sessions_events` rows are kept before the sweep job purges them. CNIL recommends 6–12 months for security logs; default 12. `nil` keeps events forever (you own the purge).



135
136
137
# File 'lib/sessions/configuration.rb', line 135

def events_retention
  @events_retention
end

#geo_precisionObject

Decimal places kept on event latitude/longitude (2 ≈ 1km — privacy now, impossible-travel math later).



128
129
130
# File 'lib/sessions/configuration.rb', line 128

def geo_precision
  @geo_precision
end

#geolocateObject

:auto geolocates through the trackdown gem when it’s installed (Cloudflare headers synchronously — free; MaxMind asynchronously in Sessions::GeolocateJob); :off disables geolocation entirely. Without trackdown, geo columns simply stay nil and the UI omits location.



124
125
126
# File 'lib/sessions/configuration.rb', line 124

def geolocate
  @geolocate
end

#idle_timeoutObject

Opt-in session expiry. BOTH default to nil — a tracking gem must never silently shorten anyone’s sessions. When set, expiry is enforced inline at session resume (both adapters) and by the generated SessionsSweepJob. ‘timeout_preset = :nist_aal2` sets both in one line.



53
54
55
# File 'lib/sessions/configuration.rb', line 53

def idle_timeout
  @idle_timeout
end

#ip_modeObject

:full stores the address as-is; :truncated zeroes the last IPv4 octet / the last 80 IPv6 bits BEFORE persistence (the Google Analytics anonymization precedent) — nothing un-truncated ever touches disk.



118
119
120
# File 'lib/sessions/configuration.rb', line 118

def ip_mode
  @ip_mode
end

#ip_resolverObject

How to extract the client IP from a request. The default (‘request.remote_ip`) honors Rails’ trusted_proxies middleware; apps behind Cloudflare without cloudflare-rails can point this at CF-Connecting-IP (see the README’s “Behind Cloudflare” section).



113
114
115
# File 'lib/sessions/configuration.rb', line 113

def ip_resolver
  @ip_resolver
end

#layoutObject

Optional explicit layout for the devices page. nil (default) inherits whatever layout the parent controller resolves — usually the host’s ‘application` layout. Set it when your signed-in surfaces render with a different one (e.g. “app”).



183
184
185
# File 'lib/sessions/configuration.rb', line 183

def layout
  @layout
end

#max_session_lifetimeObject

Returns the value of attribute max_session_lifetime.



54
55
56
# File 'lib/sessions/configuration.rb', line 54

def max_session_lifetime
  @max_session_lifetime
end

#max_sessions_per_userObject

Per-user live-session cap with oldest-eviction (GitLab keeps 100, Discourse 60). Evicted rows get a ‘revoked` event with reason `:pruned`. `nil` = unlimited.



47
48
49
# File 'lib/sessions/configuration.rb', line 47

def max_sessions_per_user
  @max_sessions_per_user
end

#native_app_namesObject

Extra app-name prefixes to recognize in native user agents, for apps using a legacy convention like “MyApp Android 1.0.5 (build 6; Android 14; sdk 34; Pixel 7)”. The documented ‘AppName/1.2.3 (model; OS version; build N);` prefix convention is always recognized without configuration.



105
106
107
# File 'lib/sessions/configuration.rb', line 105

def native_app_names
  @native_app_names
end

#on_new_deviceObject

->(user:, session:, event:) — fired when a login doesn’t match any device this user has signed in from before. Wire your “Was this you?” email here (goodmail / noticed recipes in the README). Not fired on a user’s very first session (nobody wants a new-device alert on signup).



143
144
145
# File 'lib/sessions/configuration.rb', line 143

def on_new_device
  @on_new_device
end

#on_repeated_failed_loginsObject

->(identity:, count:, event:) — fired when an identity crosses the repeated_failed_logins threshold (see above). The identity is the email AS TYPED (it may match no account — resolve it yourself if you want to notify the owner); ‘event` is the failed_login that tripped the threshold, carrying IP, location and device.



153
154
155
# File 'lib/sessions/configuration.rb', line 153

def on_repeated_failed_logins
  @on_repeated_failed_logins
end

#on_session_revokedObject

->(session:, by:, reason:) — fired after a session is revoked.



146
147
148
# File 'lib/sessions/configuration.rb', line 146

def on_session_revoked
  @on_session_revoked
end

#parent_controllerObject

The controller the engine’s devices page inherits from. Pointing this at your ApplicationController (the default) gives the page your layout, helpers, auth filters and locale for free — the same pattern Devise, api_keys and chats use.



166
167
168
# File 'lib/sessions/configuration.rb', line 166

def parent_controller
  @parent_controller
end

#repeated_failed_loginsObject

Burst detection for failed logins: when set to ‘{ threshold: 5, within: 15.minutes }`, the on_repeated_failed_logins hook fires ONCE when an identity crosses `threshold` failed attempts inside the window — never per attempt (per-attempt alerts are both notification fatigue and an abuse vector: an attacker could spam a victim’s inbox by hammering the form). nil (the default) disables detection entirely.



80
81
82
# File 'lib/sessions/configuration.rb', line 80

def repeated_failed_logins
  @repeated_failed_logins
end

#request_client_hintsObject

Set ‘Accept-CH` on responses so Chromium browsers send high-entropy client hints (real platform versions, Android device models) on subsequent requests — login POSTs are rarely first-navigations, so hints are reliably present exactly when sessions get created. Safari/Firefox don’t implement client hints; they stay UA-only.



98
99
100
# File 'lib/sessions/configuration.rb', line 98

def request_client_hints
  @request_client_hints
end

#require_reauthenticationObject

->(controller) — optional sudo gate run before destructive actions on the devices page (ASVS 3.3.4’s “having re-entered login credentials”). nil (default) means no extra gate; wire your password-confirm flow here. The action runs only when the gate returns TRUTHY without rendering: render/redirect to take over the response, or return false/nil to block (a bare falsy gets a 403 — the gate fails closed, never through to the destructive action).



192
193
194
# File 'lib/sessions/configuration.rb', line 192

def require_reauthentication
  @require_reauthentication
end

#revoke_on_password_changeObject

Terminate other sessions when the user’s password changes (ASVS 3.3.3 / 7.4.3; Laravel’s logoutOtherDevices and Phoenix’s token nuke are the cross-framework precedent; Rails 8.1’s own password reset already destroy_alls). Wired by ‘has_sessions` via an after_update on the password digest column, so it works on both auth stacks.



61
62
63
# File 'lib/sessions/configuration.rb', line 61

def revoke_on_password_change
  @revoke_on_password_change
end

#revoke_remember_meObject

Devise mode only: revoking a session also rotates the user’s remember-me credentials (‘forget_me!`), closing the stolen-remember-cookie revival hole (GitLab semantics: other devices keep their live sessions but cannot auto-revive after those end).



67
68
69
# File 'lib/sessions/configuration.rb', line 67

def revoke_remember_me
  @revoke_remember_me
end

#session_classObject

The host’s session-of-record model, as a string. “Session” matches both the Rails 8 generator and the model our install generator writes in Devise mode. Escape hatch for apps with a conflicting legacy Session class (e.g. activerecord-session_store).



198
199
200
# File 'lib/sessions/configuration.rb', line 198

def session_class
  @session_class
end

#strategy_methodsObject

Maps Warden strategy classes to auth methods for classification, on top of the built-ins (DatabaseAuthenticatable → :password, Rememberable → :password, MagicLinkAuthenticatable → :magic_link). Keys are class-name substrings, values are method symbols:

config.strategy_methods = { "OtpAuthenticatable" => :otp }


205
206
207
# File 'lib/sessions/configuration.rb', line 205

def strategy_methods
  @strategy_methods
end

#touch_everyObject

How often ‘last_seen_at` may be written, per session. The touch is ONE conditional UPDATE (hot-row-safe, callback-free) and at most one write per session per window — authie’s touch-every-request and devise-security’s per-request update_column are the documented anti-patterns this throttle exists to avoid. ‘nil` disables touching entirely (your devices page then shows sign-in-time data only).



42
43
44
# File 'lib/sessions/configuration.rb', line 42

def touch_every
  @touch_every
end

#track_failed_loginsObject

Record failed login attempts (the ‘failed_login` trail). On by default; flip off if you only want the live device registry.



71
72
73
# File 'lib/sessions/configuration.rb', line 71

def track_failed_logins
  @track_failed_logins
end

#ua_parserObject

Which web UA parser projects raw user agents into device columns:

:browser          — the bundled default (MIT, zero-dep, tiny)
:device_detector  — auto-upgrade if your app bundles the
                    device_detector gem (better Android device
                    names, Client-Hints-native — but LGPL and 1.5 MB
                    of data, which is why it's not the default)
a lambda          — ->(user_agent, headers) { { browser_name: …, … } }


91
92
93
# File 'lib/sessions/configuration.rb', line 91

def ua_parser
  @ua_parser
end

Instance Method Details

#session_modelObject

The constantized session-of-record class (resolved lazily — see class comment).



416
417
418
# File 'lib/sessions/configuration.rb', line 416

def session_model
  session_class.constantize
end

#timeout_preset=(name) ⇒ Object

Sugar: ‘config.timeout_preset = :nist_aal2` sets both timeouts to the named NIST pair in one line.



274
275
276
277
278
279
280
281
282
283
# File 'lib/sessions/configuration.rb', line 274

def timeout_preset=(name)
  preset = TIMEOUT_PRESETS[name&.to_sym]
  unless preset
    raise ConfigurationError,
          "timeout_preset must be one of #{TIMEOUT_PRESETS.keys.inspect}, got #{name.inspect}"
  end

  @idle_timeout = preset[:idle]
  @max_session_lifetime = preset[:lifetime]
end

#validate!Object

Cross-field validation, run at the end of ‘Sessions.configure`.



404
405
406
407
408
409
410
411
412
# File 'lib/sessions/configuration.rb', line 404

def validate!
  if idle_timeout && max_session_lifetime && idle_timeout > max_session_lifetime
    raise ConfigurationError,
          "idle_timeout (#{idle_timeout.inspect}) can't exceed max_session_lifetime " \
          "(#{max_session_lifetime.inspect})"
  end

  true
end