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,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
|