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,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "rails/generators/active_record"
5
+
6
+ module Sessions
7
+ module Generators
8
+ # `rails generate sessions:install` — detects the app's auth stack and
9
+ # writes the right pieces:
10
+ #
11
+ # Rails 8 omakase auth → ONE migration extending the existing
12
+ # `sessions` table (the Devise-extends-`users` precedent) + the
13
+ # events table. The generated Session model stays untouched.
14
+ #
15
+ # Devise → the Rails-8-shaped `sessions` table (plus our columns,
16
+ # with token_digest populated by the Warden adapter) + the events
17
+ # table + a 3-line app-owned Session shell model. The app converges
18
+ # on the omakase shape: a future Devise→Rails-auth migration finds
19
+ # its table already waiting.
20
+ #
21
+ # Neither → aborts with guidance. The gem decorates a session of
22
+ # record; it never creates one.
23
+ #
24
+ # Plus, in every mode: the annotated initializer, the SessionsSweepJob
25
+ # (host-scheduled — the trackdown/nondisposable pattern), and the
26
+ # post-install steps.
27
+ class InstallGenerator < Rails::Generators::Base
28
+ include ActiveRecord::Generators::Migration
29
+
30
+ source_root File.expand_path("templates", __dir__)
31
+ desc "Install sessions: adaptive migrations, initializer, and sweep job"
32
+
33
+ class_option :polymorphic, type: :boolean, default: false,
34
+ desc: "Track multiple Devise scopes/models (polymorphic session owner)"
35
+ class_option :model, type: :string, default: "Session",
36
+ desc: "Session model name (escape hatch for apps with a conflicting Session class)"
37
+
38
+ def self.next_migration_number(dir)
39
+ ActiveRecord::Generators::Base.next_migration_number(dir)
40
+ end
41
+
42
+ def detect_auth_stack!
43
+ # Detection is MEMOIZED here, before anything is generated:
44
+ # create_session_model writes app/models/session.rb a few steps
45
+ # below, which would otherwise flip omakase_detected? mid-run and
46
+ # make the post-install message claim the wrong stack.
47
+ adopt_existing_table?
48
+ detected_stack
49
+
50
+ return if omakase_detected? || devise_detected?
51
+
52
+ raise Thor::Error, <<~MSG
53
+ ❌ sessions couldn't detect an authentication system to decorate.
54
+
55
+ The gem tracks the session of record your app already has — it never
56
+ creates one. Set one up first:
57
+
58
+ • Rails 8+ omakase auth: bin/rails generate authentication
59
+ • or Devise: https://github.com/heartcombo/devise
60
+
61
+ …then run `rails generate sessions:install` again.
62
+ MSG
63
+ end
64
+
65
+ def check_for_conflicting_sessions_table!
66
+ return unless conflicting_sessions_table?
67
+
68
+ raise Thor::Error, <<~MSG
69
+ ❌ A `#{table_name}` table exists but doesn't look like the Rails 8 auth
70
+ shape (no user reference + ip_address + user_agent columns) — most
71
+ likely a legacy table (activerecord-session_store?).
72
+
73
+ Two ways forward:
74
+
75
+ 1. Re-run with a different model: rails g sessions:install --model=SessionRecord
76
+ (and set `config.session_class = "SessionRecord"` in the initializer)
77
+ 2. Migrate/rename the legacy table first, then re-run.
78
+ MSG
79
+ end
80
+
81
+ def create_migration_files
82
+ if adopt_existing_table?
83
+ migration_template "add_sessions_columns.rb.erb",
84
+ File.join(db_migrate_path, "add_sessions_columns_to_#{table_name}.rb")
85
+ else
86
+ migration_template "create_sessions.rb.erb",
87
+ File.join(db_migrate_path, "create_#{table_name}.rb")
88
+ end
89
+
90
+ migration_template "create_sessions_events.rb.erb",
91
+ File.join(db_migrate_path, "create_sessions_events.rb")
92
+ end
93
+
94
+ # Devise mode only: the app-owned 3-line shell. All gem logic lives in
95
+ # the Sessions::Model concern, so this file never goes stale.
96
+ def create_session_model
97
+ return if adopt_existing_table? || session_model_file?
98
+
99
+ template "session.rb.erb", "app/models/#{model_name.underscore}.rb"
100
+ end
101
+
102
+ def create_initializer
103
+ template "initializer.rb", "config/initializers/sessions.rb"
104
+ end
105
+
106
+ def create_sweep_job
107
+ template "sessions_sweep_job.rb", "app/jobs/sessions_sweep_job.rb"
108
+ end
109
+
110
+ def display_post_install_message
111
+ say "\n🔐 The `sessions` gem has been installed#{" (#{detected_stack} detected)" if detected_stack}.",
112
+ :green
113
+ say "\nTo complete the setup:"
114
+
115
+ migrate_verb = adopt_existing_table? ? "enrich your sessions table" : "create the sessions tables"
116
+ say " 1. Run 'rails db:migrate' to #{migrate_verb}."
117
+ say " ⚠️ You must run migrations before starting your app!", :yellow
118
+
119
+ say " 2. Add the macro to your auth model:"
120
+ say " class User < ApplicationRecord"
121
+ say " has_sessions"
122
+ say " end"
123
+
124
+ say " 3. Mount the \"Your devices\" page wherever you want it to live:"
125
+ say " # config/routes.rb"
126
+ if devise_detected? && !omakase_detected?
127
+ say " authenticate :user do"
128
+ say " mount Sessions::Engine => \"/settings/sessions\""
129
+ say " end"
130
+ else
131
+ say " mount Sessions::Engine => \"/settings/sessions\""
132
+ end
133
+
134
+ say " 4. Schedule the sweep (retention purge + cap + opt-in expiry):"
135
+ say " # config/recurring.yml (Solid Queue)"
136
+ say " production:"
137
+ say " sessions_sweep:"
138
+ say " class: SessionsSweepJob"
139
+ say " schedule: every day at 4am"
140
+
141
+ say "\nEvery login now lands on the devices page and in the trail:"
142
+ say " current_user.sessions.active # live devices, revocable"
143
+ say " current_user.session_history # the trail — logins, failures, revocations"
144
+ say "\nEvery session, every device, every login — tracked. 🔐✨\n", :green
145
+ end
146
+
147
+ private
148
+
149
+ def migration_version
150
+ "[#{ActiveRecord::VERSION::STRING.to_f}]"
151
+ end
152
+
153
+ def model_name
154
+ options[:model].presence || "Session"
155
+ end
156
+
157
+ def table_name
158
+ model_name.underscore.pluralize.tr("/", "_")
159
+ end
160
+
161
+ def polymorphic?
162
+ options[:polymorphic]
163
+ end
164
+
165
+ # --- Detection (each predicate is a test seam) ---------------------------
166
+
167
+ def detected_stack
168
+ @detected_stack ||= if adopt_existing_table? then "Rails authentication"
169
+ elsif devise_detected? then "Devise"
170
+ end
171
+ end
172
+
173
+ # The Rails-8-shaped table, or the generated Authentication concern's
174
+ # methods on ApplicationController (the same duck test the runtime
175
+ # adapter uses — generators run with the app booted). Deliberately NOT
176
+ # "a session.rb model file exists": in Devise mode this generator
177
+ # writes that file itself, and a leftover copy must not flip a re-run
178
+ # into adopt mode against a table that isn't there.
179
+ def omakase_detected?
180
+ rails8_shaped_table? || omakase_controller_shape?
181
+ end
182
+
183
+ def omakase_controller_shape?
184
+ defined?(::ApplicationController) &&
185
+ ::ApplicationController.private_method_defined?(:start_new_session_for)
186
+ rescue StandardError
187
+ false
188
+ end
189
+
190
+ def devise_detected?
191
+ defined?(::Devise) ? true : false
192
+ end
193
+
194
+ def adopt_existing_table?
195
+ return @adopt_existing_table if defined?(@adopt_existing_table)
196
+
197
+ # Adoption means "enrich the table that's already there", so it
198
+ # requires that table. An omakase-shaped app installing with
199
+ # `--model SessionRecord` (because a legacy Session class is in the
200
+ # way) has NO session_records table yet — that's the create-table
201
+ # path, not an add-columns migration against nothing.
202
+ @adopt_existing_table = omakase_detected? && sessions_table_exists?
203
+ end
204
+
205
+ def session_model_file?
206
+ File.exist?(File.expand_path("app/models/#{model_name.underscore}.rb", destination_root)) ||
207
+ (defined?(Rails.root) && Rails.root && File.exist?(Rails.root.join("app/models/#{model_name.underscore}.rb")))
208
+ end
209
+
210
+ def sessions_table_exists?
211
+ ActiveRecord::Base.connection.table_exists?(table_name)
212
+ rescue StandardError
213
+ false
214
+ end
215
+
216
+ def rails8_shaped_table?
217
+ return false unless sessions_table_exists?
218
+
219
+ columns = ActiveRecord::Base.connection.columns(table_name).map(&:name)
220
+ (%w[ip_address user_agent] - columns).empty? && columns.any? { |name| name.end_with?("user_id") }
221
+ rescue StandardError
222
+ false
223
+ end
224
+
225
+ def conflicting_sessions_table?
226
+ sessions_table_exists? && !rails8_shaped_table?
227
+ end
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module Sessions
6
+ module Generators
7
+ # `rails generate sessions:madmin` — drop-in admin surfaces for
8
+ # https://github.com/excid3/madmin: the live session registry (with a
9
+ # per-row Revoke action) and the login trail with its triage scopes.
10
+ #
11
+ # The generated files use only STOCK Madmin APIs, so they work on any
12
+ # Madmin install and are yours to restyle (swap in your custom fields,
13
+ # add member actions). Two Madmin footguns are pre-solved in the
14
+ # generated code: the trail's namespaced model needs an explicit
15
+ # `resource_class_name` on its flat controller, and the events routes
16
+ # must be drawn BEFORE `resources :sessions` (or /sessions/events gets
17
+ # captured as a session id).
18
+ class MadminGenerator < Rails::Generators::Base
19
+ source_root File.expand_path("templates/madmin", __dir__)
20
+
21
+ desc "Generate Madmin resources + controllers for sessions and the login trail"
22
+
23
+ def check_for_madmin!
24
+ return if madmin_available?
25
+
26
+ raise Thor::Error, <<~MSG
27
+ ❌ Madmin isn't loaded in this app (gem "madmin").
28
+
29
+ This generator produces Madmin resources for the session registry and
30
+ the login trail. For other admin frameworks, build on the same
31
+ primitives it uses: Session.active / session.revoke! /
32
+ Sessions::Event scopes (failed_logins, last_24_hours, …).
33
+ MSG
34
+ end
35
+
36
+ def create_resources
37
+ template "session_resource.rb", "app/madmin/resources/session_resource.rb"
38
+ template "event_resource.rb", "app/madmin/resources/sessions/event_resource.rb"
39
+ end
40
+
41
+ def create_controllers
42
+ template "sessions_controller.rb", "app/controllers/madmin/sessions_controller.rb"
43
+ template "session_events_controller.rb", "app/controllers/madmin/session_events_controller.rb"
44
+ end
45
+
46
+ def display_post_install_message
47
+ say "\n🔐 Madmin resources for sessions installed.", :green
48
+ say "\nTo complete the setup:"
49
+
50
+ say " 1. Add the routes where you draw your Madmin routes"
51
+ say " (config/routes/madmin.rb in most apps):"
52
+ say ""
53
+ say " # Login trail — drawn BEFORE `resources :sessions`, or"
54
+ say " # /sessions/events would match as a session id."
55
+ say " namespace :sessions do"
56
+ say " resources :events, only: [ :index, :show ], controller: \"/madmin/session_events\""
57
+ say " end"
58
+ say ""
59
+ say " resources :sessions, only: [ :index, :show ] do"
60
+ say " member do"
61
+ say " post :revoke"
62
+ say " end"
63
+ say " end"
64
+
65
+ say "\n 2. (Optional) Group them in the sidebar — both resources declare"
66
+ say " `parent: \"Security\"`; pre-seed its position in an initializer:"
67
+ say ""
68
+ say " Madmin.menu.before_render do"
69
+ say " add label: \"Security\", position: 91"
70
+ say " end"
71
+
72
+ say "\n 3. (Optional) For a per-user panel (devices + trail on the user's"
73
+ say " show page), add a member action to your users controller that"
74
+ say " loads `user.sessions.by_recency` and `user.session_history.recent`"
75
+ say " — the README's Admin section has the full recipe."
76
+
77
+ say "\nRevoking from the index destroys the row: that device is signed out"
78
+ say "on its very next request, and the revocation lands in the trail with"
79
+ say "admin attribution. 🔐\n", :green
80
+ end
81
+
82
+ private
83
+
84
+ def madmin_available?
85
+ defined?(::Madmin) ? true : false
86
+ end
87
+
88
+ def session_class
89
+ Sessions.config.session_class
90
+ rescue StandardError
91
+ "Session"
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Enriches the `<%= table_name %>` table that `rails generate authentication`
4
+ # created (precedent: Devise's add_devise_to_users extends your users table).
5
+ # Your existing columns and rows are untouched: ip_address/user_agent keep
6
+ # holding their login-time values; everything below is filled by the sessions
7
+ # gem from the next sign-in on.
8
+ class AddSessionsColumnsTo<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_version %>
9
+ def change
10
+ change_table :<%= table_name %>, bulk: true do |t|
11
+ # Devise/Warden mode only: SHA-256 of a random token whose raw value
12
+ # lives ONLY in the user's own session (OWASP: never persist raw
13
+ # session identifiers). Stays NULL on Rails-8-auth rows — there the
14
+ # signed cookie is the credential and nothing secret needs storing.
15
+ t.string :token_digest
16
+
17
+ # The Warden scope ("user") — multi-scope Devise apps.
18
+ t.string :scope
19
+
20
+ # How this session started: password / oauth / google_one_tap /
21
+ # passkey / magic_link / otp / sso / token / unknown — plus the
22
+ # provider ("google", "apple", "github") and per-method extras
23
+ # (One Tap's select_by, passkey UV/BS flags, OAuth scopes…).
24
+ t.string :auth_method
25
+ t.string :auth_provider
26
+ t.send(json_column_type, :auth_detail)
27
+
28
+ # Parsed device intelligence — projections of the raw user_agent
29
+ # ("Chrome 137 on macOS", "MyApp 2.4.1 on Pixel 8 (Android 16)").
30
+ t.string :browser_name
31
+ t.string :browser_version
32
+ t.string :os_name
33
+ t.string :os_version
34
+ t.string :device_type # desktop / smartphone / tablet / native_ios / native_android / bot / unknown
35
+ t.string :device_model # "iPhone15,2", "Pixel 8" — when knowable
36
+ t.string :app_name # Hotwire Native apps
37
+ t.string :app_version
38
+ t.string :app_build
39
+ t.send(json_column_type, :client_hints) # raw Sec-CH-UA* + X-Client-* headers, for re-parsing
40
+
41
+ # Approximate location via the trackdown gem (soft dependency) —
42
+ # stays NULL without it and the UI omits location cleanly.
43
+ t.string :country_code, limit: 2
44
+ t.string :country_name
45
+ t.string :city
46
+ t.string :region
47
+
48
+ # Browser continuity: a random id minted at login into a signed,
49
+ # long-lived cookie identifying the BROWSER INSTALL (never the user;
50
+ # worthless as a credential). Lets a repeat login from the same
51
+ # browser supersede its old row instead of stacking duplicate
52
+ # devices — robust to browser updates, since identity is the cookie,
53
+ # not the user agent.
54
+ t.string :device_id, limit: 36
55
+
56
+ # The throttled activity touch (at most one write per
57
+ # config.touch_every) — the column the Rails security guide's own
58
+ # Session.sweep recommendation always needed.
59
+ t.datetime :last_seen_at
60
+ t.string :last_seen_ip, limit: 45 # refreshed with the touch (roaming devices)
61
+ end
62
+
63
+ add_index :<%= table_name %>, :device_id
64
+ add_index :<%= table_name %>, :token_digest, unique: true
65
+ add_index :<%= table_name %>, :auth_method
66
+ add_index :<%= table_name %>, :auth_provider
67
+ add_index :<%= table_name %>, :country_code
68
+ add_index :<%= table_name %>, :last_seen_at
69
+ end
70
+
71
+ private
72
+
73
+ # :jsonb on PostgreSQL, :json elsewhere — same adaptive pattern as the
74
+ # rest of the gem ecosystem (chats, api_keys, …). match? (not equality):
75
+ # PostGIS apps report adapter_name "PostGIS" and are PostgreSQL too.
76
+ def json_column_type
77
+ return :jsonb if connection.adapter_name.match?(/postg/i)
78
+
79
+ :json
80
+ end
81
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The Rails-8-shaped sessions table for a Devise app: one row = one
4
+ # signed-in device, destroyed on logout/revocation (rows = active sessions;
5
+ # history lives in sessions_events). Deliberately the SAME base shape
6
+ # `rails generate authentication` creates — so if you ever migrate from
7
+ # Devise to Rails auth, your sessions table is already waiting.
8
+ class Create<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_version %>
9
+ def change
10
+ primary_key_type, foreign_key_type = primary_and_foreign_key_types
11
+
12
+ create_table :<%= table_name %>, id: primary_key_type do |t|
13
+ # The Rails 8 authentication-generator base…
14
+ <% if polymorphic? -%>
15
+ # …with a polymorphic owner (--polymorphic): every Devise scope/model
16
+ # gets tracked, each row remembering whose it is.
17
+ t.references :user, polymorphic: true, null: false, type: foreign_key_type, index: true
18
+ <% else -%>
19
+ t.references :user, null: false, foreign_key: true, type: foreign_key_type
20
+ <% end -%>
21
+ t.string :ip_address, limit: 45
22
+ t.text :user_agent # text, not string: native UAs overflow MySQL's varchar(255)
23
+
24
+ # Devise/Warden mode: SHA-256 of a random token whose raw value lives
25
+ # ONLY in the user's own session (OWASP: never persist raw session
26
+ # identifiers). The Warden adapter validates it on every request —
27
+ # destroy the row and that device is signed out on its next request.
28
+ t.string :token_digest
29
+
30
+ # The Warden scope ("user") — multi-scope Devise apps.
31
+ t.string :scope
32
+
33
+ # How this session started: password / oauth / google_one_tap /
34
+ # passkey / magic_link / otp / sso / token / unknown — plus the
35
+ # provider and per-method extras.
36
+ t.string :auth_method
37
+ t.string :auth_provider
38
+ t.send(json_column_type, :auth_detail)
39
+
40
+ # Parsed device intelligence — projections of the raw user_agent
41
+ # ("Chrome 137 on macOS", "MyApp 2.4.1 on Pixel 8 (Android 16)").
42
+ t.string :browser_name
43
+ t.string :browser_version
44
+ t.string :os_name
45
+ t.string :os_version
46
+ t.string :device_type # desktop / smartphone / tablet / native_ios / native_android / bot / unknown
47
+ t.string :device_model # "iPhone15,2", "Pixel 8" — when knowable
48
+ t.string :app_name # Hotwire Native apps
49
+ t.string :app_version
50
+ t.string :app_build
51
+ t.send(json_column_type, :client_hints) # raw Sec-CH-UA* + X-Client-* headers, for re-parsing
52
+
53
+ # Approximate location via the trackdown gem (soft dependency).
54
+ t.string :country_code, limit: 2
55
+ t.string :country_name
56
+ t.string :city
57
+ t.string :region
58
+
59
+ # Browser continuity: a random id minted at login into a signed,
60
+ # long-lived cookie identifying the BROWSER INSTALL (never the user).
61
+ # Lets a repeat login from the same browser supersede its old row
62
+ # instead of stacking duplicate devices.
63
+ t.string :device_id, limit: 36
64
+
65
+ # The throttled activity touch (at most one write per config.touch_every).
66
+ t.datetime :last_seen_at
67
+ t.string :last_seen_ip, limit: 45
68
+
69
+ t.timestamps
70
+ end
71
+
72
+ add_index :<%= table_name %>, :device_id
73
+ add_index :<%= table_name %>, :token_digest, unique: true
74
+ add_index :<%= table_name %>, :auth_method
75
+ add_index :<%= table_name %>, :auth_provider
76
+ add_index :<%= table_name %>, :country_code
77
+ add_index :<%= table_name %>, :last_seen_at
78
+ end
79
+
80
+ private
81
+
82
+ # Honor the host's configured primary key type (uuid vs bigint). Reads the
83
+ # same setting `rails g model` uses, so an app generated with
84
+ # `config.generators { |g| g.orm :active_record, primary_key_type: :uuid }`
85
+ # gets uuid sessions tables and uuid foreign keys, automatically.
86
+ def primary_and_foreign_key_types
87
+ config = Rails.configuration.generators
88
+ setting = config.options[config.orm][:primary_key_type]
89
+ primary_key_type = setting || :primary_key
90
+ foreign_key_type = setting || :bigint
91
+ [ primary_key_type, foreign_key_type ]
92
+ end
93
+
94
+ # match? (not equality): PostGIS apps report adapter_name "PostGIS" and
95
+ # are PostgreSQL too.
96
+ def json_column_type
97
+ return :jsonb if connection.adapter_name.match?(/postg/i)
98
+
99
+ :json
100
+ end
101
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The append-only login-activity trail: every successful AND failed login,
4
+ # logout, revocation, and expiry — with attempted identity, device, geo, and
5
+ # the trail ↔ registry linkage (`session_id`). Trail rows survive their
6
+ # session row (that's the point); the SessionsSweepJob purges them past
7
+ # config.events_retention (12 months by default — CNIL's recommendation for
8
+ # security logs).
9
+ class CreateSessionsEvents < ActiveRecord::Migration<%= migration_version %>
10
+ def change
11
+ primary_key_type, foreign_key_type = primary_and_foreign_key_types
12
+
13
+ # The trail mirrors the host's primary-key choice. This matters beyond
14
+ # consistency: uuid apps put events into uuid polymorphic associations
15
+ # (a Noticed notification's record, an audit ledger's subject…) and a
16
+ # bigint id assigned to a uuid column type-casts to NULL — silently.
17
+ create_table :sessions_events, id: primary_key_type do |t|
18
+ t.string :event, null: false # login / failed_login / logout / revoked / expired
19
+
20
+ # Polymorphic and NULLABLE: failed attempts against unknown identities
21
+ # have no account to point at — the typed `identity` column below is
22
+ # what correlates them.
23
+ t.references :authenticatable, polymorphic: true, type: foreign_key_type, index: false
24
+ t.string :scope
25
+
26
+ # The trail ↔ registry linkage. A plain column, NO foreign key: the
27
+ # registry row it points at gets destroyed on revoke; history must
28
+ # survive. A suspicious login here is one lookup away from revoking
29
+ # the live session it created.
30
+ t.send(session_id_column_type, :session_id)
31
+
32
+ t.string :identity # email-as-typed (normalized), even for unknown accounts
33
+ # The browser-continuity id (same value as the registry row's) — lets
34
+ # `Sessions.last_login(request)` answer "how did this browser last
35
+ # sign in" after logout, powering the "Last used" login-page badge.
36
+ t.string :device_id, limit: 36
37
+ t.string :auth_method
38
+ t.string :auth_provider
39
+ t.send(json_column_type, :auth_detail)
40
+ t.string :failure_reason # devise message symbol / omniauth error type, verbatim
41
+ t.string :revoked_reason # user_revoked / admin_revoked / password_change /
42
+ # logout_everywhere / pruned / unknown
43
+
44
+ t.string :ip_address, limit: 45
45
+ t.text :user_agent
46
+ t.send(json_column_type, :client_hints)
47
+ t.string :browser_name
48
+ t.string :browser_version
49
+ t.string :os_name
50
+ t.string :os_version
51
+ t.string :device_type
52
+ t.string :device_model
53
+ t.string :app_name
54
+ t.string :app_version
55
+
56
+ # Geo via trackdown (soft dependency). lat/lng precision-reduced per
57
+ # config.geo_precision (2 decimals ≈ 1km) — privacy now,
58
+ # impossible-travel math later.
59
+ t.string :country_code, limit: 2
60
+ t.string :country_name
61
+ t.string :city
62
+ t.string :region
63
+ t.decimal :latitude, precision: 10, scale: 7
64
+ t.decimal :longitude, precision: 10, scale: 7
65
+
66
+ t.string :request_id
67
+ t.string :context # "controller#action"
68
+ t.send(json_column_type, :metadata)
69
+
70
+ t.datetime :occurred_at, null: false # append-only: no updated_at
71
+ end
72
+
73
+ add_index :sessions_events, %i[authenticatable_type authenticatable_id occurred_at],
74
+ name: "index_sessions_events_on_authenticatable_and_occurred_at"
75
+ add_index :sessions_events, %i[event occurred_at]
76
+ add_index :sessions_events, %i[device_id occurred_at]
77
+ add_index :sessions_events, :identity
78
+ add_index :sessions_events, :ip_address
79
+ add_index :sessions_events, :session_id
80
+ add_index :sessions_events, :occurred_at
81
+ end
82
+
83
+ private
84
+
85
+ def primary_and_foreign_key_types
86
+ config = Rails.configuration.generators
87
+ setting = config.options[config.orm][:primary_key_type]
88
+ primary_key_type = setting || :primary_key
89
+ foreign_key_type = setting || :bigint
90
+ [ primary_key_type, foreign_key_type ]
91
+ end
92
+
93
+ # session_id must match the sessions table's PRIMARY KEY type (uuid hosts
94
+ # store uuid linkage; bigint hosts store bigint).
95
+ def session_id_column_type
96
+ config = Rails.configuration.generators
97
+ setting = config.options[config.orm][:primary_key_type]
98
+ setting == :uuid ? :uuid : :bigint
99
+ end
100
+
101
+ # match? (not equality): PostGIS apps report adapter_name "PostGIS" and
102
+ # are PostgreSQL too.
103
+ def json_column_type
104
+ return :jsonb if connection.adapter_name.match?(/postg/i)
105
+
106
+ :json
107
+ end
108
+ end