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/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/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
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.active # 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, EngineHelper, Generators, Geolocation, HasSessions, IpAddress, Macros, 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.1.0"
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 live session, 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.events` tee, 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 ‘safely` pattern, ecosystem rule).
-
.session_model ⇒ Object
The host’s session-of-record model (‘Session` on both supported stacks; `config.session_class` is 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. - .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 ——————————————————–
71 72 73 |
# File 'lib/sessions.rb', line 71 def config @config ||= Configuration.new end |
.configure {|config| ... } ⇒ Object
77 78 79 80 81 |
# File 'lib/sessions.rb', line 77 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)
146 147 148 149 150 151 152 153 154 155 156 157 158 |
# File 'lib/sessions.rb', line 146 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 live session, 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.
251 252 253 254 255 256 257 258 259 260 261 |
# File 'lib/sessions.rb', line 251 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
293 294 295 |
# File 'lib/sessions.rb', line 293 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" %>
<span class="badge">Last used</span>
<% 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.
176 177 178 179 180 181 182 183 184 185 186 |
# File 'lib/sessions.rb', line 176 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
281 282 283 |
# File 'lib/sessions.rb', line 281 def logger defined?(::Rails) && ::Rails.respond_to?(:logger) ? ::Rails.logger : nil end |
.notify_event(event) ⇒ Object
The catch-all ‘config.events` tee, error-isolated.
308 309 310 |
# File 'lib/sessions.rb', line 308 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.
198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 |
# File 'lib/sessions.rb', line 198 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.
85 86 87 88 |
# File 'lib/sessions.rb', line 85 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.
269 270 271 272 273 274 |
# File 'lib/sessions.rb', line 269 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).
92 93 94 |
# File 'lib/sessions.rb', line 92 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.
128 129 130 131 132 133 |
# File 'lib/sessions.rb', line 128 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.
240 241 242 243 244 245 246 |
# File 'lib/sessions.rb', line 240 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 })
105 106 107 108 109 110 |
# File 'lib/sessions.rb', line 105 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).
289 290 291 |
# File 'lib/sessions.rb', line 289 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.
219 220 221 222 223 224 225 226 227 228 229 230 231 |
# File 'lib/sessions.rb', line 219 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 |
.warn(message) ⇒ Object
276 277 278 279 |
# File 'lib/sessions.rb', line 276 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.
299 300 301 302 303 304 305 |
# File 'lib/sessions.rb', line 299 def with_request(request) previous = Sessions::Current.request Sessions::Current.request = request yield ensure Sessions::Current.request = previous end |