Module: Sessions::Model
- Extended by:
- ActiveSupport::Concern
- Includes:
- DeviceDisplay
- Defined in:
- lib/sessions/models/concerns/model.rb
Overview
The registry concern — included into the host’s session-of-record model (‘Session`). On Rails 8 omakase apps the adapter includes it automatically at boot (the generated 2-line model stays untouched); in Devise mode the install generator writes a 3-line shell that includes it explicitly. Either way, ALL gem logic lives here, so the host’s model file never goes stale.
One mental model: **rows = active sessions; events = history.** A row is destroyed on logout/revocation/expiry (instant remote revocation — the same omakase semantics as Rails 8.1’s own password-reset destroy_all), and its tombstone lives in the ‘sessions_events` trail.
The three lifecycle callbacks observe 100% of both adapters’ flows:
before_create — enrich the row: normalize the IP, parse the
device, capture client hints, classify the auth
method, geolocate (when free).
after_create_commit — write the `login` event, detect new devices,
enforce the per-user cap, enqueue async geo.
after_destroy_commit — write the `logout`/`revoked`/`expired` event.
Every callback body is error-isolated: a parsing/geo/event failure may lose a log row; it may NEVER break a sign-in.
Constant Summary collapse
- INACTIVE_AFTER =
How long without activity before a session is grouped as “inactive” on the devices page (UI grouping only — never enforcement; expiry is the opt-in idle_timeout/max_session_lifetime pair).
30.days
Class Method Summary collapse
Instance Method Summary collapse
-
#active_now?(window = Sessions.config.touch_every || 5.minutes) ⇒ Boolean
“Active now” — activity within the touch window.
-
#current?(request = Sessions::Current.request) ⇒ Boolean
Whether this row is the one serving
request— powers the “This device” badge (which is also the row the devices page refuses to revoke). -
#last_active_at ⇒ Object
The moment of last activity: the throttled touch when present, else sign-in time.
-
#revoke!(reason: :user_revoked, by: nil) ⇒ Object
Destroy this session — remote logout, effective on that device’s very next request (both adapters validate liveness per request).
-
#second_factor!(kind) ⇒ Object
Stamp the second factor onto an ALREADY-LIVE session — the affordance for step-up flows where the row exists before the challenge completes (a post-login TOTP gate, a WebAuthn step-up before sensitive areas):.
-
#sessions_expired?(now = Time.current) ⇒ Boolean
Opt-in expiry — false unless the host configured timeouts.
-
#sessions_token_matches?(token) ⇒ Boolean
Constant-time token check (Devise mode).
-
#touch_last_seen!(request = nil) ⇒ Object
The throttled last-seen touch: at most one write per config.touch_every per session, issued as a single conditional UPDATE (hot-row-safe under concurrent requests, callback-free, and it also moves updated_at — which finally makes the Rails security guide’s own ‘Session.sweep` recommendation implementable).
Methods included from DeviceDisplay
#auth_method_label, #bot?, #country_flag, #device_name, #hotwire_native?, #location, #native_android?, #native_ios?, #second_factor, #second_factor?, #source_line, #via_oauth?, #via_password?, #web?
Class Method Details
.polymorphic_table?(klass) ⇒ Boolean
87 88 89 90 91 |
# File 'lib/sessions/models/concerns/model.rb', line 87 def self.polymorphic_table?(klass) klass.table_exists? && klass.column_names.include?("user_type") rescue StandardError false end |
Instance Method Details
#active_now?(window = Sessions.config.touch_every || 5.minutes) ⇒ Boolean
“Active now” — activity within the touch window. The window IS config.touch_every (last_seen_at lags by up to one throttle window by design), so the devices-page badge stays truthful whatever the host configured.
108 109 110 111 |
# File 'lib/sessions/models/concerns/model.rb', line 108 def active_now?(window = Sessions.config.touch_every || 5.minutes) activity = last_active_at activity.present? && activity > window.ago end |
#current?(request = Sessions::Current.request) ⇒ Boolean
Whether this row is the one serving request — powers the “This device” badge (which is also the row the devices page refuses to revoke).
116 117 118 119 120 |
# File 'lib/sessions/models/concerns/model.rb', line 116 def current?(request = Sessions::Current.request) return false unless request Sessions.current(request) == self end |
#last_active_at ⇒ Object
The moment of last activity: the throttled touch when present, else sign-in time.
100 101 102 |
# File 'lib/sessions/models/concerns/model.rb', line 100 def last_active_at try(:last_seen_at) || created_at end |
#revoke!(reason: :user_revoked, by: nil) ⇒ Object
Destroy this session — remote logout, effective on that device’s very next request (both adapters validate liveness per request). Writes a ‘revoked` (or `expired`) event with the reason and actor, rotates the user’s remember-me credentials in Devise mode (config.revoke_remember_me), and fires the on_session_revoked hook.
145 146 147 148 149 150 151 152 153 154 155 156 |
# File 'lib/sessions/models/concerns/model.rb', line 145 def revoke!(reason: :user_revoked, by: nil) self.revocation_reason = reason self.revoked_by = by destroy! Sessions.safely("revoke_remember_me") { sessions_forget_remember_me! } if Sessions.config.revoke_remember_me Sessions.safely("on_session_revoked hook") do Sessions.config.on_session_revoked.call(session: self, by: by, reason: reason) end self end |
#second_factor!(kind) ⇒ Object
Stamp the second factor onto an ALREADY-LIVE session — the affordance for step-up flows where the row exists before the challenge completes (a post-login TOTP gate, a WebAuthn step-up before sensitive areas):
Sessions.current(request)&.second_factor!("totp")
Flows that verify the second factor BEFORE the session exists (devise-two-factor, authentication-zero’s challenge controllers, devise-otp) don’t need this — they classify at login via the strategy map or a ‘Sessions.tag` call (see the README’s two-factor recipes). Reading happens through ‘second_factor` / `second_factor?`.
133 134 135 136 |
# File 'lib/sessions/models/concerns/model.rb', line 133 def second_factor!(kind) detail = (try(:auth_detail) || {}).to_h update!(auth_detail: detail.merge("second_factor" => kind.to_s)) end |
#sessions_expired?(now = Time.current) ⇒ Boolean
Opt-in expiry — false unless the host configured timeouts.
161 162 163 164 165 166 167 168 |
# File 'lib/sessions/models/concerns/model.rb', line 161 def sessions_expired?(now = Time.current) config = Sessions.config activity = last_active_at return true if config.idle_timeout && activity && activity < now - config.idle_timeout return true if config.max_session_lifetime && created_at && created_at < now - config.max_session_lifetime false end |
#sessions_token_matches?(token) ⇒ Boolean
Constant-time token check (Devise mode). Omakase rows store no token (the signed cookie is the credential) and never match.
208 209 210 211 212 213 |
# File 'lib/sessions/models/concerns/model.rb', line 208 def sessions_token_matches?(token) digest = try(:token_digest) return false if digest.blank? || token.blank? ActiveSupport::SecurityUtils.secure_compare(digest, Sessions.token_digest(token)) end |
#touch_last_seen!(request = nil) ⇒ Object
The throttled last-seen touch: at most one write per config.touch_every per session, issued as a single conditional UPDATE (hot-row-safe under concurrent requests, callback-free, and it also moves updated_at — which finally makes the Rails security guide’s own ‘Session.sweep` recommendation implementable).
175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 |
# File 'lib/sessions/models/concerns/model.rb', line 175 def touch_last_seen!(request = nil) every = Sessions.config.touch_every return false unless every return false unless sessions_column?("last_seen_at") now = Time.current threshold = now - every return false if last_seen_at && last_seen_at > threshold updates = { last_seen_at: now, updated_at: now } if sessions_column?("last_seen_ip") && request && (ip = Sessions::IpAddress.resolve(request)) updates[:last_seen_ip] = ip end updated = self.class.where(id: id) .where("last_seen_at IS NULL OR last_seen_at <= ?", threshold) .update_all(updates) if updated.positive? updates.each { |column, value| self[column] = value } clear_attribute_changes(updates.keys) true else # Another request won the race — refresh our throttle window so this # instance doesn't retry. self[:last_seen_at] = now clear_attribute_changes([:last_seen_at]) false end end |