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,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ module Sessions
6
+ # IP capture, normalization and (optional) anonymization.
7
+ #
8
+ # Capture goes through `config.ip_resolver` (default `request.remote_ip`,
9
+ # which honors Rails' trusted_proxies — see the README's "Behind
10
+ # Cloudflare" section for CDN setups). Every address is IPAddr-normalized
11
+ # before persistence (canonical form, garbage rejected) and, when
12
+ # `config.ip_mode = :truncated`, anonymized BEFORE it ever touches disk:
13
+ # the last IPv4 octet / the last 80 IPv6 bits are zeroed — the Google
14
+ # Analytics precedent, and the reason the column can be shown to a GDPR
15
+ # auditor with a straight face.
16
+ module IpAddress
17
+ # 45 chars covers the maximum IPv6 textual form including IPv4-mapped
18
+ # addresses ("::ffff:255.255.255.255") — the portable column size used
19
+ # across sqlite/mysql/postgres.
20
+ MAX_LENGTH = 45
21
+
22
+ # Anonymization prefix lengths (bits kept): IPv4 /24 zeroes the last
23
+ # octet; IPv6 /48 zeroes the last 80 bits.
24
+ IPV4_PREFIX = 24
25
+ IPV6_PREFIX = 48
26
+
27
+ module_function
28
+
29
+ # The client IP for this request, resolved + normalized + anonymized per
30
+ # configuration. Returns nil for unresolvable/garbage input — a nil IP
31
+ # must never block a login write.
32
+ def resolve(request)
33
+ return nil unless request
34
+
35
+ raw = Sessions.config.ip_resolver.call(request)
36
+ normalize(raw)
37
+ rescue StandardError => e
38
+ Sessions.warn("ip resolution failed: #{e.class}: #{e.message}")
39
+ nil
40
+ end
41
+
42
+ def normalize(raw)
43
+ return nil if raw.to_s.strip.empty?
44
+
45
+ address = IPAddr.new(raw.to_s.strip[0, MAX_LENGTH])
46
+ address = truncate(address) if Sessions.config.ip_mode == :truncated
47
+ address.to_s
48
+ rescue ArgumentError # IPAddr::Error included — it subclasses ArgumentError
49
+ nil
50
+ end
51
+
52
+ def truncate(address)
53
+ address.mask(address.ipv4? ? IPV4_PREFIX : IPV6_PREFIX)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sessions
4
+ # Async geo enrichment for the MaxMind path (the Cloudflare path is a free
5
+ # synchronous header read at request time and never gets here). Enqueued
6
+ # after commit — only when trackdown is present AND has a database (see
7
+ # Sessions::Geolocation.async_capable?), so hosts without MaxMind never
8
+ # see a no-op job.
9
+ #
10
+ # The conditional superclass keeps the constant loadable (Zeitwerk eager
11
+ # loads this file) in the rare host that runs without ActiveJob — where
12
+ # enqueueing is already guarded off.
13
+ class GeolocateJob < (defined?(::ActiveJob::Base) ? ::ActiveJob::Base : Object)
14
+ if defined?(::ActiveJob::Base)
15
+ discard_on ActiveRecord::RecordNotFound if defined?(::ActiveRecord::RecordNotFound)
16
+
17
+ def perform(class_name, id)
18
+ record = class_name.constantize.find_by(id: id)
19
+ return unless record.respond_to?(:country_code)
20
+ return if record.country_code.present?
21
+
22
+ ip = record.try(:ip_address)
23
+ return if ip.blank?
24
+ return unless defined?(::Trackdown)
25
+
26
+ result = ::Trackdown.locate(ip.to_s)
27
+ updates = Sessions::Geolocation.columns_from(result)
28
+ return if updates.empty?
29
+
30
+ # Events also store precision-reduced coordinates (privacy now,
31
+ # impossible-travel math later); registry rows don't have the
32
+ # columns and the filter drops them.
33
+ updates.merge!(Sessions::Geolocation.coordinates_from(result))
34
+ updates.select! { |column, _| record.class.column_names.include?(column.to_s) }
35
+
36
+ record.update_columns(updates) if updates.any?
37
+
38
+ mirror_to_login_event(record, updates)
39
+ rescue StandardError => e
40
+ Sessions.warn("geolocate job failed: #{e.class}: #{e.message}")
41
+ end
42
+
43
+ private
44
+
45
+ # When the enriched record is a registry row, its login event was
46
+ # written before geo resolved — keep the trail consistent.
47
+ def mirror_to_login_event(record, updates)
48
+ return unless record.respond_to?(:sessions_token_matches?) # a registry row
49
+ return unless Sessions::Event.table_exists?
50
+
51
+ event_updates = updates.select { |column, _| Sessions::Event.column_names.include?(column.to_s) }
52
+ return if event_updates.empty?
53
+
54
+ Sessions::Event.logins.where(session_id: record.id, country_code: nil).update_all(event_updates)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sessions
4
+ # The host-facing macro. The engine extends `ActiveRecord::Base` with this
5
+ # module (via `ActiveSupport.on_load(:active_record)`), so the auth model
6
+ # can declare:
7
+ #
8
+ # class User < ApplicationRecord
9
+ # has_sessions
10
+ # end
11
+ #
12
+ # On a Rails 8 omakase app this ENRICHES the `has_many :sessions` the
13
+ # authentication generator already wrote (revocation verbs, the events
14
+ # association, password-change auto-revocation); on a Devise app it also
15
+ # declares the association itself. Same grammar as the rest of the
16
+ # ecosystem: `has_credits`, `has_api_keys`, `has_wallets`.
17
+ #
18
+ # The macro is a thin forwarder — all behavior lives in
19
+ # Sessions::HasSessions so it's discoverable, testable, and `include`-able
20
+ # directly when a host prefers that style.
21
+ module Macros
22
+ def has_sessions
23
+ include Sessions::HasSessions
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sessions
4
+ # A tiny rack middleware with two jobs:
5
+ #
6
+ # 1. Stash the current request in Sessions::Current so MODEL-level
7
+ # callbacks (the omakase adapter's whole login pipeline) can see
8
+ # request context. The engine inserts this after
9
+ # ActionDispatch::Executor, so the executor's CurrentAttributes
10
+ # reset cleans up after every request — no leaks across requests or
11
+ # between jobs and web.
12
+ #
13
+ # 2. When `config.request_client_hints` is on, advertise `Accept-CH` so
14
+ # Chromium browsers attach high-entropy client hints (real platform
15
+ # versions, Android device models) to subsequent requests — login
16
+ # POSTs are rarely first-navigations, so the hints are reliably
17
+ # there exactly when sessions get created.
18
+ class Middleware
19
+ # The high-entropy hints the device pipeline consumes (low-entropy ones
20
+ # are sent by default on every secure request).
21
+ ACCEPT_CH = "Sec-CH-UA-Platform-Version, Sec-CH-UA-Model, Sec-CH-UA-Full-Version-List"
22
+
23
+ def initialize(app)
24
+ @app = app
25
+ end
26
+
27
+ def call(env)
28
+ Sessions::Current.request = ActionDispatch::Request.new(env)
29
+
30
+ status, headers, body = @app.call(env)
31
+
32
+ if Sessions.config.request_client_hints && !(headers["accept-ch"] || headers["Accept-CH"])
33
+ # Lowercase per the Rack 3 spec; Rack 2 hashes pass it through
34
+ # verbatim and HTTP header names are case-insensitive on the wire.
35
+ headers["accept-ch"] = ACCEPT_CH
36
+ end
37
+
38
+ [status, headers, body]
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sessions
4
+ # Human, honest device presentation — shared by the registry rows
5
+ # (Sessions::Model) and the trail (Sessions::Event), which carry the same
6
+ # parsed device columns:
7
+ #
8
+ # "Chrome 137 on macOS"
9
+ # "MyApp 2.4.1 on Pixel 8 (Android 16)"
10
+ # "iPhone (iOS 19.5)"
11
+ #
12
+ # Frozen-UA tokens are never rendered as facts — versions appear only
13
+ # where they're real (see Sessions::Device).
14
+ module DeviceDisplay
15
+ extend ActiveSupport::Concern
16
+
17
+ def device_name
18
+ return sessions_t("bot", default: "Bot (%{name})", name: try(:browser_name) || "unknown") if bot?
19
+ return sessions_native_device_name if hotwire_native?
20
+
21
+ client = [try(:browser_name), try(:browser_version)].compact.join(" ").presence
22
+ platform = sessions_os_label
23
+
24
+ if client && platform
25
+ sessions_t("composite", default: "%{client} on %{platform}", client: client, platform: platform)
26
+ else
27
+ client || platform || sessions_t("unknown", default: "Unknown device")
28
+ end
29
+ end
30
+
31
+ # "Madrid, Spain" — or nil when geolocation is unavailable (the UI
32
+ # omits location cleanly).
33
+ def location
34
+ [try(:city), try(:country_name)].compact_blank.join(", ").presence
35
+ end
36
+
37
+ # "🇪🇸" — derived from country_code at render time; no column needed.
38
+ def country_flag
39
+ code = try(:country_code).to_s
40
+ return nil unless code.match?(/\A[A-Za-z]{2}\z/)
41
+
42
+ code.upcase.each_codepoint.map { |codepoint| (codepoint + 0x1F1A5).chr(Encoding::UTF_8) }.join
43
+ end
44
+
45
+ # "🇪🇸 Madrid, Spain · IP 83.45.112.7 · Firefox 139 on Windows" — the
46
+ # one-line WHERE-then-WHAT of a login, ready for security emails,
47
+ # notification bodies, and admin lists. Location leads (people
48
+ # recognize places; browser version numbers mean nothing to them), the
49
+ # IP is the verifiable fact, the device closes. Each part drops out
50
+ # cleanly when the record lacks it (no geo in dev, no UA on odd
51
+ # clients). `ip: false` for compact UI like notification feed rows;
52
+ # `separator:` for plain-text contexts.
53
+ def source_line(ip: true, separator: " · ")
54
+ located = [country_flag, location].compact.join(" ").presence
55
+ address = ip ? (try(:ip_address) || try(:last_seen_ip)).presence : nil
56
+
57
+ [located, address && "IP #{address}", device_name].compact.join(separator).presence
58
+ end
59
+
60
+ def hotwire_native?
61
+ try(:device_type).to_s.start_with?("native_")
62
+ end
63
+
64
+ def native_ios?
65
+ try(:device_type) == "native_ios"
66
+ end
67
+
68
+ def native_android?
69
+ try(:device_type) == "native_android"
70
+ end
71
+
72
+ def bot?
73
+ try(:device_type) == "bot"
74
+ end
75
+
76
+ def web?
77
+ !hotwire_native? && !bot?
78
+ end
79
+
80
+ def via_oauth?
81
+ try(:auth_method) == "oauth"
82
+ end
83
+
84
+ def via_password?
85
+ try(:auth_method) == "password"
86
+ end
87
+
88
+ # The second factor that protected this login, when one did: "totp"
89
+ # (authenticator apps via devise-two-factor — detected automatically),
90
+ # "backup_code", or whatever the host tagged ("webauthn" for security
91
+ # keys / Touch ID as a second factor — see the README's two-factor
92
+ # recipes). nil for single-factor logins.
93
+ def second_factor
94
+ detail = try(:auth_detail)
95
+ detail.is_a?(Hash) ? detail["second_factor"].presence : nil
96
+ end
97
+
98
+ def second_factor?
99
+ second_factor.present?
100
+ end
101
+
102
+ # "Google", "GitHub", "password", "passkey"… for "Signed in via %{method}"
103
+ # copy. nil when the method is unknown (the UI omits the clause).
104
+ def auth_method_label
105
+ method = try(:auth_method)
106
+ return nil if method.blank? || method == "unknown"
107
+ return try(:auth_provider).to_s.titleize if via_oauth? && try(:auth_provider).present?
108
+
109
+ I18n.t("sessions.auth_methods.#{method}", default: method.humanize.downcase)
110
+ end
111
+
112
+ private
113
+
114
+ def sessions_native_device_name
115
+ hardware = [try(:device_model).presence, sessions_os_label && "(#{sessions_os_label})"].compact.join(" ")
116
+ hardware = sessions_os_label if hardware.blank?
117
+
118
+ if try(:app_name).present?
119
+ client = [try(:app_name), try(:app_version)].compact_blank.join(" ")
120
+ sessions_t("composite", default: "%{client} on %{platform}", client: client, platform: hardware)
121
+ else
122
+ hardware || sessions_t("unknown", default: "Unknown device")
123
+ end
124
+ end
125
+
126
+ def sessions_os_label
127
+ [try(:os_name), try(:os_version)].compact_blank.join(" ").presence
128
+ end
129
+
130
+ def sessions_t(key, **options)
131
+ I18n.t("sessions.device_name.#{key}", **options)
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sessions
4
+ # What `has_sessions` includes into the auth model. On Rails 8 omakase
5
+ # apps the generated `has_many :sessions, dependent: :destroy` already
6
+ # exists and is left alone; on Devise apps the association is declared
7
+ # here. Either way the model gains the events trail and the revocation
8
+ # verbs:
9
+ #
10
+ # current_user.sessions.active
11
+ # current_user.session_events.failed_logins.last_24_hours
12
+ # current_user.revoke_other_sessions! # "sign out everywhere else"
13
+ # current_user.revoke_all_sessions! # the account-takeover hammer
14
+ #
15
+ # Plus the ASVS 3.3.3 default: changing the password revokes every other
16
+ # session (`config.revoke_on_password_change`), detected on whichever
17
+ # digest column the auth stack uses (password_digest / encrypted_password).
18
+ module HasSessions
19
+ extend ActiveSupport::Concern
20
+
21
+ PASSWORD_COLUMNS = %w[password_digest encrypted_password].freeze
22
+
23
+ included do
24
+ unless reflect_on_association(:sessions)
25
+ if Sessions::HasSessions.polymorphic_sessions?
26
+ has_many :sessions, class_name: Sessions.config.session_class, as: :user, dependent: :destroy
27
+ else
28
+ has_many :sessions,
29
+ class_name: Sessions.config.session_class,
30
+ foreign_key: :user_id,
31
+ inverse_of: :user,
32
+ dependent: :destroy
33
+ end
34
+ end
35
+
36
+ # delete_all (not destroy) — the trail is append-only data with no
37
+ # callbacks worth running, and erasing it with the account is the
38
+ # GDPR-correct default. `Sessions.forget(user)` does the full
39
+ # right-to-erasure pass including typed identities.
40
+ has_many :session_events,
41
+ class_name: "Sessions::Event",
42
+ as: :authenticatable,
43
+ dependent: :delete_all
44
+
45
+ after_update :sessions_revoke_others_on_password_change, if: :sessions_password_changed?
46
+ end
47
+
48
+ def self.polymorphic_sessions?
49
+ Sessions.session_model.column_names.include?("user_type")
50
+ rescue StandardError
51
+ false
52
+ end
53
+
54
+ # The user's COMPLETE trail slice — owned events PLUS the failed
55
+ # attempts typed against their email. Failures deliberately never link
56
+ # to accounts (`session_events` alone can't see them — recording a
57
+ # failure must not confirm an account exists); matching the resolved
58
+ # user's own identity here is the safe read side. This is what the
59
+ # engine's history page renders:
60
+ #
61
+ # user.session_history.recent # everything, newest first
62
+ # user.session_history.failed_logins # including identity-matched ones
63
+ def session_history
64
+ scope = Sessions::Event.where(authenticatable: self)
65
+
66
+ identity = Sessions::Event.normalize_identity(try(:email_address) || try(:email))
67
+ scope = scope.or(Sessions::Event.where(identity: identity)) if identity
68
+
69
+ scope
70
+ end
71
+
72
+ # GitHub's "sign out everywhere else": revoke every session except
73
+ # +current+ (defaulting to the one serving this request, so a controller
74
+ # can call it bare). Each revocation writes its event and fires hooks.
75
+ def revoke_other_sessions!(current: nil, by: nil, reason: :logout_everywhere)
76
+ current = Sessions.current if current.nil?
77
+
78
+ scope = sessions
79
+ scope = scope.where.not(id: current.id) if current.respond_to?(:id)
80
+ scope.each { |session| session.revoke!(reason: reason, by: by || self) }
81
+ true
82
+ end
83
+
84
+ # The admin hammer — the account-takeover response. Revokes EVERYTHING,
85
+ # including the session serving this request if it belongs to this user.
86
+ def revoke_all_sessions!(by: nil, reason: :admin_revoked)
87
+ sessions.each { |session| session.revoke!(reason: reason, by: by) }
88
+ true
89
+ end
90
+
91
+ private
92
+
93
+ def sessions_password_changed?
94
+ return false unless Sessions.config.revoke_on_password_change
95
+
96
+ PASSWORD_COLUMNS.any? do |column|
97
+ respond_to?(:"saved_change_to_#{column}?") && public_send(:"saved_change_to_#{column}?")
98
+ end
99
+ end
100
+
101
+ # ASVS 3.3.3 / 7.4.3: terminate other sessions on password change. The
102
+ # session performing the change survives — but only when it belongs to
103
+ # THIS user (an admin resetting someone's password keeps their own
104
+ # session, and the target loses all of theirs; a password reset by an
105
+ # anonymous visitor revokes everything — exactly Rails 8.1's own
106
+ # behavior, with events).
107
+ def sessions_revoke_others_on_password_change
108
+ Sessions.safely("revoke_on_password_change") do
109
+ current = Sessions.current
110
+ current = nil unless current && current.try(:user) == self
111
+
112
+ revoke_other_sessions!(current: current, by: self, reason: :password_change)
113
+ end
114
+ end
115
+ end
116
+ end