Class: Sessions::Event
- Inherits:
-
ActiveRecord::Base
- Object
- ActiveRecord::Base
- Sessions::Event
- 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
-
.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.
- .context_for(request) ⇒ Object
-
.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.
-
.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).
-
.record!(attributes) ⇒ Object
The single, error-isolated write path.
-
.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.
Instance Method Summary collapse
- #failure? ⇒ Boolean
-
#label ⇒ Object
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”.
-
#name ⇒ Object
‘event.name` reads better than `event.event` in host hooks: config.events = ->(event) { AuditLog.log(event_type: “session.#eventevent.name”, …) }.
- #new_device? ⇒ Boolean
-
#reason ⇒ Object
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.
- #reason_label ⇒ Object
-
#request ⇒ Object
The request being served when the event was recorded (only available in the same request cycle — handy inside ‘config.events` hooks).
-
#session ⇒ Object
The live registry row this event points at — nil once it’s been revoked/logged out (that’s the point of the trail).
- #success? ⇒ Boolean
-
#summary ⇒ Object
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:.
- #to_h ⇒ Object
- #user ⇒ Object
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
237 238 239 |
# File 'lib/sessions/models/event.rb', line 237 def failure? event == "failed_login" end |
#label ⇒ Object
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 |
#name ⇒ Object
‘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
241 242 243 |
# File 'lib/sessions/models/event.rb', line 241 def new_device? !!(.is_a?(Hash) && ["new_device"]) end |
#reason ⇒ Object
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_label ⇒ Object
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 |
#request ⇒ Object
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 |
#session ⇒ Object
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
233 234 235 |
# File 'lib/sessions/models/event.rb', line 233 def success? event == "login" end |
#summary ⇒ Object
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_h ⇒ Object
289 290 291 |
# File 'lib/sessions/models/event.rb', line 289 def to_h attributes.symbolize_keys end |
#user ⇒ Object
213 214 215 |
# File 'lib/sessions/models/event.rb', line 213 def user authenticatable end |