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 live 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: registry rows get destroyed on revoke/logout (rows = active sessions); history must survive them.

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.



109
110
111
112
113
114
115
116
# File 'lib/sessions/models/event.rb', line 109

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



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

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



164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/sessions/models/event.rb', line 164

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



188
189
190
191
192
193
# File 'lib/sessions/models/event.rb', line 188

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.



87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
# File 'lib/sessions/models/event.rb', line 87

def record!(attributes)
  Sessions.safely("event") do
    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)
    event
  end
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.



122
123
124
125
126
127
128
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
# File 'lib/sessions/models/event.rb', line 122

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

Instance Method Details

#failure?Boolean

Returns:

  • (Boolean)


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

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"


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

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


209
210
211
# File 'lib/sessions/models/event.rb', line 209

def name
  event&.to_sym
end

#new_device?Boolean

Returns:

  • (Boolean)


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

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.



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

def reason
  failure_reason.presence || revoked_reason.presence
end

#reason_labelObject



260
261
262
263
264
# File 'lib/sessions/models/event.rb', line 260

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



229
230
231
# File 'lib/sessions/models/event.rb', line 229

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



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

def session
  return nil if session_id.nil?

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

#success?Boolean

Returns:

  • (Boolean)


233
234
235
# File 'lib/sessions/models/event.rb', line 233

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


274
275
276
277
278
279
280
281
282
283
284
285
286
287
# File 'lib/sessions/models/event.rb', line 274

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



289
290
291
# File 'lib/sessions/models/event.rb', line 289

def to_h
  attributes.symbolize_keys
end

#userObject



213
214
215
# File 'lib/sessions/models/event.rb', line 213

def user
  authenticatable
end