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.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +61 -0
  3. data/.simplecov +54 -0
  4. data/AGENTS.md +5 -0
  5. data/Appraisals +26 -0
  6. data/CHANGELOG.md +26 -0
  7. data/CLAUDE.md +5 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +454 -0
  10. data/Rakefile +32 -0
  11. data/app/assets/stylesheets/sessions.css +50 -0
  12. data/app/controllers/sessions/application_controller.rb +159 -0
  13. data/app/controllers/sessions/devices_controller.rb +48 -0
  14. data/app/helpers/sessions/engine_helper.rb +126 -0
  15. data/app/views/sessions/_device.html.erb +40 -0
  16. data/app/views/sessions/_devices.html.erb +34 -0
  17. data/app/views/sessions/_event.html.erb +13 -0
  18. data/app/views/sessions/_history.html.erb +20 -0
  19. data/app/views/sessions/devices/history.html.erb +5 -0
  20. data/app/views/sessions/devices/index.html.erb +15 -0
  21. data/config/locales/en.yml +59 -0
  22. data/config/locales/es.yml +59 -0
  23. data/config/routes.rb +17 -0
  24. data/docs/PRD.md +743 -0
  25. data/docs/research/01-carhey.md +250 -0
  26. data/docs/research/02-ecosystem.md +261 -0
  27. data/docs/research/03-rails-core.md +220 -0
  28. data/docs/research/04-devise-warden.md +249 -0
  29. data/docs/research/05-oauth.md +193 -0
  30. data/docs/research/06-prior-art.md +312 -0
  31. data/docs/research/07-device-detection.md +250 -0
  32. data/docs/research/08-rails8-landscape.md +216 -0
  33. data/docs/research/09-market-security.md +450 -0
  34. data/gemfiles/rails_7.1.gemfile +34 -0
  35. data/gemfiles/rails_7.2.gemfile +34 -0
  36. data/gemfiles/rails_8.0.gemfile +34 -0
  37. data/gemfiles/rails_8.1.gemfile +34 -0
  38. data/lib/generators/sessions/install_generator.rb +230 -0
  39. data/lib/generators/sessions/madmin_generator.rb +95 -0
  40. data/lib/generators/sessions/templates/add_sessions_columns.rb.erb +81 -0
  41. data/lib/generators/sessions/templates/create_sessions.rb.erb +101 -0
  42. data/lib/generators/sessions/templates/create_sessions_events.rb.erb +108 -0
  43. data/lib/generators/sessions/templates/initializer.rb +201 -0
  44. data/lib/generators/sessions/templates/madmin/event_resource.rb +77 -0
  45. data/lib/generators/sessions/templates/madmin/session_events_controller.rb +21 -0
  46. data/lib/generators/sessions/templates/madmin/session_resource.rb +65 -0
  47. data/lib/generators/sessions/templates/madmin/sessions_controller.rb +30 -0
  48. data/lib/generators/sessions/templates/session.rb.erb +14 -0
  49. data/lib/generators/sessions/templates/sessions_sweep_job.rb +20 -0
  50. data/lib/generators/sessions/views_generator.rb +33 -0
  51. data/lib/sessions/adapters/omakase.rb +195 -0
  52. data/lib/sessions/adapters/omniauth.rb +64 -0
  53. data/lib/sessions/adapters/warden.rb +293 -0
  54. data/lib/sessions/classifier.rb +208 -0
  55. data/lib/sessions/configuration.rb +441 -0
  56. data/lib/sessions/current.rb +20 -0
  57. data/lib/sessions/device.rb +411 -0
  58. data/lib/sessions/engine.rb +120 -0
  59. data/lib/sessions/errors.rb +24 -0
  60. data/lib/sessions/geolocation.rb +111 -0
  61. data/lib/sessions/ip_address.rb +56 -0
  62. data/lib/sessions/jobs/geolocate_job.rb +58 -0
  63. data/lib/sessions/macros.rb +26 -0
  64. data/lib/sessions/middleware.rb +41 -0
  65. data/lib/sessions/models/concerns/device_display.rb +134 -0
  66. data/lib/sessions/models/concerns/has_sessions.rb +116 -0
  67. data/lib/sessions/models/concerns/model.rb +513 -0
  68. data/lib/sessions/models/event.rb +293 -0
  69. data/lib/sessions/version.rb +5 -0
  70. data/lib/sessions.rb +423 -0
  71. 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sessions
4
+ VERSION = "0.1.0"
5
+ end