Module: Sessions

Defined in:
lib/sessions.rb,
lib/sessions/device.rb,
lib/sessions/engine.rb,
lib/sessions/errors.rb,
lib/sessions/macros.rb,
lib/sessions/current.rb,
lib/sessions/version.rb,
lib/sessions/classifier.rb,
lib/sessions/end_reason.rb,
lib/sessions/ip_address.rb,
lib/sessions/middleware.rb,
lib/sessions/geolocation.rb,
lib/sessions/models/event.rb,
lib/sessions/configuration.rb,
lib/sessions/adapters/warden.rb,
lib/sessions/adapters/omakase.rb,
lib/sessions/adapters/omniauth.rb,
lib/sessions/jobs/geolocate_job.rb,
app/helpers/sessions/engine_helper.rb,
lib/sessions/models/concerns/model.rb,
lib/generators/sessions/views_generator.rb,
lib/generators/sessions/madmin_generator.rb,
lib/generators/sessions/install_generator.rb,
lib/generators/sessions/upgrade_generator.rb,
lib/sessions/models/concerns/has_sessions.rb,
app/controllers/sessions/devices_controller.rb,
lib/sessions/models/concerns/device_display.rb,
app/controllers/sessions/application_controller.rb,
lib/generators/sessions/templates/madmin/user_panel/madmin_user_panel.rb

Overview

Sessions

Every session, every device, every login — tracked, revocable, visible. The missing session layer for Rails.

The public surface is intentionally tiny:

Sessions.configure { |config| ... }       # one block, in an initializer
has_sessions                              # on your auth model

current_user.sessions.live                # live devices
session.device_name                       # => "Chrome on macOS"
session.revoke!                           # remote logout, effective next request
current_user.revoke_other_sessions!       # GitHub's "sign out everywhere else"
current_user.session_history.failed_logins # the trail, identity-matched failures included

Plus a handful of request-side seams for flows that can't self-identify:

Sessions.tag(request, method: :passkey)   # label the upcoming login
Sessions.skip!(request)                   # "neither a login nor a failure" (2FA handoffs)
Sessions.current(request)                 # this request's session row
Sessions.(request)              # how this browser last signed in ("Last used" badge)
Sessions.record_failed_attempt(request, identity: params[:email], reason: :invalid_password)
Sessions.(user, request, method: :sso)

Everything else (adapters, the devices page, the sweep) ships with the engine and stays out of your way. One rule above all: tracking NEVER breaks authentication — every recording path in this gem is error-isolated (see Sessions.safely).

Defined Under Namespace

Modules: Adapters, Classifier, DeviceDisplay, EndReason, EngineHelper, Generators, Geolocation, HasSessions, IpAddress, Macros, MadminUserPanel, Model Classes: ApplicationController, Configuration, ConfigurationError, Current, Device, DevicesController, Engine, Error, Event, EventResource, GeolocateJob, Middleware, UnknownAuthSystemError

Constant Summary collapse

:sessions_device_id
SKIP_ENV_KEY =

The rack env flag Sessions.skip! sets — every recording seam checks it before writing anything for the request.

"sessions.skip"
VERSION =
"0.2.2"

Class Method Summary collapse

Class Method Details

.configObject Also known as: configuration

--- Configuration --------------------------------------------------------



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

def config
  @config ||= Configuration.new
end

.configure {|config| ... } ⇒ Object

Yields:



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

def configure
  yield config if block_given?
  config.validate!
  config
end

.current(request = Sessions::Current.request, scope: nil) ⇒ Object

The registry row for this request — works on both adapters: omakase (Current.session / the signed session cookie) and Devise/Warden (the per-scope token stashed in the warden session). Returns nil when the request carries no live tracked session.

Multi-scope Devise apps (user + admin signed in on one rack session) carry one tracked row per scope; pass scope: to pick — without it you get the first live row found, which is unambiguous for the single-scope majority:

Sessions.current(request, scope: :admin_user)


168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/sessions.rb', line 168

def current(request = Sessions::Current.request, scope: nil)
  return nil unless request

  safely("current") do
    if scope
      # An explicit scope is a Warden concept — answering it from
      # Current.session (the omakase shortcut) would ignore it.
      warden_current(request, scope: scope)
    else
      omakase_current(request) || warden_current(request) || cookie_current(request)
    end
  end
end

.forget(user, identity: nil) ⇒ Object

Right-to-erasure helper: destroy every session row, delete the trail, and null the typed identity on any retained failure rows that match +user+'s email — so honoring a GDPR deletion request is one call.



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

def forget(user, identity: nil)
  safely("forget") do
    session_model.where(user: user).destroy_all if session_model_table?
    Sessions::Event.where(authenticatable: user).delete_all

    typed = identity || user.try(:email_address) || user.try(:email)
    Sessions::Event.where(identity: Sessions::Event.normalize_identity(typed)).update_all(identity: nil) if typed

    true
  end
end

.generate_tokenObject



315
316
317
# File 'lib/sessions.rb', line 315

def generate_token
  SecureRandom.hex(32)
end

.last_login(request) ⇒ Object

The most recent login EVENT from THIS BROWSER — works on the login page, signed out, because the browser-continuity cookie (the same one that deduplicates devices) survives logout by design. This is the one-lookup answer behind the "Last used" badge next to your sign-in buttons:

<% if (last = Sessions.last_login(request))&.auth_provider == "google" %>
Last used
<% end %>

The event carries auth_method / auth_provider / auth_method_label / occurred_at ("last used 2 days ago"). Device-scoped, not account-scoped: it reflects whoever last signed in from this browser — exactly what a login page can honestly know. Returns nil for browsers that never signed in, cleared cookies, or tampered values (the cookie is signed). Read-only: never mints the cookie.



198
199
200
201
202
203
204
205
206
207
208
# File 'lib/sessions.rb', line 198

def (request)
  return nil unless request.respond_to?(:cookie_jar)

  safely("last_login") do
    device_id = request.cookie_jar.signed[DEVICE_COOKIE]
    next nil if device_id.blank?

    Sessions::Event.logins.where(device_id: device_id.to_s[0, 36])
                   .order(occurred_at: :desc).first
  end
end

.loggerObject



303
304
305
# File 'lib/sessions.rb', line 303

def logger
  defined?(::Rails) && ::Rails.respond_to?(:logger) ? ::Rails.logger : nil
end

.notify_event(event) ⇒ Object

The catch-all config.events tee, error-isolated.



330
331
332
# File 'lib/sessions.rb', line 330

def notify_event(event)
  safely("events hook") { config.events.call(event) }
end

.record_failed_attempt(request, scope: nil, identity: nil, reason: nil, method: nil, provider: nil, detail: {}, metadata: {}) ⇒ Object

Record a failed login attempt from a custom controller — the manual seam for flows outside Warden's failure app and the omakase SessionsController (a native-app sign-in branch, passkey verification rescues, One Tap token errors…).

Sessions.record_failed_attempt(request, scope: :user,
                             identity: params[:email],
                             reason: :invalid_password)

Never raises; returns the Sessions::Event or nil.



220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
# File 'lib/sessions.rb', line 220

def record_failed_attempt(request, scope: nil, identity: nil, reason: nil,
                          method: nil, provider: nil, detail: {}, metadata: {})
  return nil unless config.track_failed_logins

  safely("record_failed_attempt") do
    tag(request, method: method, provider: provider, detail: detail) if method

    Sessions::Event.record_failure(
      request,
      scope: scope,
      identity: identity,
      reason: reason,
      metadata: 
    )
  end
end

.reset!Object

Reset all global state. Used by the test suite to keep examples isolated; also handy in a console when experimenting.



86
87
88
89
# File 'lib/sessions.rb', line 86

def reset!
  @config = Configuration.new
  self
end

.safely(context = nil) ⇒ Object

The error-isolation chokepoint: this gem sits on the authentication hot path, where a tracking bug may lose a log row but must NEVER 500 a sign-in (authtrail's safely pattern, ecosystem rule). Everything the adapters and model callbacks do goes through here.



291
292
293
294
295
296
# File 'lib/sessions.rb', line 291

def safely(context = nil)
  yield
rescue StandardError => e
  warn("#{context}: #{e.class}: #{e.message}")
  nil
end

.session_modelObject

The host's session-of-record model (Session on both supported stacks; config.session_class is the escape hatch).



93
94
95
# File 'lib/sessions.rb', line 93

def session_model
  config.session_model
end

.skip!(request) ⇒ Object

Silence tracking for THIS request — the escape hatch for flows that intentionally end with neither a session nor a failure. The canonical case is the password phase of a two-phase 2FA challenge (authentication-zero's --two-factor, hand-rolled TOTP gates): the password was RIGHT, the controller redirects to the challenge, and recording a failed_login there would be a lie:

if user.otp_required_for_sign_in?
Sessions.skip!(request)
session[:challenge_token] = user.signed_id(...)
redirect_to new_two_factor_authentication_challenge_totp_path
end

Honored by every recording seam (both adapters, the failed-login heuristics). One request only — the challenge completion records normally.



150
151
152
153
154
155
# File 'lib/sessions.rb', line 150

def skip!(request)
  return unless request

  request.env[SKIP_ENV_KEY] = true
  request
end

.sweep!Object

The maintenance pass the generated SessionsSweepJob runs on a schedule: expire idle/over-age sessions (only when timeouts are configured), evict per-user overflow beyond the session cap, and purge trail rows past retention. Each part is independently error-isolated. Returns a Hash of counts.



262
263
264
265
266
267
268
# File 'lib/sessions.rb', line 262

def sweep!
  {
    expired: safely("sweep.expired") { sweep_expired_sessions! } || 0,
    pruned: safely("sweep.pruned") { sweep_session_overflow! } || 0,
    purged_events: safely("sweep.events") { sweep_stale_events! } || 0
  }
end

.tag(request, method:, provider: nil, detail: {}) ⇒ Object

Label the login that's about to happen on this request — for flows that can't self-identify at the session-row level (Google One Tap, passkeys, magic links, custom SSO). Call it BEFORE signing the user in; the classification pipeline gives explicit tags top priority.

Sessions.tag(request, method: :google_one_tap, detail: { select_by: params[:select_by] })
Sessions.tag(request, method: :passkey, detail: { user_verified: true })


127
128
129
130
131
132
# File 'lib/sessions.rb', line 127

def tag(request, method:, provider: nil, detail: {})
  return unless request

  request.env[Classifier::TAG_ENV_KEY] = { method: method, provider: provider, detail: detail }
  request
end

.token_digest(token) ⇒ Object

SHA-256 of a session token. High-entropy random input ⇒ a plain digest suffices (no pepper KDF theater); the raw token only ever lives in the user's own Rack session (OWASP: never persist raw session identifiers).



311
312
313
# File 'lib/sessions.rb', line 311

def token_digest(token)
  OpenSSL::Digest::SHA256.hexdigest(token.to_s)
end

.track_login(user, request, method: nil, provider: nil, detail: {}) ⇒ Object

Fully manual integration: create (and classify, parse, geolocate) a registry row + login event for user outside any adapter. The host owns linking the returned row to its own session mechanism and enforcing revocation. Never raises; returns the session row or nil.



241
242
243
244
245
246
247
248
249
250
251
252
253
# File 'lib/sessions.rb', line 241

def (user, request, method: nil, provider: nil, detail: {})
  safely("track_login") do
    tag(request, method: method, provider: provider, detail: detail) if method

    with_request(request) do
      session_model.create!(
        user: user,
        ip_address: IpAddress.resolve(request),
        user_agent: request&.user_agent
      )
    end
  end
end

.user_for_identity(identity, scope: nil) ⇒ Object

Resolve a typed login identity to an account for host hooks. Failed login events still do NOT persist an account link (enumeration safety); this is only an in-process convenience for notification hooks that are already allowed to know whether the account exists.



101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/sessions.rb', line 101

def user_for_identity(identity, scope: nil)
  normalized = Sessions::Event.normalize_identity(identity)
  return nil if normalized.blank?

  safely("user_for_identity") do
    model = user_model_for_identity(scope)
    next nil unless model.respond_to?(:column_names)

    column = %w[email email_address login username phone].find { |name| model.column_names.include?(name) }
    next nil unless column

    table = model.quoted_table_name
    quoted_column = model.connection.quote_column_name(column)
    model.where("LOWER(#{table}.#{quoted_column}) = ?", normalized).first
  end
end

.warn(message) ⇒ Object



298
299
300
301
# File 'lib/sessions.rb', line 298

def warn(message)
  logger&.warn("[sessions] #{message}")
  nil
end

.with_request(request) ⇒ Object

Run a block with Sessions::Current.request temporarily set — lets explicit APIs reuse the same model-callback pipeline the adapters use.



321
322
323
324
325
326
327
# File 'lib/sessions.rb', line 321

def with_request(request)
  previous = Sessions::Current.request
  Sessions::Current.request = request
  yield
ensure
  Sessions::Current.request = previous
end