Class: Sessions::Event

Inherits:
ActiveRecord::Base
  • Object
show all
Includes:
DeviceDisplay
Defined in:
lib/sessions/models/event.rb

Overview

The append-only login-activity trail: every successful AND failed login, logout, revocation and expiry — with attempted identity, device, geo, and the linkage no prior art has: session_id points at the lifecycle registry row the event created (or ended), so a suspicious login in the trail is one click away from revoking the session it started.

session_id is a plain column with NO foreign key on purpose: hosts may still hard-delete rows for account erasure or legacy Rails destroy_all flows, and history must survive that. Normal v0.2 logout/revocation ends the row in place and records an event as audit, not liveness state.

Rows are written through one tolerant pipeline (.record!): unknown attributes are dropped instead of raising, so hosts can add or remove columns without waiting for a gem release (authtrail's proven pattern).

Scopes are the admin product (BYOUI):

Sessions::Event.failed_logins.last_24_hours.group(:ip_address).count
Sessions::Event.for_identity("j@example.com")     # ATO investigation
Sessions::Event.failed_logins.for_ip("203.0.113.7")
Sessions::Event.by_country("RU").logins

Constant Summary collapse

EVENTS =
%w[login failed_login logout revoked expired].freeze

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

.call_repeated_failed_logins_hook(**kwargs) ⇒ Object



205
206
207
208
209
210
211
212
213
214
# File 'lib/sessions/models/event.rb', line 205

def call_repeated_failed_logins_hook(**kwargs)
  hook = Sessions.config.on_repeated_failed_logins
  params = hook_keyword_parameters(hook)
  return hook.call(**kwargs) if params.nil?
  return hook.call(**kwargs) if params.any? { |type, _name| type == :keyrest }

  accepted = params.select { |type, _name| %i[key keyreq].include?(type) }
                   .filter_map { |_type, name| name }
  hook.call(**kwargs.slice(*accepted))
end

.clamp_string_columns!(event) ⇒ Object

Clamp string columns to their limits BEFORE the insert: the identity is attacker-typed (a 10KB "email" must not turn into MySQL's ValueTooLong and silently cost us the failure row — that row IS the attack trail), and hosts may have pruned the text columns down to strings.



116
117
118
119
120
121
122
123
# File 'lib/sessions/models/event.rb', line 116

def clamp_string_columns!(event)
  columns_hash.each do |name, column|
    next unless column.type == :string && column.limit
    next unless (value = event[name]).is_a?(String) && value.length > column.limit

    event[name] = value[0, column.limit]
  end
end

.context_for(request) ⇒ Object



229
230
231
232
233
234
235
236
# File 'lib/sessions/models/event.rb', line 229

def context_for(request)
  params = request.respond_to?(:path_parameters) ? request.path_parameters : nil
  return nil unless params && params[:controller]

  "#{params[:controller]}##{params[:action]}"
rescue StandardError
  nil
end

.hook_keyword_parameters(hook) ⇒ Object

Procs/lambdas/Method objects expose #parameters directly; a plain callable object (a class with #call) only exposes them through #method(:call). nil means "cannot introspect" — dispatch everything and let the hook decide.



220
221
222
223
224
225
226
227
# File 'lib/sessions/models/event.rb', line 220

def hook_keyword_parameters(hook)
  return hook.parameters if hook.respond_to?(:parameters)
  return hook.method(:call).parameters if hook.respond_to?(:call)

  nil
rescue StandardError
  nil
end

.maybe_alert_repeated_failures(event) ⇒ Object

Burst detection (config.repeated_failed_logins): fires the hook exactly when the identity CROSSES the threshold inside the window — count == threshold, so the 6th, 7th… attempt doesn't re-fire and an attacker can't turn the alert into an inbox-flooding primitive. (Two simultaneous commits can race past the crossing — the alert is then skipped rather than doubled; for a notification, missing one beats spamming two.)



171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
# File 'lib/sessions/models/event.rb', line 171

def maybe_alert_repeated_failures(event)
  config = Sessions.config.repeated_failed_logins
  return unless config
  return if event.identity.blank?

  count = failed_logins
          .for_identity(event.identity)
          .where(occurred_at: config[:within].ago..)
          .count
  return unless count == config[:threshold]

  user = Sessions.user_for_identity(event.identity, scope: event.scope)

  Sessions.safely("on_repeated_failed_logins hook") do
    call_repeated_failed_logins_hook(
      identity: event.identity,
      count: count,
      event: event,
      user: user
    )
  end
end

.normalize_identity(identity) ⇒ Object

Emails-as-typed are normalized (strip + downcase) so failed attempts correlate across casing — but stored even for identities that match no account (the data authtrail proved valuable and Rodauth can't capture).



198
199
200
201
202
203
# File 'lib/sessions/models/event.rb', line 198

def normalize_identity(identity)
  return nil if identity.nil?

  normalized = identity.to_s.strip.downcase
  normalized.empty? ? nil : normalized
end

.record!(attributes) ⇒ Object

The single, error-isolated write path. Tolerant-assigns every attribute (unknown columns are skipped via try), normalizes the typed identity for correlation, stamps occurred_at, persists, and tees the event into config.events. Returns the Event or nil — never raises into a login.



88
89
90
# File 'lib/sessions/models/event.rb', line 88

def record!(attributes)
  Sessions.safely("event") { record_strict!(attributes) }
end

.record_failure(request, scope: nil, identity: nil, reason: nil, metadata: {}) ⇒ Object

Build a failed_login event straight from a request — the shared engine behind Warden's before_failure, the OmniAuth failure composer, the omakase controller hook, and the public Sessions.record_failed_attempt seam.



129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
# File 'lib/sessions/models/event.rb', line 129

def record_failure(request, scope: nil, identity: nil, reason: nil, metadata: {})
  headers = Sessions::Device.headers_from(request)
  user_agent = request&.user_agent
  device = Sessions::Device.parse(user_agent, headers: headers)
  auth = Sessions::Classifier.classify(request)
  ip = Sessions::IpAddress.resolve(request)

  geo = {}
  if ip && Sessions::Geolocation.cloudflare_headers?(request)
    geo = Sessions::Geolocation.locate(ip, request: request, coordinates: true)
  end

  event = record!(
    device.to_h.merge(geo).merge(
      event: "failed_login",
      scope: scope&.to_s,
      identity: identity,
      failure_reason: reason&.to_s,
      auth_method: auth[:method],
      auth_provider: auth[:provider],
      auth_detail: auth[:detail].presence,
      ip_address: ip,
      user_agent: user_agent,
      client_hints: headers.presence,
      request_id: (request.request_id if request.respond_to?(:request_id)),
      context: context_for(request),
      metadata: .presence
    )
  )

  Sessions::Geolocation.enqueue(event) if event && event.try(:country_code).blank?
  maybe_alert_repeated_failures(event) if event
  event
end

.record_strict!(attributes, notify: true) ⇒ Object

Same tolerant attribute pipeline as record!, but persistence errors bubble. Session#end! writes lifecycle state and its matching audit event in one transaction; if the event cannot be written, the row must stay live rather than silently losing the audit trail.



96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/sessions/models/event.rb', line 96

def record_strict!(attributes, notify: true)
  event = new
  attributes.each do |name, value|
    next if value.nil?

    event.try(:"#{name}=", value)
  end
  event.identity = normalize_identity(event.try(:identity))
  clamp_string_columns!(event)
  event.save!

  Sessions.notify_event(event) if notify
  event
end

Instance Method Details

#failure?Boolean

Returns:

  • (Boolean)


272
273
274
# File 'lib/sessions/models/event.rb', line 272

def failure?
  event == "failed_login"
end

#labelObject

Human, localized labels (the gem ships en + es; hosts override the i18n keys like any Rails app):

event.label        # => "Signed in" / "Inicio de sesión"
event.reason_label # => "wrong credentials" / "credenciales incorrectas"


291
292
293
# File 'lib/sessions/models/event.rb', line 291

def label
  I18n.t("sessions.history.events.#{event}", default: event.to_s.humanize)
end

#nameObject

event.name reads better than event.event in host hooks:

config.events = ->(event) { AuditLog.log(event_type: "session.#{event.name}", …) }


243
244
245
# File 'lib/sessions/models/event.rb', line 243

def name
  event&.to_sym
end

#new_device?Boolean

Returns:

  • (Boolean)


276
277
278
# File 'lib/sessions/models/event.rb', line 276

def new_device?
  !!(.is_a?(Hash) && ["new_device"])
end

#reasonObject

The reason that applies to THIS event: the failure reason on failed logins, the revocation reason on revocations — so views and hooks never branch on the event type to find it.



283
284
285
# File 'lib/sessions/models/event.rb', line 283

def reason
  failure_reason.presence || revoked_reason.presence
end

#reason_labelObject



295
296
297
298
299
# File 'lib/sessions/models/event.rb', line 295

def reason_label
  return nil unless reason

  I18n.t("sessions.history.reasons.#{reason}", default: reason.humanize.downcase)
end

#requestObject

The request being served when the event was recorded (only available in the same request cycle — handy inside config.events hooks).



264
265
266
# File 'lib/sessions/models/event.rb', line 264

def request
  Sessions::Current.request
end

#sessionObject

The lifecycle registry row this event points at. In v0.2 an ended row remains available as explicit revocation state; nil means the host later hard-deleted it (for example during account erasure).



254
255
256
257
258
259
260
# File 'lib/sessions/models/event.rb', line 254

def session
  return nil if session_id.nil?

  Sessions.session_model.find_by(id: session_id)
rescue StandardError
  nil
end

#success?Boolean

Returns:

  • (Boolean)


268
269
270
# File 'lib/sessions/models/event.rb', line 268

def success?
  event == "login"
end

#summaryObject

The audit-friendly compact projection — exactly what a config.events tee wants to forward to an audit ledger or analytics pipe, without hand-picking columns:

config.events = ->(event) do
AuditLog.log(event_type: "session.#{event.name}", user: event.user,
             request: event.request, data: event.summary)
end


309
310
311
312
313
314
315
316
317
318
319
320
321
322
# File 'lib/sessions/models/event.rb', line 309

def summary
  {
    session_id: session_id,
    identity: identity,
    device: (device_name if try(:device_type).present? && device_type != "unknown"),
    device_type: device_type,
    auth_method: auth_method,
    auth_provider: auth_provider,
    failure_reason: failure_reason,
    revoked_reason: revoked_reason,
    ip: ip_address,
    country: country_code
  }.compact
end

#to_hObject



324
325
326
# File 'lib/sessions/models/event.rb', line 324

def to_h
  attributes.symbolize_keys
end

#userObject



247
248
249
# File 'lib/sessions/models/event.rb', line 247

def user
  authenticatable
end