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 = session lifecycle state; events = audit history.** ‘ended_at: nil` means the device is live. Ending a row is an explicit state transition (`ended_reason`) and never relies on deleting a row plus later interpreting the absence. That distinction matters in Devise/Warden mode, where the gem decorates the host session and must never kick a user unless a durable explicit ending says so.
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 — best-effort compatibility for host-side deletes
(account erasure, Rails generator destroy_all).
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). -
#end!(reason:, by: nil, at: Time.current, metadata: nil, notify: true) ⇒ Object
End this registry row without deleting it.
- #ended? ⇒ Boolean
-
#last_active_at ⇒ Object
The moment of last activity: the throttled touch when present, else sign-in time.
- #live? ⇒ Boolean
-
#revoke!(reason: :user_revoked, by: nil) ⇒ Object
End this session as an explicit revocation/expiry action.
-
#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_kicks_on_resume? ⇒ Boolean
-
#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
96 97 98 99 100 |
# File 'lib/sessions/models/concerns/model.rb', line 96 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.
117 118 119 120 |
# File 'lib/sessions/models/concerns/model.rb', line 117 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).
125 126 127 128 129 |
# File 'lib/sessions/models/concerns/model.rb', line 125 def current?(request = Sessions::Current.request) return false unless request Sessions.current(request) == self end |
#end!(reason:, by: nil, at: Time.current, metadata: nil, notify: true) ⇒ Object
End this registry row without deleting it. This is the core v0.2 invariant: the row itself is the durable liveness state, and ‘sessions_events` remains a read-only audit trail.
152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 |
# File 'lib/sessions/models/concerns/model.rb', line 152 def end!(reason:, by: nil, at: Time.current, metadata: nil, notify: true) reason = Sessions::EndReason.normalize(reason) event = nil ended_now = false with_lock do unless ended? sessions_assign_end_state(reason: reason, by: by, at: at, metadata: ) save! ended_now = true event = sessions_record_end_event!(reason: reason, by: by, notify: false) end end Sessions.notify_event(event) if notify && event @sessions_ended_now = ended_now self end |
#ended? ⇒ Boolean
205 206 207 |
# File 'lib/sessions/models/concerns/model.rb', line 205 def ended? !live? end |
#last_active_at ⇒ Object
The moment of last activity: the throttled touch when present, else sign-in time.
109 110 111 |
# File 'lib/sessions/models/concerns/model.rb', line 109 def last_active_at try(:last_seen_at) || created_at end |
#live? ⇒ Boolean
201 202 203 |
# File 'lib/sessions/models/concerns/model.rb', line 201 def live? !sessions_column?("ended_at") || try(:ended_at).blank? end |
#revoke!(reason: :user_revoked, by: nil) ⇒ Object
End this session as an explicit revocation/expiry action. In Devise mode the next request still proves possession of the per-row token before the adapter kicks; in Rails-8 auth mode the row id in the signed cookie now resolves to an ended row and the controller hook refuses it.
175 176 177 178 179 180 181 182 183 184 185 |
# File 'lib/sessions/models/concerns/model.rb', line 175 def revoke!(reason: :user_revoked, by: nil) end!(reason: reason, by: by) return self unless @sessions_ended_now 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?`.
142 143 144 145 |
# File 'lib/sessions/models/concerns/model.rb', line 142 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.
190 191 192 193 194 195 196 197 198 199 |
# File 'lib/sessions/models/concerns/model.rb', line 190 def sessions_expired?(now = Time.current) return false if ended? 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_kicks_on_resume? ⇒ Boolean
209 210 211 |
# File 'lib/sessions/models/concerns/model.rb', line 209 def sessions_kicks_on_resume? ended? && Sessions::EndReason.kicks_on_resume?(try(:ended_reason)) 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.
252 253 254 255 256 257 |
# File 'lib/sessions/models/concerns/model.rb', line 252 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).
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 244 245 246 247 248 |
# File 'lib/sessions/models/concerns/model.rb', line 218 def touch_last_seen!(request = nil) every = Sessions.config.touch_every return false unless every return false unless sessions_column?("last_seen_at") return false if ended? 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 |