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
- .call_repeated_failed_logins_hook(**kwargs) ⇒ Object
-
.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
-
.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).
-
.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_loginevent 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.namereads better thanevent.eventin 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.eventshooks). -
#session ⇒ Object
The lifecycle registry row this event points at.
- #success? ⇒ Boolean
-
#summary ⇒ Object
The audit-friendly compact projection — exactly what a
config.eventstee 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
.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
272 273 274 |
# File 'lib/sessions/models/event.rb', line 272 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"
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 |
#name ⇒ Object
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
276 277 278 |
# File 'lib/sessions/models/event.rb', line 276 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.
283 284 285 |
# File 'lib/sessions/models/event.rb', line 283 def reason failure_reason.presence || revoked_reason.presence end |
#reason_label ⇒ Object
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 |
#request ⇒ Object
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 |
#session ⇒ Object
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
268 269 270 |
# File 'lib/sessions/models/event.rb', line 268 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
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_h ⇒ Object
324 325 326 |
# File 'lib/sessions/models/event.rb', line 324 def to_h attributes.symbolize_keys end |
#user ⇒ Object
247 248 249 |
# File 'lib/sessions/models/event.rb', line 247 def user authenticatable end |