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