sessions 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rubocop.yml +61 -0
- data/.simplecov +54 -0
- data/AGENTS.md +5 -0
- data/Appraisals +26 -0
- data/CHANGELOG.md +26 -0
- data/CLAUDE.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +454 -0
- data/Rakefile +32 -0
- data/app/assets/stylesheets/sessions.css +50 -0
- data/app/controllers/sessions/application_controller.rb +159 -0
- data/app/controllers/sessions/devices_controller.rb +48 -0
- data/app/helpers/sessions/engine_helper.rb +126 -0
- data/app/views/sessions/_device.html.erb +40 -0
- data/app/views/sessions/_devices.html.erb +34 -0
- data/app/views/sessions/_event.html.erb +13 -0
- data/app/views/sessions/_history.html.erb +20 -0
- data/app/views/sessions/devices/history.html.erb +5 -0
- data/app/views/sessions/devices/index.html.erb +15 -0
- data/config/locales/en.yml +59 -0
- data/config/locales/es.yml +59 -0
- data/config/routes.rb +17 -0
- data/docs/PRD.md +743 -0
- data/docs/research/01-carhey.md +250 -0
- data/docs/research/02-ecosystem.md +261 -0
- data/docs/research/03-rails-core.md +220 -0
- data/docs/research/04-devise-warden.md +249 -0
- data/docs/research/05-oauth.md +193 -0
- data/docs/research/06-prior-art.md +312 -0
- data/docs/research/07-device-detection.md +250 -0
- data/docs/research/08-rails8-landscape.md +216 -0
- data/docs/research/09-market-security.md +450 -0
- data/gemfiles/rails_7.1.gemfile +34 -0
- data/gemfiles/rails_7.2.gemfile +34 -0
- data/gemfiles/rails_8.0.gemfile +34 -0
- data/gemfiles/rails_8.1.gemfile +34 -0
- data/lib/generators/sessions/install_generator.rb +230 -0
- data/lib/generators/sessions/madmin_generator.rb +95 -0
- data/lib/generators/sessions/templates/add_sessions_columns.rb.erb +81 -0
- data/lib/generators/sessions/templates/create_sessions.rb.erb +101 -0
- data/lib/generators/sessions/templates/create_sessions_events.rb.erb +108 -0
- data/lib/generators/sessions/templates/initializer.rb +201 -0
- data/lib/generators/sessions/templates/madmin/event_resource.rb +77 -0
- data/lib/generators/sessions/templates/madmin/session_events_controller.rb +21 -0
- data/lib/generators/sessions/templates/madmin/session_resource.rb +65 -0
- data/lib/generators/sessions/templates/madmin/sessions_controller.rb +30 -0
- data/lib/generators/sessions/templates/session.rb.erb +14 -0
- data/lib/generators/sessions/templates/sessions_sweep_job.rb +20 -0
- data/lib/generators/sessions/views_generator.rb +33 -0
- data/lib/sessions/adapters/omakase.rb +195 -0
- data/lib/sessions/adapters/omniauth.rb +64 -0
- data/lib/sessions/adapters/warden.rb +293 -0
- data/lib/sessions/classifier.rb +208 -0
- data/lib/sessions/configuration.rb +441 -0
- data/lib/sessions/current.rb +20 -0
- data/lib/sessions/device.rb +411 -0
- data/lib/sessions/engine.rb +120 -0
- data/lib/sessions/errors.rb +24 -0
- data/lib/sessions/geolocation.rb +111 -0
- data/lib/sessions/ip_address.rb +56 -0
- data/lib/sessions/jobs/geolocate_job.rb +58 -0
- data/lib/sessions/macros.rb +26 -0
- data/lib/sessions/middleware.rb +41 -0
- data/lib/sessions/models/concerns/device_display.rb +134 -0
- data/lib/sessions/models/concerns/has_sessions.rb +116 -0
- data/lib/sessions/models/concerns/model.rb +513 -0
- data/lib/sessions/models/event.rb +293 -0
- data/lib/sessions/version.rb +5 -0
- data/lib/sessions.rb +423 -0
- metadata +225 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sessions
|
|
4
|
+
# The append-only login-activity trail: every successful AND failed login,
|
|
5
|
+
# logout, revocation and expiry — with attempted identity, device, geo,
|
|
6
|
+
# and the linkage no prior art has: `session_id` points at the live
|
|
7
|
+
# registry row the event created (or ended), so a suspicious login in the
|
|
8
|
+
# trail is one click away from revoking the session it started.
|
|
9
|
+
#
|
|
10
|
+
# `session_id` is a plain column with NO foreign key on purpose: registry
|
|
11
|
+
# rows get destroyed on revoke/logout (rows = active sessions); history
|
|
12
|
+
# must survive them.
|
|
13
|
+
#
|
|
14
|
+
# Rows are written through one tolerant pipeline (`.record!`): unknown
|
|
15
|
+
# attributes are dropped instead of raising, so hosts can add or remove
|
|
16
|
+
# columns without waiting for a gem release (authtrail's proven pattern).
|
|
17
|
+
#
|
|
18
|
+
# Scopes are the admin product (BYOUI):
|
|
19
|
+
#
|
|
20
|
+
# Sessions::Event.failed_logins.last_24_hours.group(:ip_address).count
|
|
21
|
+
# Sessions::Event.for_identity("j@example.com") # ATO investigation
|
|
22
|
+
# Sessions::Event.failed_logins.for_ip("203.0.113.7")
|
|
23
|
+
# Sessions::Event.by_country("RU").logins
|
|
24
|
+
class Event < ::ActiveRecord::Base
|
|
25
|
+
self.table_name = "sessions_events"
|
|
26
|
+
|
|
27
|
+
# device_name / location / country_flag / native predicates /
|
|
28
|
+
# auth_method_label — shared with the registry rows; events carry the
|
|
29
|
+
# same parsed device columns (handy for admin lists and host hooks).
|
|
30
|
+
include Sessions::DeviceDisplay
|
|
31
|
+
|
|
32
|
+
EVENTS = %w[login failed_login logout revoked expired].freeze
|
|
33
|
+
|
|
34
|
+
# Nullable: failed attempts against unknown identities have no one to
|
|
35
|
+
# point at — that's exactly why the typed `identity` column exists.
|
|
36
|
+
belongs_to :authenticatable, polymorphic: true, optional: true
|
|
37
|
+
|
|
38
|
+
validates :event, presence: true, inclusion: { in: EVENTS }
|
|
39
|
+
|
|
40
|
+
# APPEND-ONLY at the model-contract level: normal AR mutations raise;
|
|
41
|
+
# history is evidence, and evidence you can casually rewrite is
|
|
42
|
+
# worthless. Every legitimate internal mutation already goes through
|
|
43
|
+
# callback-bypassing APIs — the geolocate job backfills geo via
|
|
44
|
+
# update_columns/update_all, `Sessions.forget` nulls identities via
|
|
45
|
+
# update_all (GDPR), the sweep and `dependent: :delete_all` purge via
|
|
46
|
+
# delete_all — so the callback paths can refuse everything else loudly.
|
|
47
|
+
# (Those bypass APIs remain public Active Record; this is a guardrail,
|
|
48
|
+
# not a database constraint.)
|
|
49
|
+
before_update { raise ActiveRecord::ReadOnlyRecord, "sessions_events are append-only history" }
|
|
50
|
+
before_destroy { raise ActiveRecord::ReadOnlyRecord, "sessions_events are append-only history (retention purges go through delete_all)" }
|
|
51
|
+
|
|
52
|
+
scope :logins, -> { where(event: "login") }
|
|
53
|
+
scope :failed_logins, -> { where(event: "failed_login") }
|
|
54
|
+
scope :logouts, -> { where(event: "logout") }
|
|
55
|
+
scope :revocations, -> { where(event: "revoked") }
|
|
56
|
+
scope :expirations, -> { where(event: "expired") }
|
|
57
|
+
|
|
58
|
+
scope :recent, -> { order(occurred_at: :desc) }
|
|
59
|
+
scope :last_24_hours, -> { where(occurred_at: 24.hours.ago..) }
|
|
60
|
+
scope :last_days, ->(days) { where(occurred_at: days.days.ago..) }
|
|
61
|
+
scope :between, ->(from, to) { where(occurred_at: from..to) }
|
|
62
|
+
|
|
63
|
+
scope :for_ip, ->(ip) { where(ip_address: ip.to_s) }
|
|
64
|
+
scope :for_identity, ->(identity) { where(identity: normalize_identity(identity)) }
|
|
65
|
+
scope :by_country, ->(code) { where(country_code: code.to_s.upcase) }
|
|
66
|
+
scope :with_method, ->(method) { where(auth_method: method.to_s) }
|
|
67
|
+
scope :new_devices, lambda {
|
|
68
|
+
# Portable substring match on a json/jsonb column: each adapter casts
|
|
69
|
+
# differently (PG can't LIKE jsonb directly; CHAR(1) truncates there;
|
|
70
|
+
# SQLite/MySQL accept their own casts). /postg/ also covers PostGIS.
|
|
71
|
+
column = case connection.adapter_name
|
|
72
|
+
when /postg/i then "metadata::text"
|
|
73
|
+
when /mysql/i then "CAST(metadata AS CHAR)"
|
|
74
|
+
else "CAST(metadata AS TEXT)"
|
|
75
|
+
end
|
|
76
|
+
logins.where("#{column} LIKE ?", "%new_device%")
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
before_validation { self.occurred_at ||= Time.current }
|
|
80
|
+
|
|
81
|
+
class << self
|
|
82
|
+
# The single, error-isolated write path. Tolerant-assigns every
|
|
83
|
+
# attribute (unknown columns are skipped via `try`), normalizes the
|
|
84
|
+
# typed identity for correlation, stamps occurred_at, persists, and
|
|
85
|
+
# tees the event into `config.events`. Returns the Event or nil —
|
|
86
|
+
# never raises into a login.
|
|
87
|
+
def record!(attributes)
|
|
88
|
+
Sessions.safely("event") do
|
|
89
|
+
event = new
|
|
90
|
+
attributes.each do |name, value|
|
|
91
|
+
next if value.nil?
|
|
92
|
+
|
|
93
|
+
event.try(:"#{name}=", value)
|
|
94
|
+
end
|
|
95
|
+
event.identity = normalize_identity(event.try(:identity))
|
|
96
|
+
clamp_string_columns!(event)
|
|
97
|
+
event.save!
|
|
98
|
+
|
|
99
|
+
Sessions.notify_event(event)
|
|
100
|
+
event
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Clamp string columns to their limits BEFORE the insert: the
|
|
105
|
+
# identity is attacker-typed (a 10KB "email" must not turn into
|
|
106
|
+
# MySQL's ValueTooLong and silently cost us the failure row — that
|
|
107
|
+
# row IS the attack trail), and hosts may have pruned the text
|
|
108
|
+
# columns down to strings.
|
|
109
|
+
def clamp_string_columns!(event)
|
|
110
|
+
columns_hash.each do |name, column|
|
|
111
|
+
next unless column.type == :string && column.limit
|
|
112
|
+
next unless (value = event[name]).is_a?(String) && value.length > column.limit
|
|
113
|
+
|
|
114
|
+
event[name] = value[0, column.limit]
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Build a `failed_login` event straight from a request — the shared
|
|
119
|
+
# engine behind Warden's before_failure, the OmniAuth failure
|
|
120
|
+
# composer, the omakase controller hook, and the public
|
|
121
|
+
# Sessions.record_failed_attempt seam.
|
|
122
|
+
def record_failure(request, scope: nil, identity: nil, reason: nil, metadata: {})
|
|
123
|
+
headers = Sessions::Device.headers_from(request)
|
|
124
|
+
user_agent = request&.user_agent
|
|
125
|
+
device = Sessions::Device.parse(user_agent, headers: headers)
|
|
126
|
+
auth = Sessions::Classifier.classify(request)
|
|
127
|
+
ip = Sessions::IpAddress.resolve(request)
|
|
128
|
+
|
|
129
|
+
geo = {}
|
|
130
|
+
if ip && Sessions::Geolocation.cloudflare_headers?(request)
|
|
131
|
+
geo = Sessions::Geolocation.locate(ip, request: request, coordinates: true)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
event = record!(
|
|
135
|
+
device.to_h.merge(geo).merge(
|
|
136
|
+
event: "failed_login",
|
|
137
|
+
scope: scope&.to_s,
|
|
138
|
+
identity: identity,
|
|
139
|
+
failure_reason: reason&.to_s,
|
|
140
|
+
auth_method: auth[:method],
|
|
141
|
+
auth_provider: auth[:provider],
|
|
142
|
+
auth_detail: auth[:detail].presence,
|
|
143
|
+
ip_address: ip,
|
|
144
|
+
user_agent: user_agent,
|
|
145
|
+
client_hints: headers.presence,
|
|
146
|
+
request_id: (request.request_id if request.respond_to?(:request_id)),
|
|
147
|
+
context: context_for(request),
|
|
148
|
+
metadata: metadata.presence
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
Sessions::Geolocation.enqueue(event) if event && event.try(:country_code).blank?
|
|
153
|
+
maybe_alert_repeated_failures(event) if event
|
|
154
|
+
event
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Burst detection (config.repeated_failed_logins): fires the hook
|
|
158
|
+
# exactly when the identity CROSSES the threshold inside the window —
|
|
159
|
+
# count == threshold, so the 6th, 7th… attempt doesn't re-fire and an
|
|
160
|
+
# attacker can't turn the alert into an inbox-flooding primitive.
|
|
161
|
+
# (Two simultaneous commits can race past the crossing — the alert is
|
|
162
|
+
# then skipped rather than doubled; for a notification, missing one
|
|
163
|
+
# beats spamming two.)
|
|
164
|
+
def maybe_alert_repeated_failures(event)
|
|
165
|
+
config = Sessions.config.repeated_failed_logins
|
|
166
|
+
return unless config
|
|
167
|
+
return if event.identity.blank?
|
|
168
|
+
|
|
169
|
+
count = failed_logins
|
|
170
|
+
.for_identity(event.identity)
|
|
171
|
+
.where(occurred_at: config[:within].ago..)
|
|
172
|
+
.count
|
|
173
|
+
return unless count == config[:threshold]
|
|
174
|
+
|
|
175
|
+
Sessions.safely("on_repeated_failed_logins hook") do
|
|
176
|
+
Sessions.config.on_repeated_failed_logins.call(
|
|
177
|
+
identity: event.identity,
|
|
178
|
+
count: count,
|
|
179
|
+
event: event
|
|
180
|
+
)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Emails-as-typed are normalized (strip + downcase) so failed attempts
|
|
185
|
+
# correlate across casing — but stored even for identities that match
|
|
186
|
+
# no account (the data authtrail proved valuable and Rodauth can't
|
|
187
|
+
# capture).
|
|
188
|
+
def normalize_identity(identity)
|
|
189
|
+
return nil if identity.nil?
|
|
190
|
+
|
|
191
|
+
normalized = identity.to_s.strip.downcase
|
|
192
|
+
normalized.empty? ? nil : normalized
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def context_for(request)
|
|
196
|
+
params = request.respond_to?(:path_parameters) ? request.path_parameters : nil
|
|
197
|
+
return nil unless params && params[:controller]
|
|
198
|
+
|
|
199
|
+
"#{params[:controller]}##{params[:action]}"
|
|
200
|
+
rescue StandardError
|
|
201
|
+
nil
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# --- Candy ------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
# `event.name` reads better than `event.event` in host hooks:
|
|
208
|
+
# config.events = ->(event) { AuditLog.log(event_type: "session.#{event.name}", …) }
|
|
209
|
+
def name
|
|
210
|
+
event&.to_sym
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def user
|
|
214
|
+
authenticatable
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# The live registry row this event points at — nil once it's been
|
|
218
|
+
# revoked/logged out (that's the point of the trail).
|
|
219
|
+
def session
|
|
220
|
+
return nil if session_id.nil?
|
|
221
|
+
|
|
222
|
+
Sessions.session_model.find_by(id: session_id)
|
|
223
|
+
rescue StandardError
|
|
224
|
+
nil
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# The request being served when the event was recorded (only available
|
|
228
|
+
# in the same request cycle — handy inside `config.events` hooks).
|
|
229
|
+
def request
|
|
230
|
+
Sessions::Current.request
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def success?
|
|
234
|
+
event == "login"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def failure?
|
|
238
|
+
event == "failed_login"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def new_device?
|
|
242
|
+
!!(metadata.is_a?(Hash) && metadata["new_device"])
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# The reason that applies to THIS event: the failure reason on failed
|
|
246
|
+
# logins, the revocation reason on revocations — so views and hooks
|
|
247
|
+
# never branch on the event type to find it.
|
|
248
|
+
def reason
|
|
249
|
+
failure_reason.presence || revoked_reason.presence
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Human, localized labels (the gem ships en + es; hosts override the
|
|
253
|
+
# i18n keys like any Rails app):
|
|
254
|
+
# event.label # => "Signed in" / "Inicio de sesión"
|
|
255
|
+
# event.reason_label # => "wrong credentials" / "credenciales incorrectas"
|
|
256
|
+
def label
|
|
257
|
+
I18n.t("sessions.history.events.#{event}", default: event.to_s.humanize)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def reason_label
|
|
261
|
+
return nil unless reason
|
|
262
|
+
|
|
263
|
+
I18n.t("sessions.history.reasons.#{reason}", default: reason.humanize.downcase)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# The audit-friendly compact projection — exactly what a
|
|
267
|
+
# `config.events` tee wants to forward to an audit ledger or analytics
|
|
268
|
+
# pipe, without hand-picking columns:
|
|
269
|
+
#
|
|
270
|
+
# config.events = ->(event) do
|
|
271
|
+
# AuditLog.log(event_type: "session.#{event.name}", user: event.user,
|
|
272
|
+
# request: event.request, data: event.summary)
|
|
273
|
+
# end
|
|
274
|
+
def summary
|
|
275
|
+
{
|
|
276
|
+
session_id: session_id,
|
|
277
|
+
identity: identity,
|
|
278
|
+
device: (device_name if try(:device_type).present? && device_type != "unknown"),
|
|
279
|
+
device_type: device_type,
|
|
280
|
+
auth_method: auth_method,
|
|
281
|
+
auth_provider: auth_provider,
|
|
282
|
+
failure_reason: failure_reason,
|
|
283
|
+
revoked_reason: revoked_reason,
|
|
284
|
+
ip: ip_address,
|
|
285
|
+
country: country_code
|
|
286
|
+
}.compact
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def to_h
|
|
290
|
+
attributes.symbolize_keys
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|