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

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)


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.

Returns:

  • (Boolean)


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

Returns:

  • (Boolean)


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_atObject

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.

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean)


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