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

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

Returns:

  • (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.

Returns:

  • (Boolean)


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).

Returns:

  • (Boolean)


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

Returns:

  • (Boolean)


205
206
207
# File 'lib/sessions/models/concerns/model.rb', line 205

def ended?
  !live?
end

#last_active_atObject

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

Returns:

  • (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.

Returns:

  • (Boolean)


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

Returns:

  • (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.

Returns:

  • (Boolean)


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