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

.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



202
203
204
205
206
207
208
209
# File 'lib/sessions/models/event.rb', line 202

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

.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
# 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]

  Sessions.safely("on_repeated_failed_logins hook") do
    Sessions.config.on_repeated_failed_logins.call(
      identity: event.identity,
      count: count,
      event: event
    )
  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).



195
196
197
198
199
200
# File 'lib/sessions/models/event.rb', line 195

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)


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

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"


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

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}", …) }


216
217
218
# File 'lib/sessions/models/event.rb', line 216

def name
  event&.to_sym
end

#new_device?Boolean

Returns:

  • (Boolean)


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

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.



255
256
257
# File 'lib/sessions/models/event.rb', line 255

def reason
  failure_reason.presence || revoked_reason.presence
end

#reason_labelObject



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

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



236
237
238
# File 'lib/sessions/models/event.rb', line 236

def request
  Sessions::Current.request
end

#sessionObject

The live registry row this event points at — nil once it’s been revoked/logged out (that’s the point of the trail).



226
227
228
229
230
231
232
# File 'lib/sessions/models/event.rb', line 226

def session
  return nil if session_id.nil?

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

#success?Boolean

Returns:

  • (Boolean)


240
241
242
# File 'lib/sessions/models/event.rb', line 240

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


281
282
283
284
285
286
287
288
289
290
291
292
293
294
# File 'lib/sessions/models/event.rb', line 281

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



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

def to_h
  attributes.symbolize_keys
end

#userObject



220
221
222
# File 'lib/sessions/models/event.rb', line 220

def user
  authenticatable
end