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.last_login(request) # how this browser last signed in ("Last used" badge)
Sessions.record_failed_attempt(request, identity: params[:email], reason: :invalid_password)
Sessions.track_login(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
- DEVICE_COOKIE =
The signed browser-continuity cookie (see Sessions::Model — minted at login, identifies the browser install so repeat logins replace their old device row instead of stacking duplicates).
: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
-
.config ⇒ Object
(also: configuration)
--- Configuration --------------------------------------------------------.
- .configure {|config| ... } ⇒ Object
-
.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).
-
.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.
- .generate_token ⇒ Object
-
.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.
- .logger ⇒ Object
-
.notify_event(event) ⇒ Object
The catch-all
config.eventstee, error-isolated. -
.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…).
-
.reset! ⇒ Object
Reset all global state.
-
.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
safelypattern, ecosystem rule). -
.session_model ⇒ Object
The host's session-of-record model (
Sessionon both supported stacks;config.session_classis the escape hatch). -
.skip!(request) ⇒ Object
Silence tracking for THIS request — the escape hatch for flows that intentionally end with neither a session nor a failure.
-
.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.
-
.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).
-
.token_digest(token) ⇒ Object
SHA-256 of a session token.
-
.track_login(user, request, method: nil, provider: nil, detail: {}) ⇒ Object
Fully manual integration: create (and classify, parse, geolocate) a registry row + login event for
useroutside any adapter. -
.user_for_identity(identity, scope: nil) ⇒ Object
Resolve a typed login identity to an account for host hooks.
- .warn(message) ⇒ Object
-
.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.
Class Method Details
.config ⇒ Object Also known as: configuration
--- Configuration --------------------------------------------------------
72 73 74 |
# File 'lib/sessions.rb', line 72 def config @config ||= Configuration.new end |
.configure {|config| ... } ⇒ Object
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) || (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_token ⇒ Object
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 last_login(request) return nil unless request.respond_to?(:cookie_jar) safely("last_login") do device_id = request..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 |
.logger ⇒ Object
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.}") nil end |
.session_model ⇒ Object
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 track_login(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() logger&.warn("[sessions] #{}") 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 |