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 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
-
.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.
-
.record_strict!(attributes, notify: true) ⇒ Object
Same tolerant attribute pipeline as ‘record!`, but persistence errors bubble.
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.
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
244 245 246 |
# File 'lib/sessions/models/event.rb', line 244 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"
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 |
#name ⇒ Object
‘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
248 249 250 |
# File 'lib/sessions/models/event.rb', line 248 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.
255 256 257 |
# File 'lib/sessions/models/event.rb', line 255 def reason failure_reason.presence || revoked_reason.presence end |
#reason_label ⇒ Object
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 |
#request ⇒ Object
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 |
#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).
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
240 241 242 |
# File 'lib/sessions/models/event.rb', line 240 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
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_h ⇒ Object
296 297 298 |
# File 'lib/sessions/models/event.rb', line 296 def to_h attributes.symbolize_keys end |
#user ⇒ Object
220 221 222 |
# File 'lib/sessions/models/event.rb', line 220 def user authenticatable end |