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,411 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "browser"
|
|
4
|
+
|
|
5
|
+
module Sessions
|
|
6
|
+
# Turns a raw user agent (+ optional client-hint / X-Client-* headers) into
|
|
7
|
+
# the parsed device columns a "your devices" page renders. This is a
|
|
8
|
+
# PROJECTION: the raw UA is always persisted alongside, so parsing can be
|
|
9
|
+
# re-run as parsers and conventions improve.
|
|
10
|
+
#
|
|
11
|
+
# Three layers, in order (→ docs/research/07-device-detection.md):
|
|
12
|
+
#
|
|
13
|
+
# 1. Native matcher — Hotwire Native UAs (`/(Turbo|Hotwire) Native/`, the
|
|
14
|
+
# same contract as turbo-rails' `hotwire_native_app?`), the documented
|
|
15
|
+
# `AppName/1.2.3 (model; OS version; build N);` prefix convention,
|
|
16
|
+
# legacy shapes like "MyApp Android 1.0.5 (build 6; Android 14; sdk
|
|
17
|
+
# 34; Pixel 7)", and validated X-Client-* headers. No third-party
|
|
18
|
+
# parser understands any of this; it's the layer that names a session
|
|
19
|
+
# "MyApp 2.4.1 on Pixel 8 (Android 16)".
|
|
20
|
+
# 2. Web parser — the `browser` gem by default (MIT, zero-dep, what
|
|
21
|
+
# Mastodon uses), auto-upgrading to `device_detector` when the host
|
|
22
|
+
# bundles it, or any `->(user_agent, headers) { ... }` lambda.
|
|
23
|
+
# 3. Honesty filter — 2026 web UAs are frozen husks ("Windows NT 10.0",
|
|
24
|
+
# "Intel Mac OS X 10_15_7", "Android 10; K"); we never present a
|
|
25
|
+
# frozen token as a fact. OS versions are kept only where they're
|
|
26
|
+
# real: iOS UAs, Android WebViews (exempt from UA reduction), and
|
|
27
|
+
# Chromium client hints. Everything else renders version-less
|
|
28
|
+
# ("Chrome on macOS") — accurate beats impressive.
|
|
29
|
+
class Device
|
|
30
|
+
# Hard input bound (GitLab's SafeDeviceDetector precedent): parse at most
|
|
31
|
+
# this many characters. The FULL raw UA is still stored by the caller.
|
|
32
|
+
UA_PARSE_LIMIT = 1024
|
|
33
|
+
|
|
34
|
+
# Same contract as turbo-rails (app/controllers/turbo/native/navigation.rb).
|
|
35
|
+
NATIVE_MARKER = /(?:Turbo|Hotwire) Native (iOS|Android)/i
|
|
36
|
+
|
|
37
|
+
# The README's recommended prefix convention:
|
|
38
|
+
# MyApp/2.4.1 (iPhone15,2; iOS 19.5; build 241);
|
|
39
|
+
# Product tokens that are part of every browser UA must never match as
|
|
40
|
+
# app names.
|
|
41
|
+
BROWSER_PRODUCT_TOKENS = %w[
|
|
42
|
+
Mozilla AppleWebKit Chrome CriOS Safari Version Firefox FxiOS Gecko
|
|
43
|
+
KHTML Mobile OPR Edg\w* SamsungBrowser
|
|
44
|
+
].freeze
|
|
45
|
+
CONVENTION_DENYLIST = /\A(?:#{BROWSER_PRODUCT_TOKENS.join("|")})\z/i
|
|
46
|
+
CONVENTION = %r{(?<app>[A-Za-z][\w .-]*?)/(?<version>\d[\w.]*)\s*\((?<fields>[^)]*)\)}
|
|
47
|
+
|
|
48
|
+
# The legacy native-HTTP-client shape (apps declare the name via
|
|
49
|
+
# config.native_app_names):
|
|
50
|
+
# MyApp Android 1.0.5 (build 6; Android 14; sdk 34; Pixel 7)
|
|
51
|
+
LEGACY = /(?<app>[A-Za-z][\w .-]*?) (?<platform>iOS|Android) (?<version>\d[\w.]*)\s*\((?<fields>[^)]*)\)/
|
|
52
|
+
|
|
53
|
+
# The Android WebView segment of a Hotwire Native UA — exempt from
|
|
54
|
+
# Chrome's UA reduction, so model + OS version here are REAL.
|
|
55
|
+
ANDROID_WEBVIEW = %r{\(Linux; Android (?<os_version>[\d.]+); (?<model>[^;)]+?)(?: Build/[^;)]*)?[;)]}
|
|
56
|
+
|
|
57
|
+
# Real iOS version from the WebKit UA ("CPU iPhone OS 19_5 like Mac OS X").
|
|
58
|
+
IOS_OS_VERSION = /CPU (?:iPhone )?OS (?<version>\d+(?:_\d+)*)/
|
|
59
|
+
|
|
60
|
+
DEVICE_TYPES = %w[desktop smartphone tablet native_ios native_android bot unknown].freeze
|
|
61
|
+
|
|
62
|
+
ATTRIBUTES = %i[
|
|
63
|
+
browser_name browser_version os_name os_version
|
|
64
|
+
device_type device_model app_name app_version app_build
|
|
65
|
+
].freeze
|
|
66
|
+
|
|
67
|
+
attr_reader(*ATTRIBUTES)
|
|
68
|
+
|
|
69
|
+
# Parse a raw user agent string (and optional canonical headers hash from
|
|
70
|
+
# `Device.headers_from`). Never raises: a hostile or unparseable UA
|
|
71
|
+
# degrades to device_type "unknown" — tracking must never break login.
|
|
72
|
+
def self.parse(user_agent, headers: {})
|
|
73
|
+
new(user_agent, headers: headers)
|
|
74
|
+
rescue StandardError => e
|
|
75
|
+
Sessions.warn("device parsing failed: #{e.class}: #{e.message}")
|
|
76
|
+
allocate.tap { |device| device.send(:initialize_blank) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The interesting request headers, normalized to their canonical names —
|
|
80
|
+
# used both as parser input and as the raw `client_hints` column value
|
|
81
|
+
# (so a future `sessions:reparse` can re-run parsing offline).
|
|
82
|
+
CAPTURED_HEADERS = {
|
|
83
|
+
"HTTP_SEC_CH_UA" => "Sec-CH-UA",
|
|
84
|
+
"HTTP_SEC_CH_UA_MOBILE" => "Sec-CH-UA-Mobile",
|
|
85
|
+
"HTTP_SEC_CH_UA_PLATFORM" => "Sec-CH-UA-Platform",
|
|
86
|
+
"HTTP_SEC_CH_UA_PLATFORM_VERSION" => "Sec-CH-UA-Platform-Version",
|
|
87
|
+
"HTTP_SEC_CH_UA_MODEL" => "Sec-CH-UA-Model",
|
|
88
|
+
"HTTP_SEC_CH_UA_FULL_VERSION_LIST" => "Sec-CH-UA-Full-Version-List",
|
|
89
|
+
"HTTP_X_CLIENT_PLATFORM" => "X-Client-Platform",
|
|
90
|
+
"HTTP_X_CLIENT_VERSION" => "X-Client-Version",
|
|
91
|
+
"HTTP_X_CLIENT_BUILD" => "X-Client-Build",
|
|
92
|
+
"HTTP_X_CLIENT_OS" => "X-Client-OS"
|
|
93
|
+
}.freeze
|
|
94
|
+
|
|
95
|
+
def self.headers_from(request)
|
|
96
|
+
return {} unless request
|
|
97
|
+
|
|
98
|
+
CAPTURED_HEADERS.each_with_object({}) do |(env_key, name), hints|
|
|
99
|
+
value = request.get_header(env_key)
|
|
100
|
+
hints[name] = value if value.respond_to?(:to_str) && !value.to_str.empty?
|
|
101
|
+
end
|
|
102
|
+
rescue StandardError
|
|
103
|
+
{}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def initialize(user_agent, headers: {})
|
|
107
|
+
initialize_blank
|
|
108
|
+
# Strip BEFORE the emptiness check: the browser gem's bot list treats
|
|
109
|
+
# whitespace-only UAs as bots, but for a devices page they're just
|
|
110
|
+
# unknown.
|
|
111
|
+
@user_agent = user_agent.to_s.strip[0, UA_PARSE_LIMIT]
|
|
112
|
+
@headers = headers || {}
|
|
113
|
+
|
|
114
|
+
if native_platform
|
|
115
|
+
parse_native
|
|
116
|
+
elsif @user_agent.empty?
|
|
117
|
+
@device_type = "unknown"
|
|
118
|
+
else
|
|
119
|
+
parse_web
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
freeze
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def native?
|
|
126
|
+
device_type&.start_with?("native_")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def bot?
|
|
130
|
+
device_type == "bot"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Only the attributes that map 1:1 onto session/event columns.
|
|
134
|
+
def to_h
|
|
135
|
+
ATTRIBUTES.index_with { |attribute| public_send(attribute) }.compact
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def initialize_blank
|
|
141
|
+
ATTRIBUTES.each { |attribute| instance_variable_set(:"@#{attribute}", nil) }
|
|
142
|
+
@device_type = "unknown"
|
|
143
|
+
@user_agent = ""
|
|
144
|
+
@headers = {}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# --- Layer 1: native ------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
# ios/android/nil — the union of the three native signals.
|
|
150
|
+
def native_platform
|
|
151
|
+
@native_platform ||=
|
|
152
|
+
if (marker = @user_agent.match(NATIVE_MARKER))
|
|
153
|
+
marker[1].downcase == "ios" ? "ios" : "android"
|
|
154
|
+
elsif %w[ios android].include?(header("X-Client-Platform").to_s.downcase)
|
|
155
|
+
header("X-Client-Platform").downcase
|
|
156
|
+
elsif (legacy = legacy_match) && known_native_app?(legacy[:app])
|
|
157
|
+
legacy[:platform].downcase == "ios" ? "ios" : "android"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def parse_native
|
|
162
|
+
@device_type = "native_#{native_platform}"
|
|
163
|
+
|
|
164
|
+
apply_native_fallbacks # weakest signals first…
|
|
165
|
+
apply_ua_prefix
|
|
166
|
+
apply_client_headers # …explicit headers win
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Platform defaults available even when the app sets no UA prefix at all:
|
|
170
|
+
# Android WebView UAs carry real model + OS; iOS UAs carry real OS.
|
|
171
|
+
def apply_native_fallbacks
|
|
172
|
+
if native_platform == "android"
|
|
173
|
+
@os_name = "Android"
|
|
174
|
+
if (webview = @user_agent.match(ANDROID_WEBVIEW))
|
|
175
|
+
@os_version = webview[:os_version]
|
|
176
|
+
@device_model = clean_token(webview[:model])
|
|
177
|
+
end
|
|
178
|
+
else
|
|
179
|
+
@os_name = "iOS"
|
|
180
|
+
if (ios = @user_agent.match(IOS_OS_VERSION))
|
|
181
|
+
@os_version = ios[:version].tr("_", ".")
|
|
182
|
+
end
|
|
183
|
+
# WKWebView UAs carry the device FAMILY (real, unlike desktop
|
|
184
|
+
# Safari) — never the hardware model; the prefix convention adds
|
|
185
|
+
# that and overwrites this via apply_prefix_fields.
|
|
186
|
+
@device_model = "iPad" if @user_agent.include?("iPad")
|
|
187
|
+
@device_model = "iPhone" if @user_agent.include?("iPhone")
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def apply_ua_prefix
|
|
192
|
+
if (convention = convention_match)
|
|
193
|
+
@app_name = clean_token(convention[:app])
|
|
194
|
+
@app_version = convention[:version]
|
|
195
|
+
apply_prefix_fields(convention[:fields])
|
|
196
|
+
elsif (legacy = legacy_match)
|
|
197
|
+
@app_name = clean_token(legacy[:app])
|
|
198
|
+
@app_version = legacy[:version]
|
|
199
|
+
apply_prefix_fields(legacy[:fields])
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Semicolon-separated, order-insensitive fields from the parenthesized
|
|
204
|
+
# part of the prefix: "iPhone15,2; iOS 19.5; build 241". The first
|
|
205
|
+
# unrecognized field is the device model — more specific than any
|
|
206
|
+
# platform fallback (the iOS family, the Android WebView model), so it
|
|
207
|
+
# overwrites.
|
|
208
|
+
def apply_prefix_fields(fields)
|
|
209
|
+
model = nil
|
|
210
|
+
fields.to_s.split(";").map(&:strip).each do |field|
|
|
211
|
+
case field
|
|
212
|
+
when /\Abuild (\w+)\z/i then @app_build = Regexp.last_match(1)
|
|
213
|
+
when /\A(?:iOS|iPadOS) ([\d.]+)\z/i then @os_name = "iOS"
|
|
214
|
+
@os_version = Regexp.last_match(1)
|
|
215
|
+
when /\AAndroid ([\d.]+)\z/i then @os_name = "Android"
|
|
216
|
+
@os_version = Regexp.last_match(1)
|
|
217
|
+
when /\Asdk \d+\z/i then nil # Android API level — implied by the OS version
|
|
218
|
+
when "" then nil
|
|
219
|
+
else
|
|
220
|
+
model ||= field
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
@device_model = model if model
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Validated headers, production-proven (spoofable, diagnostics-only —
|
|
227
|
+
# never authorization). Bounds mirror ClientVersionInfo: semver-ish
|
|
228
|
+
# versions, OS strings capped at 64 chars.
|
|
229
|
+
def apply_client_headers
|
|
230
|
+
if (version = header("X-Client-Version")) && version.match?(/\A\d+(\.\d+){0,3}\z/)
|
|
231
|
+
@app_version = version
|
|
232
|
+
end
|
|
233
|
+
if (build = header("X-Client-Build")) && build.match?(/\A\w{1,32}\z/)
|
|
234
|
+
@app_build = build
|
|
235
|
+
end
|
|
236
|
+
if (os = header("X-Client-OS")) && (os = os[0, 64].strip) &&
|
|
237
|
+
(parsed = os.match(/\A(?<name>iOS|iPadOS|Android)\s+(?<version>[\d.]+)\z/i))
|
|
238
|
+
@os_name = parsed[:name].casecmp("android").zero? ? "Android" : "iOS"
|
|
239
|
+
@os_version = parsed[:version]
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def convention_match
|
|
244
|
+
@user_agent.to_enum(:scan, CONVENTION).map { Regexp.last_match }.find do |match|
|
|
245
|
+
!match[:app].match?(CONVENTION_DENYLIST) && match[:fields].include?(";")
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def legacy_match
|
|
250
|
+
@user_agent.match(LEGACY)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def known_native_app?(app_name)
|
|
254
|
+
Sessions.config.native_app_names.any? { |known| app_name.strip.casecmp?(known) }
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# --- Layer 2: web ---------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
def parse_web
|
|
260
|
+
parser = Sessions.config.ua_parser
|
|
261
|
+
|
|
262
|
+
if parser.respond_to?(:call)
|
|
263
|
+
apply_custom(parser.call(@user_agent, @headers))
|
|
264
|
+
elsif parser == :device_detector && defined?(::DeviceDetector)
|
|
265
|
+
parse_with_device_detector
|
|
266
|
+
else
|
|
267
|
+
parse_with_browser
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
apply_client_hints
|
|
271
|
+
enforce_honest_os_version
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def parse_with_browser
|
|
275
|
+
browser = ::Browser.new(@user_agent)
|
|
276
|
+
|
|
277
|
+
if browser.bot?
|
|
278
|
+
@device_type = "bot"
|
|
279
|
+
@browser_name = browser.bot.name
|
|
280
|
+
return
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
@browser_name = presence(browser.name) unless browser.name == "Unknown Browser"
|
|
284
|
+
@browser_version = presence(browser.version) unless browser.version.to_s == "0"
|
|
285
|
+
@os_name = browser_platform_name(browser)
|
|
286
|
+
@os_version = presence(browser.platform.version)
|
|
287
|
+
@device_type = if browser.device.tablet? then "tablet"
|
|
288
|
+
elsif browser.device.mobile? then "smartphone"
|
|
289
|
+
elsif @os_name then "desktop"
|
|
290
|
+
else "unknown"
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def browser_platform_name(browser)
|
|
295
|
+
case browser.platform.id
|
|
296
|
+
when :mac then "macOS"
|
|
297
|
+
when :windows then "Windows"
|
|
298
|
+
when :linux then "Linux"
|
|
299
|
+
when :ios then "iOS"
|
|
300
|
+
when :android then "Android"
|
|
301
|
+
when :chrome_os then "ChromeOS"
|
|
302
|
+
when :unknown_platform, nil then nil
|
|
303
|
+
else presence(browser.platform.name)
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def parse_with_device_detector
|
|
308
|
+
detector = ::DeviceDetector.new(@user_agent, device_detector_headers)
|
|
309
|
+
|
|
310
|
+
if detector.bot?
|
|
311
|
+
@device_type = "bot"
|
|
312
|
+
@browser_name = detector.bot_name
|
|
313
|
+
return
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
@browser_name = presence(detector.name) unless detector.name == "UNK"
|
|
317
|
+
@browser_version = presence(detector.full_version.to_s.split(".").first)
|
|
318
|
+
@os_name = presence(detector.os_name) unless detector.os_name == "UNK"
|
|
319
|
+
@os_name = "macOS" if @os_name == "Mac"
|
|
320
|
+
@os_version = presence(detector.os_full_version)
|
|
321
|
+
@device_model = presence(detector.device_name)
|
|
322
|
+
@device_type = case detector.device_type
|
|
323
|
+
when "smartphone", "phablet" then "smartphone"
|
|
324
|
+
when "tablet" then "tablet"
|
|
325
|
+
when "desktop" then "desktop"
|
|
326
|
+
when nil then @os_name ? "desktop" : "unknown"
|
|
327
|
+
else "unknown"
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# device_detector expects literal header names, not Rack env keys — our
|
|
332
|
+
# canonical hash already uses them.
|
|
333
|
+
def device_detector_headers
|
|
334
|
+
@headers.slice(*CAPTURED_HEADERS.values.grep(/\ASec-CH/))
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def apply_custom(result)
|
|
338
|
+
return unless result.respond_to?(:to_h)
|
|
339
|
+
|
|
340
|
+
result.to_h.symbolize_keys.slice(*ATTRIBUTES).each do |attribute, value|
|
|
341
|
+
instance_variable_set(:"@#{attribute}", presence(value&.to_s))
|
|
342
|
+
end
|
|
343
|
+
@device_type = "unknown" unless DEVICE_TYPES.include?(@device_type)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# High-entropy Chromium client hints recover what the frozen UA dropped:
|
|
347
|
+
# real platform versions and Android device models.
|
|
348
|
+
def apply_client_hints
|
|
349
|
+
if (model = header("Sec-CH-UA-Model")) && !(model = unquote(model)).empty?
|
|
350
|
+
@device_model = model
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
if (platform_version = header("Sec-CH-UA-Platform-Version"))
|
|
354
|
+
version = unquote(platform_version)
|
|
355
|
+
@os_version = hinted_os_version(version) unless version.empty?
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
case header("Sec-CH-UA-Mobile")
|
|
359
|
+
when "?1" then @device_type = "smartphone" if @device_type == "desktop"
|
|
360
|
+
when "?0" then @device_type = "desktop" if @device_type == "smartphone"
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Windows is the one platform whose hinted version needs decoding: the
|
|
365
|
+
# platform version is the internal build line, where 13+ means Windows 11
|
|
366
|
+
# (learn.microsoft.com/microsoft-edge/web-platform/how-to-detect-win11).
|
|
367
|
+
def hinted_os_version(version)
|
|
368
|
+
return version unless @os_name == "Windows"
|
|
369
|
+
|
|
370
|
+
major = version.split(".").first.to_i
|
|
371
|
+
if major >= 13 then "11"
|
|
372
|
+
elsif major.positive? then "10"
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Frozen-UA honesty: a 2026 web UA carries REAL OS versions only on iOS.
|
|
377
|
+
# macOS is frozen at 10.15.7, Windows at NT 10.0, Chrome-on-Android at
|
|
378
|
+
# "Android 10; K" — rendering those as facts would be lying to users on
|
|
379
|
+
# their own security page. Client hints (handled above) overwrite with
|
|
380
|
+
# real values when present.
|
|
381
|
+
def enforce_honest_os_version
|
|
382
|
+
return if @os_version.nil?
|
|
383
|
+
return if @os_name == "iOS"
|
|
384
|
+
return if hinted?("Sec-CH-UA-Platform-Version")
|
|
385
|
+
|
|
386
|
+
@os_version = nil
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# --- Helpers ----------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
def header(name)
|
|
392
|
+
presence(@headers[name])
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def hinted?(name)
|
|
396
|
+
!header(name).nil?
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def unquote(value)
|
|
400
|
+
value.to_s.delete('"').strip
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def clean_token(value)
|
|
404
|
+
presence(value.to_s.strip.delete_suffix(";"))
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def presence(value)
|
|
408
|
+
value.nil? || value.to_s.empty? ? nil : value
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
|
|
5
|
+
module Sessions
|
|
6
|
+
# The mountable engine: wires autoloading, migrations, locales, the
|
|
7
|
+
# `has_sessions` macro, the request-capture middleware, and all three
|
|
8
|
+
# adapters into the host app — every adapter strictly capability-detected,
|
|
9
|
+
# so the gem is inert wherever its target isn't present.
|
|
10
|
+
class Engine < ::Rails::Engine
|
|
11
|
+
isolate_namespace Sessions
|
|
12
|
+
|
|
13
|
+
# -------------------------------------------------------------------------
|
|
14
|
+
# Zeitwerk: the gem keeps its ActiveRecord models and jobs under
|
|
15
|
+
# lib/sessions/{models,jobs} (same layout as the moderate and chats gems)
|
|
16
|
+
# so the whole domain ships in lib/ and the engine's app/ tree only holds
|
|
17
|
+
# the web layer (controllers, helpers, views). For that to autoload
|
|
18
|
+
# correctly we manage the loader by hand:
|
|
19
|
+
#
|
|
20
|
+
# - `push_dir(lib/sessions, namespace: Sessions)` makes
|
|
21
|
+
# lib/sessions/models/... autoloadable *under the Sessions namespace*.
|
|
22
|
+
# - `collapse(models)` + `collapse(models/concerns)` + `collapse(jobs)`
|
|
23
|
+
# mean those files define Sessions::Event / Sessions::Model /
|
|
24
|
+
# Sessions::GeolocateJob — not Sessions::Models::Event.
|
|
25
|
+
# - The SPINE files (version/errors/configuration/adapters/…) are
|
|
26
|
+
# required explicitly by lib/sessions.rb at boot, so they must be
|
|
27
|
+
# *ignored* by the loader or Zeitwerk would complain about double
|
|
28
|
+
# definitions / unmanaged constants.
|
|
29
|
+
# -------------------------------------------------------------------------
|
|
30
|
+
LIB_ROOT = File.expand_path("..", __dir__)
|
|
31
|
+
SESSIONS_LIB = File.expand_path("sessions", LIB_ROOT)
|
|
32
|
+
|
|
33
|
+
ZEITWERK_IGNORED = %w[
|
|
34
|
+
version.rb errors.rb configuration.rb current.rb ip_address.rb
|
|
35
|
+
device.rb classifier.rb geolocation.rb middleware.rb macros.rb
|
|
36
|
+
adapters engine.rb
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
initializer "sessions.autoload", before: :set_autoload_paths do
|
|
40
|
+
loader = Rails.autoloaders.main
|
|
41
|
+
|
|
42
|
+
ZEITWERK_IGNORED.each do |entry|
|
|
43
|
+
path = File.join(SESSIONS_LIB, entry)
|
|
44
|
+
loader.ignore(path) if File.exist?(path)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
%w[models models/concerns jobs].each do |dir|
|
|
48
|
+
path = File.join(SESSIONS_LIB, dir)
|
|
49
|
+
loader.collapse(path) if File.directory?(path)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
loader.push_dir(SESSIONS_LIB, namespace: Sessions)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
config.eager_load_paths << SESSIONS_LIB
|
|
56
|
+
|
|
57
|
+
# Request capture for model-callback context (and the opt-in Accept-CH
|
|
58
|
+
# advertisement). Inserted after the Executor so CurrentAttributes'
|
|
59
|
+
# executor-driven reset cleans our state after every request.
|
|
60
|
+
initializer "sessions.middleware" do |app|
|
|
61
|
+
app.middleware.insert_after ActionDispatch::Executor, Sessions::Middleware
|
|
62
|
+
rescue StandardError
|
|
63
|
+
app.middleware.use Sessions::Middleware
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Expose `has_sessions` on every AR model.
|
|
67
|
+
initializer "sessions.active_record" do
|
|
68
|
+
ActiveSupport.on_load(:active_record) do
|
|
69
|
+
extend Sessions::Macros
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Ship the gem's locale files (en, es). Host locale files with the same
|
|
74
|
+
# keys override these automatically (I18n's load order puts the app last).
|
|
75
|
+
initializer "sessions.locales" do |app|
|
|
76
|
+
app.config.i18n.load_path += Dir[root.join("config", "locales", "**", "*.{rb,yml}").to_s]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Serve the engine's stylesheet through the host's asset pipeline
|
|
80
|
+
# (propshaft or sprockets — both honor config.assets.paths).
|
|
81
|
+
initializer "sessions.assets" do |app|
|
|
82
|
+
app.config.assets.paths << root.join("app/assets/stylesheets") if app.config.respond_to?(:assets)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The Devise/Warden adapter. Bundler.require has already loaded every
|
|
86
|
+
# gem in the Gemfile by the time initializers run, so `defined?` is
|
|
87
|
+
# decisive regardless of Gemfile order; Warden hooks live on the Manager
|
|
88
|
+
# CLASS and are read live per request, so registering here (before the
|
|
89
|
+
# first request) is all that's needed (→ research/04 §8).
|
|
90
|
+
initializer "sessions.warden" do
|
|
91
|
+
Sessions::Adapters::Warden.install! if defined?(::Warden::Manager)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Rails 8.1's rate_limit notification — a free brute-force-threshold
|
|
95
|
+
# signal on the generated sessions/passwords controllers. Subscribed
|
|
96
|
+
# once per process; inert on earlier Rails.
|
|
97
|
+
initializer "sessions.rate_limit" do
|
|
98
|
+
Sessions::Adapters::Omakase.subscribe_rate_limit_notifications!
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The OmniAuth failure composer must wrap LAST — after Devise replaced
|
|
102
|
+
# the failure endpoint (at require time) and after the app's own
|
|
103
|
+
# initializers possibly customized it. after_initialize runs once, after
|
|
104
|
+
# everything.
|
|
105
|
+
config.after_initialize do
|
|
106
|
+
Sessions::Adapters::Omniauth.install! if defined?(::OmniAuth)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The omakase adapter touches autoloaded app constants (Session,
|
|
110
|
+
# ApplicationController), so it must re-apply on every code reload.
|
|
111
|
+
config.to_prepare do
|
|
112
|
+
# Touch the helper so its bottom-of-file on_load(:action_view) hook
|
|
113
|
+
# registers even if no engine code was referenced yet — and clear its
|
|
114
|
+
# mount-name memo, since a development reload may have redrawn routes.
|
|
115
|
+
Sessions::EngineHelper.reset_engine_mount_name!
|
|
116
|
+
|
|
117
|
+
Sessions::Adapters::Omakase.install!
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sessions
|
|
4
|
+
# Base class for every error this gem raises, so hosts can
|
|
5
|
+
# `rescue Sessions::Error` to catch anything sessions-specific.
|
|
6
|
+
#
|
|
7
|
+
# Deliberately tiny: the gem sits on the authentication hot path, where its
|
|
8
|
+
# contract is to OBSERVE and never to interrupt — tracking code rescues its
|
|
9
|
+
# own failures (see Sessions.safely) instead of raising into a sign-in.
|
|
10
|
+
# Errors here are reserved for things that SHOULD stop you: invalid
|
|
11
|
+
# configuration at boot, and generator-time misdetection.
|
|
12
|
+
class Error < StandardError; end
|
|
13
|
+
|
|
14
|
+
# Raised by `Sessions.configure` / setters when the configuration is
|
|
15
|
+
# invalid (unknown ip_mode, non-callable hook, blank class name, …).
|
|
16
|
+
# Fails at boot with a plain-English message, not at 3am with a
|
|
17
|
+
# NoMethodError.
|
|
18
|
+
class ConfigurationError < Error; end
|
|
19
|
+
|
|
20
|
+
# Raised at generator time when `rails generate sessions:install` can't
|
|
21
|
+
# tell which auth system the app uses (no Rails 8 authentication, no
|
|
22
|
+
# Devise) — the gem decorates a session of record; it never creates one.
|
|
23
|
+
class UnknownAuthSystemError < Error; end
|
|
24
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sessions
|
|
4
|
+
# IP geolocation through the `trackdown` gem — a SOFT dependency, never
|
|
5
|
+
# required: every call site is `defined?(::Trackdown)`-guarded and
|
|
6
|
+
# rescue-everything (trackdown raises on private/loopback IPs in
|
|
7
|
+
# development, and a geo hiccup must never block a login write). The
|
|
8
|
+
# integration contract is lifted verbatim from footprinted's proven one
|
|
9
|
+
# (→ docs/research/02-ecosystem.md §2):
|
|
10
|
+
#
|
|
11
|
+
# - always pass `request:` through so Cloudflare headers win when present
|
|
12
|
+
# (zero config, free, synchronous header read);
|
|
13
|
+
# - go async only when sync would mean a database lookup (the MaxMind
|
|
14
|
+
# mode) — Sessions::GeolocateJob enriches the row after commit;
|
|
15
|
+
# - skip lookups when country_code is already present.
|
|
16
|
+
#
|
|
17
|
+
# Without trackdown, geo columns simply stay nil and the devices page
|
|
18
|
+
# omits location cleanly.
|
|
19
|
+
module Geolocation
|
|
20
|
+
COLUMNS = %i[country_code country_name city region].freeze
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def enabled?
|
|
25
|
+
Sessions.config.geolocate == :auto && defined?(::Trackdown)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Synchronous geolocation — called inline at session-creation time ONLY
|
|
29
|
+
# when it's free (Cloudflare already did the lookup and put the answer
|
|
30
|
+
# in request headers). Returns a column hash or {}. `coordinates: true`
|
|
31
|
+
# adds precision-reduced lat/lng (event rows only).
|
|
32
|
+
def locate(ip, request: nil, coordinates: false)
|
|
33
|
+
return {} unless enabled?
|
|
34
|
+
return {} if ip.to_s.empty?
|
|
35
|
+
|
|
36
|
+
result = ::Trackdown.locate(ip.to_s, request: request)
|
|
37
|
+
columns = columns_from(result)
|
|
38
|
+
columns = columns.merge(coordinates_from(result)) if coordinates && columns.any?
|
|
39
|
+
columns
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
Sessions.warn("geolocation failed for #{ip}: #{e.class}: #{e.message}")
|
|
42
|
+
{}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Hand a record to the async MaxMind path (no-op without ActiveJob or a
|
|
46
|
+
# trackdown database — see async_capable?).
|
|
47
|
+
def enqueue(record)
|
|
48
|
+
return false unless record&.persisted?
|
|
49
|
+
return false unless async_capable?
|
|
50
|
+
|
|
51
|
+
Sessions::GeolocateJob.perform_later(record.class.name, record.id)
|
|
52
|
+
true
|
|
53
|
+
rescue StandardError => e
|
|
54
|
+
Sessions.warn("geolocation enqueue failed: #{e.class}: #{e.message}")
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Whether this request already carries a Cloudflare geo answer — the
|
|
59
|
+
# header read is free, so we resolve synchronously.
|
|
60
|
+
def cloudflare_headers?(request)
|
|
61
|
+
return false unless request
|
|
62
|
+
|
|
63
|
+
country = request.get_header("HTTP_CF_IPCOUNTRY")
|
|
64
|
+
!country.nil? && !country.empty? && country != "XX"
|
|
65
|
+
rescue StandardError
|
|
66
|
+
false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Whether an async MaxMind lookup could possibly succeed — used to avoid
|
|
70
|
+
# enqueueing a no-op job per login on hosts without a MaxMind database.
|
|
71
|
+
def async_capable?
|
|
72
|
+
enabled? &&
|
|
73
|
+
defined?(::ActiveJob) &&
|
|
74
|
+
::Trackdown.respond_to?(:database_exists?) &&
|
|
75
|
+
::Trackdown.database_exists?
|
|
76
|
+
rescue StandardError
|
|
77
|
+
false
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def columns_from(result)
|
|
81
|
+
return {} unless result
|
|
82
|
+
return {} if result.country_code.to_s.empty?
|
|
83
|
+
|
|
84
|
+
{
|
|
85
|
+
country_code: result.country_code,
|
|
86
|
+
country_name: presence(result.country_name),
|
|
87
|
+
city: presence(result.city),
|
|
88
|
+
region: presence(result.region)
|
|
89
|
+
}.compact
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Latitude/longitude for EVENT rows only, precision-reduced per
|
|
93
|
+
# config.geo_precision (2 decimals ≈ 1km — privacy now,
|
|
94
|
+
# impossible-travel math later).
|
|
95
|
+
def coordinates_from(result)
|
|
96
|
+
return {} unless result.respond_to?(:latitude) && result.latitude
|
|
97
|
+
|
|
98
|
+
precision = Sessions.config.geo_precision
|
|
99
|
+
{
|
|
100
|
+
latitude: result.latitude.to_f.round(precision),
|
|
101
|
+
longitude: result.longitude.to_f.round(precision)
|
|
102
|
+
}
|
|
103
|
+
rescue StandardError
|
|
104
|
+
{}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def presence(value)
|
|
108
|
+
value.nil? || value.to_s.empty? || value.to_s == "Unknown" ? nil : value
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|