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,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sessions
|
|
4
|
+
module Adapters
|
|
5
|
+
# OmniAuth integration.
|
|
6
|
+
#
|
|
7
|
+
# Successes need NO hook here: the OAuth callback always lands in an
|
|
8
|
+
# app-side controller that creates the session through whichever adapter
|
|
9
|
+
# is active, and the classifier sniffs `env["omniauth.auth"]` at that
|
|
10
|
+
# moment (→ docs/research/05-oauth.md §1.2).
|
|
11
|
+
#
|
|
12
|
+
# Failures are the part nobody records: every strategy failure funnels
|
|
13
|
+
# through the swappable `OmniAuth.config.on_failure` rack endpoint. We
|
|
14
|
+
# COMPOSE-wrap it (record, then call the original — Devise's dispatcher
|
|
15
|
+
# or OmniAuth's FailureEndpoint both keep working) from
|
|
16
|
+
# `config.after_initialize`, so it wraps whatever the app's own
|
|
17
|
+
# initializers installed. Captured: the error type symbol
|
|
18
|
+
# (:invalid_credentials, :access_denied = the user hit Cancel,
|
|
19
|
+
# :authenticity_error = CSRF), the provider, the originating page, and
|
|
20
|
+
# IP/UA. Not capturable (documented): which local user, and
|
|
21
|
+
# abandonments at the provider.
|
|
22
|
+
module Omniauth
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def install!
|
|
26
|
+
return if @installed
|
|
27
|
+
return unless defined?(::OmniAuth) && ::OmniAuth.respond_to?(:config)
|
|
28
|
+
|
|
29
|
+
@installed = true
|
|
30
|
+
original = ::OmniAuth.config.on_failure
|
|
31
|
+
::OmniAuth.config.on_failure = lambda do |env|
|
|
32
|
+
Sessions::Adapters::Omniauth.record_failure(env)
|
|
33
|
+
original.call(env)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def installed?
|
|
38
|
+
!!@installed
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def reset_installation!
|
|
42
|
+
@installed = false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def record_failure(env)
|
|
46
|
+
Sessions.safely("omniauth.failure") do
|
|
47
|
+
next unless Sessions.config.track_failed_logins
|
|
48
|
+
|
|
49
|
+
request = ActionDispatch::Request.new(env)
|
|
50
|
+
strategy = env["omniauth.error.strategy"]
|
|
51
|
+
provider = strategy.respond_to?(:name) ? strategy.name.to_s : nil
|
|
52
|
+
|
|
53
|
+
Sessions.record_failed_attempt(
|
|
54
|
+
request,
|
|
55
|
+
reason: env["omniauth.error.type"],
|
|
56
|
+
method: :oauth,
|
|
57
|
+
provider: Sessions::Classifier.normalize_provider(provider),
|
|
58
|
+
detail: { origin: env["omniauth.origin"] }.compact
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sessions
|
|
4
|
+
module Adapters
|
|
5
|
+
# The Devise/Warden adapter — four class-level Warden hooks, registered
|
|
6
|
+
# from the engine ONLY when `::Warden::Manager` is already loaded
|
|
7
|
+
# (Bundler.require precedes initializers, so the check is decisive; the
|
|
8
|
+
# gem never `require`s warden itself and stays inert in non-Warden apps).
|
|
9
|
+
#
|
|
10
|
+
# The revocation mechanism generalizes devise-security's proven
|
|
11
|
+
# `session_limitable` (a complete 55-line template whose only structural
|
|
12
|
+
# flaw is one-token-per-user): the token moves from a users-table column
|
|
13
|
+
# to a sessions-table ROW, turning "exactly one session" into "N devices,
|
|
14
|
+
# each individually revocable" (→ docs/research/04-devise-warden.md §5).
|
|
15
|
+
#
|
|
16
|
+
# login — mint a random token, store [row_id, raw_token] in the
|
|
17
|
+
# per-scope warden session (it survives Warden's :renew SID
|
|
18
|
+
# rotation and is deleted by Warden itself on logout; we
|
|
19
|
+
# never key on the Rack SID), persist only the SHA-256
|
|
20
|
+
# digest on the row.
|
|
21
|
+
# fetch — per-request liveness check: row exists + digest matches
|
|
22
|
+
# (constant-time) → throttled touch; row gone (revoked!) →
|
|
23
|
+
# the proven session_limitable kick: clear, logout, throw.
|
|
24
|
+
# failure — record the failed attempt with the typed identity.
|
|
25
|
+
# logout — destroy the row, labeled as a logout.
|
|
26
|
+
module Warden
|
|
27
|
+
# Key inside `warden.session(scope)` holding [row_id, raw_token].
|
|
28
|
+
SESSION_KEY = "sessions"
|
|
29
|
+
|
|
30
|
+
# Sticky per-scope flag: a login recorded with `sessions_skip: true`
|
|
31
|
+
# must not be kicked by the fetch validation later (session_limitable's
|
|
32
|
+
# third skip layer).
|
|
33
|
+
SKIP_SESSION_KEY = "sessions.skip"
|
|
34
|
+
|
|
35
|
+
# Request-wide skip: `request.env["sessions.skip"] = true`.
|
|
36
|
+
SKIP_ENV_KEY = "sessions.skip" # = Sessions::SKIP_ENV_KEY (set by Sessions.skip!)
|
|
37
|
+
|
|
38
|
+
# The `throw :warden` message on revoked sessions — Devise's failure
|
|
39
|
+
# app surfaces it like :timeout/:session_limited (add a
|
|
40
|
+
# `devise.failure.session_revoked` translation for custom copy).
|
|
41
|
+
THROW_MESSAGE = :session_revoked
|
|
42
|
+
|
|
43
|
+
module_function
|
|
44
|
+
|
|
45
|
+
def install!
|
|
46
|
+
return if @installed
|
|
47
|
+
|
|
48
|
+
@installed = true
|
|
49
|
+
|
|
50
|
+
::Warden::Manager.after_set_user(except: :fetch) do |record, warden, opts|
|
|
51
|
+
Sessions::Adapters::Warden.record_login(record, warden, opts)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
::Warden::Manager.after_set_user(only: :fetch) do |record, warden, opts|
|
|
55
|
+
Sessions::Adapters::Warden.validate_session(record, warden, opts)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
::Warden::Manager.before_failure do |env, opts|
|
|
59
|
+
Sessions::Adapters::Warden.record_failure(env, opts)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
::Warden::Manager.before_logout do |record, warden, opts|
|
|
63
|
+
Sessions::Adapters::Warden.record_logout(record, warden, opts)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Test seam.
|
|
68
|
+
def installed?
|
|
69
|
+
!!@installed
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def reset_installation!
|
|
73
|
+
@installed = false
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# --- Hook 1: any fresh login (form, remember-me, OmniAuth, sign-up
|
|
77
|
+
# auto-login, post-password-reset) ----------------------------------------
|
|
78
|
+
|
|
79
|
+
def record_login(record, warden, opts)
|
|
80
|
+
Sessions.safely("warden.login") do
|
|
81
|
+
scope = opts[:scope]
|
|
82
|
+
# Guard set lifted from Devise's own hooks. The `store: false`
|
|
83
|
+
# check is CRITICAL: token/HTTP-Basic strategies fire this hook on
|
|
84
|
+
# EVERY request with store: false — without it we'd mint a session
|
|
85
|
+
# row per API call.
|
|
86
|
+
next unless warden.authenticated?(scope)
|
|
87
|
+
next if opts[:store] == false
|
|
88
|
+
next if warden.request.env[SKIP_ENV_KEY]
|
|
89
|
+
next if record.respond_to?(:sessions_skip?) && record.sessions_skip?
|
|
90
|
+
# Reauthentication (sudo-style confirms) re-runs sign_in
|
|
91
|
+
# MID-SESSION — devise-passkeys' `reauthenticate` calls
|
|
92
|
+
# `sign_in(..., event: :passkey_reauthentication)` (see its
|
|
93
|
+
# controllers/reauthentication_controller_concern.rb), which fires
|
|
94
|
+
# after_set_user like any login. That's the same person proving
|
|
95
|
+
# presence on an already-tracked session, not a new device:
|
|
96
|
+
# minting a row here would orphan the live one mid-request.
|
|
97
|
+
next if opts[:event].to_s.match?(/reauth/i)
|
|
98
|
+
|
|
99
|
+
if opts[:sessions_skip]
|
|
100
|
+
warden.session(scope)[SKIP_SESSION_KEY] = true
|
|
101
|
+
next
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
next unless row_accepts?(record)
|
|
105
|
+
|
|
106
|
+
create_row_for(record, warden, scope)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def create_row_for(record, warden, scope, suppress_login_event: false)
|
|
111
|
+
token = Sessions.generate_token
|
|
112
|
+
request = warden.request
|
|
113
|
+
|
|
114
|
+
row = Sessions.session_model.new(
|
|
115
|
+
user: record,
|
|
116
|
+
scope: scope.to_s,
|
|
117
|
+
ip_address: Sessions::IpAddress.resolve(request),
|
|
118
|
+
user_agent: request.user_agent,
|
|
119
|
+
token_digest: Sessions.token_digest(token)
|
|
120
|
+
)
|
|
121
|
+
row.sessions_suppress_login_event = suppress_login_event
|
|
122
|
+
Sessions.with_request(request) { row.save! }
|
|
123
|
+
|
|
124
|
+
warden.session(scope)[SESSION_KEY] = [row.id, token]
|
|
125
|
+
row
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# --- Hook 2: per-request resume — validate, expire, touch ---------------
|
|
129
|
+
|
|
130
|
+
def validate_session(record, warden, opts)
|
|
131
|
+
scope = opts[:scope]
|
|
132
|
+
return if opts[:store] == false
|
|
133
|
+
return if warden.request.env[SKIP_ENV_KEY]
|
|
134
|
+
return if record.respond_to?(:sessions_skip?) && record.sessions_skip?
|
|
135
|
+
|
|
136
|
+
data = Sessions.safely("warden.fetch") do
|
|
137
|
+
session_data = warden.session(scope)
|
|
138
|
+
next :skip if session_data[SKIP_SESSION_KEY]
|
|
139
|
+
|
|
140
|
+
session_data[SESSION_KEY]
|
|
141
|
+
end
|
|
142
|
+
return if data == :skip
|
|
143
|
+
|
|
144
|
+
if data.nil?
|
|
145
|
+
adopt_preexisting_session(record, warden, scope)
|
|
146
|
+
return
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The lookup is NOT wrapped in `safely`: an ERRORED lookup and a
|
|
150
|
+
# MISSING row must be distinguishable. A row that's genuinely gone
|
|
151
|
+
# (or a token that doesn't match) means revocation → kick. A raised
|
|
152
|
+
# lookup — the sessions table unreachable, a timeout, a migration
|
|
153
|
+
# mid-deploy — means the TRACKING layer is down, and tracking must
|
|
154
|
+
# never break authentication: fail OPEN, let the request through
|
|
155
|
+
# untracked, try again next request.
|
|
156
|
+
begin
|
|
157
|
+
id, token = data
|
|
158
|
+
found = Sessions.session_model.find_by(id: id)
|
|
159
|
+
row = found if found&.sessions_token_matches?(token)
|
|
160
|
+
rescue StandardError => e
|
|
161
|
+
Sessions.warn("warden.fetch failed open: #{e.class}: #{e.message}")
|
|
162
|
+
return
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
if row.nil?
|
|
166
|
+
# Revoked (the row is gone) or tampered (digest mismatch): the
|
|
167
|
+
# proven session_limitable sequence — log the scope out and hand
|
|
168
|
+
# control to the failure app. NOT wrapped in `safely`: the throw
|
|
169
|
+
# is control flow, not an error.
|
|
170
|
+
kick!(warden, scope)
|
|
171
|
+
elsif Sessions.safely("warden.expired?") { row.sessions_expired? }
|
|
172
|
+
Sessions.safely("warden.expire") { row.revoke!(reason: :expired) }
|
|
173
|
+
kick!(warden, scope)
|
|
174
|
+
else
|
|
175
|
+
Sessions.safely("warden.touch") { row.touch_last_seen!(warden.request) }
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# A session that predates the gem (no token in the warden session):
|
|
180
|
+
# adopt it so existing logged-in users appear on their devices page
|
|
181
|
+
# right after deploy — a row is minted with `auth_method: "unknown"`
|
|
182
|
+
# and NO login event (adoption isn't a login; the trail stays honest).
|
|
183
|
+
# Never kicks anyone: adoption failures degrade to "untracked".
|
|
184
|
+
def adopt_preexisting_session(record, warden, scope)
|
|
185
|
+
Sessions.safely("warden.adopt") do
|
|
186
|
+
next unless row_accepts?(record)
|
|
187
|
+
|
|
188
|
+
row = create_row_for(record, warden, scope, suppress_login_event: true)
|
|
189
|
+
row&.update_columns(auth_detail: { "adopted" => true })
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# SCOPE-PRECISE teardown: only this scope's warden entries go (the
|
|
194
|
+
# serialized user key and our token stash) — an admin scope riding
|
|
195
|
+
# the same rack session, and unrelated host session data (carts,
|
|
196
|
+
# locale, return-to paths), survive a user-scope kick. Deleting the
|
|
197
|
+
# keys BEFORE logout matters: our before_logout hook then finds no
|
|
198
|
+
# token and records nothing (a kick is not a logout — the revocation
|
|
199
|
+
# event was already written by whoever destroyed the row).
|
|
200
|
+
def kick!(warden, scope)
|
|
201
|
+
warden.raw_session.delete("warden.user.#{scope}.key")
|
|
202
|
+
warden.raw_session.delete("warden.user.#{scope}.session")
|
|
203
|
+
warden.logout(scope)
|
|
204
|
+
throw :warden, scope: scope, message: THROW_MESSAGE
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# --- Hook 3: failed logins ------------------------------------------------
|
|
208
|
+
|
|
209
|
+
def record_failure(env, opts)
|
|
210
|
+
Sessions.safely("warden.failure") do
|
|
211
|
+
next unless Sessions.config.track_failed_logins
|
|
212
|
+
next if env[SKIP_ENV_KEY]
|
|
213
|
+
|
|
214
|
+
request = ActionDispatch::Request.new(env)
|
|
215
|
+
# `before_failure` fires for EVERY warden failure, including plain
|
|
216
|
+
# unauthenticated page-hits and timeouts. A real credential
|
|
217
|
+
# failure is a POST carrying the scope's credentials hash
|
|
218
|
+
# (→ research/04 §3). The password key is never read.
|
|
219
|
+
next unless request.post?
|
|
220
|
+
|
|
221
|
+
# Devise passes scope: explicitly in auth_options; a bare
|
|
222
|
+
# `warden.authenticate!` throws opts WITHOUT it — fall back to the
|
|
223
|
+
# stack's default scope, like Warden itself does.
|
|
224
|
+
scope = opts[:scope] || warden_default_scope(env)
|
|
225
|
+
credentials = request.params[scope.to_s]
|
|
226
|
+
next unless credentials.is_a?(Hash)
|
|
227
|
+
|
|
228
|
+
identity = credentials.values_at("email", "login", "username", "phone").compact.first
|
|
229
|
+
|
|
230
|
+
Sessions::Event.record_failure(
|
|
231
|
+
request,
|
|
232
|
+
scope: scope,
|
|
233
|
+
identity: identity,
|
|
234
|
+
# Devise's message symbol, verbatim — under paranoid mode this
|
|
235
|
+
# stays :invalid; we never infer (or leak) account existence.
|
|
236
|
+
reason: opts[:message],
|
|
237
|
+
metadata: { attempted_path: opts[:attempted_path] }.compact
|
|
238
|
+
)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# --- Hook 4: logout ---------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
# Fires once per scope (including forced logouts: timeout, lockout,
|
|
245
|
+
# our own revocation kick). If the row is already gone — revoked from
|
|
246
|
+
# another device — there's nothing to do; the `revoked` event was
|
|
247
|
+
# written by whoever destroyed it.
|
|
248
|
+
#
|
|
249
|
+
# CRITICAL: read the RAW session here, never `warden.session(scope)`.
|
|
250
|
+
# Warden's logout deletes `@users[scope]` BEFORE running before_logout
|
|
251
|
+
# callbacks (proxy.rb#logout), so Proxy#session's authenticated? check
|
|
252
|
+
# would re-deserialize the user → re-fire after_set_user → and when the
|
|
253
|
+
# logout came from a hook that logs out and throws (Devise's
|
|
254
|
+
# activatable on unconfirmed/locked accounts, timeoutable) that loops:
|
|
255
|
+
# activatable → logout → us → re-auth → activatable → … SystemStackError.
|
|
256
|
+
def record_logout(_record, warden, opts)
|
|
257
|
+
Sessions.safely("warden.logout") do
|
|
258
|
+
scope = opts[:scope]
|
|
259
|
+
data = warden.raw_session["warden.user.#{scope}.session"]&.dig(SESSION_KEY)
|
|
260
|
+
next unless data
|
|
261
|
+
|
|
262
|
+
id, token = data
|
|
263
|
+
row = Sessions.session_model.find_by(id: id)
|
|
264
|
+
next unless row&.sessions_token_matches?(token)
|
|
265
|
+
|
|
266
|
+
row.revocation_reason ||= :logout
|
|
267
|
+
row.destroy
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def warden_default_scope(env)
|
|
272
|
+
warden = env["warden"]
|
|
273
|
+
warden.respond_to?(:config) ? warden.config.default_scope : nil
|
|
274
|
+
rescue StandardError
|
|
275
|
+
nil
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Multi-scope safety: with a plain (non-polymorphic) `user`
|
|
279
|
+
# association, rows can only hold the matching class — a second Devise
|
|
280
|
+
# scope on another model stays silently untracked (re-run the install
|
|
281
|
+
# generator with --polymorphic to track every scope).
|
|
282
|
+
def row_accepts?(record)
|
|
283
|
+
reflection = Sessions.session_model.reflect_on_association(:user)
|
|
284
|
+
return false unless reflection
|
|
285
|
+
return true if reflection.polymorphic?
|
|
286
|
+
|
|
287
|
+
record.is_a?(reflection.klass)
|
|
288
|
+
rescue StandardError
|
|
289
|
+
false
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sessions
|
|
4
|
+
# Classifies HOW a session was started — password, OAuth (which provider),
|
|
5
|
+
# passkey, magic link… — from whatever signals the request carries at
|
|
6
|
+
# session-creation time. First match wins (→ docs/research/05-oauth.md):
|
|
7
|
+
#
|
|
8
|
+
# 1. An explicit `Sessions.tag(request, …)` (the universal escape hatch —
|
|
9
|
+
# One Tap, passkeys, custom SSO flows can't self-identify).
|
|
10
|
+
# 2. `env["omniauth.auth"]` — any OmniAuth callback, on either auth stack.
|
|
11
|
+
# 3. The winning Warden strategy class (Devise password and remember-me
|
|
12
|
+
# logins, devise-passwordless magic links, custom strategies via
|
|
13
|
+
# `config.strategy_methods`).
|
|
14
|
+
# 4. `flash[:google_sign_in]` — Basecamp's google_sign_in gem hands the
|
|
15
|
+
# id_token to the app through the flash.
|
|
16
|
+
# 5. A credentials POST (the omakase SessionsController#create shape and
|
|
17
|
+
# any custom password form: a password param was just exchanged for a
|
|
18
|
+
# session).
|
|
19
|
+
# 6. :unknown — never guess.
|
|
20
|
+
#
|
|
21
|
+
# Output: { method:, provider:, detail: } matching the auth_method /
|
|
22
|
+
# auth_provider / auth_detail columns. Methods are reserved for
|
|
23
|
+
# transport-distinct flows (Sign in with Apple is `oauth` + provider
|
|
24
|
+
# "apple", NOT its own method) so the taxonomy stays stable.
|
|
25
|
+
module Classifier
|
|
26
|
+
METHODS = %w[password oauth google_one_tap passkey magic_link otp sso token unknown].freeze
|
|
27
|
+
|
|
28
|
+
# The rack env key `Sessions.tag` writes.
|
|
29
|
+
TAG_ENV_KEY = "sessions.auth"
|
|
30
|
+
|
|
31
|
+
# Built-in Warden strategy → method mapping. Keys are matched as
|
|
32
|
+
# substrings of the strategy class name, so Devise's
|
|
33
|
+
# `Devise::Strategies::DatabaseAuthenticatable` and a host's custom
|
|
34
|
+
# subclass both classify. `config.strategy_methods` entries are
|
|
35
|
+
# consulted first and may override these.
|
|
36
|
+
#
|
|
37
|
+
# devise-two-factor is SINGLE-PHASE (password + OTP validated together
|
|
38
|
+
# in one strategy — its TwoFactorAuthenticatable SUBCLASSES Devise's
|
|
39
|
+
# DatabaseAuthenticatable and consumes params[scope]["otp_attempt"]
|
|
40
|
+
# before deferring to password validation, see devise-two-factor
|
|
41
|
+
# lib/devise_two_factor/strategies/two_factor_authenticatable.rb — so
|
|
42
|
+
# warden signs in once, at full auth). Its method is therefore
|
|
43
|
+
# :password — the second factor rides auth_detail (see from_warden).
|
|
44
|
+
#
|
|
45
|
+
# Passkey first-factor strategies classify as :passkey out of the box:
|
|
46
|
+
# devise-passkeys registers Devise::Strategies::PasskeyAuthenticatable
|
|
47
|
+
# (lib/devise/passkeys/strategy.rb) and its PasskeyReauthentication
|
|
48
|
+
# subclass; bare warden-webauthn registers Warden::WebAuthn::Strategy
|
|
49
|
+
# (lib/warden/webauthn/strategy.rb) — both names match by substring.
|
|
50
|
+
STRATEGY_METHODS = {
|
|
51
|
+
"DatabaseAuthenticatable" => :password,
|
|
52
|
+
"Rememberable" => :password,
|
|
53
|
+
"MagicLinkAuthenticatable" => :magic_link,
|
|
54
|
+
"TwoFactorAuthenticatable" => :password,
|
|
55
|
+
"TwoFactorBackupable" => :password,
|
|
56
|
+
"Passkey" => :passkey,
|
|
57
|
+
"WebAuthn" => :passkey
|
|
58
|
+
}.freeze
|
|
59
|
+
|
|
60
|
+
# OmniAuth strategy names normalized to recognizable providers
|
|
61
|
+
# ("google_oauth2" → "google"). Unlisted strategies pass through as-is.
|
|
62
|
+
PROVIDER_ALIASES = {
|
|
63
|
+
"google_oauth2" => "google",
|
|
64
|
+
"google_oauth2_hd" => "google",
|
|
65
|
+
"azure_activedirectory_v2" => "microsoft",
|
|
66
|
+
"microsoft_graph" => "microsoft"
|
|
67
|
+
}.freeze
|
|
68
|
+
|
|
69
|
+
module_function
|
|
70
|
+
|
|
71
|
+
# Never raises — classification is best-effort decoration on the login
|
|
72
|
+
# hot path; an exotic env degrades to :unknown.
|
|
73
|
+
def classify(request)
|
|
74
|
+
return blank if request.nil?
|
|
75
|
+
|
|
76
|
+
from_tag(request) ||
|
|
77
|
+
from_omniauth(request) ||
|
|
78
|
+
from_warden(request) ||
|
|
79
|
+
from_google_sign_in(request) ||
|
|
80
|
+
from_password_post(request) ||
|
|
81
|
+
blank
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
Sessions.warn("auth classification failed: #{e.class}: #{e.message}")
|
|
84
|
+
blank
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def blank
|
|
88
|
+
{ method: "unknown", provider: nil, detail: {} }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def from_tag(request)
|
|
92
|
+
tag = request.env[TAG_ENV_KEY]
|
|
93
|
+
return unless tag.is_a?(Hash)
|
|
94
|
+
|
|
95
|
+
{
|
|
96
|
+
method: normalize_method(tag[:method]),
|
|
97
|
+
provider: tag[:provider]&.to_s,
|
|
98
|
+
detail: (tag[:detail] || {}).to_h
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def from_omniauth(request)
|
|
103
|
+
auth = request.env["omniauth.auth"]
|
|
104
|
+
return unless auth
|
|
105
|
+
|
|
106
|
+
detail = {}
|
|
107
|
+
detail["origin"] = request.env["omniauth.origin"] if request.env["omniauth.origin"]
|
|
108
|
+
# AuthHash is a Hashie::Mash; plain hashes from tests work too.
|
|
109
|
+
credentials = auth["credentials"] if auth.respond_to?(:[])
|
|
110
|
+
detail["scopes"] = credentials["scope"] if credentials.respond_to?(:[]) && credentials["scope"]
|
|
111
|
+
info = auth["info"] if auth.respond_to?(:[])
|
|
112
|
+
detail["email_verified"] = info["email_verified"] if info.respond_to?(:[]) && !info["email_verified"].nil?
|
|
113
|
+
extra = auth["extra"] if auth.respond_to?(:[])
|
|
114
|
+
id_info = extra["id_info"] if extra.respond_to?(:[])
|
|
115
|
+
detail["hd"] = id_info["hd"] if id_info.respond_to?(:[]) && id_info["hd"]
|
|
116
|
+
|
|
117
|
+
{ method: "oauth", provider: normalize_provider(auth["provider"]), detail: detail }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def from_warden(request)
|
|
121
|
+
warden = request.env["warden"]
|
|
122
|
+
return unless warden.respond_to?(:winning_strategy)
|
|
123
|
+
|
|
124
|
+
strategy = warden.winning_strategy
|
|
125
|
+
return unless strategy
|
|
126
|
+
|
|
127
|
+
strategy_name = strategy.class.name.to_s
|
|
128
|
+
method = method_for_strategy(strategy_name)
|
|
129
|
+
return unless method
|
|
130
|
+
|
|
131
|
+
detail = {}
|
|
132
|
+
detail["remembered"] = true if strategy_name.include?("Rememberable")
|
|
133
|
+
|
|
134
|
+
# devise-two-factor: a backup-code win IS a second factor; the main
|
|
135
|
+
# strategy also serves users without 2FA, so the OTP only counts when
|
|
136
|
+
# an otp_attempt actually rode the request.
|
|
137
|
+
if strategy_name.include?("TwoFactorBackupable")
|
|
138
|
+
detail["second_factor"] = "backup_code"
|
|
139
|
+
elsif strategy_name.include?("TwoFactorAuthenticatable") && otp_attempted?(request)
|
|
140
|
+
detail["second_factor"] = "totp"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
{ method: method.to_s, provider: nil, detail: detail }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def otp_attempted?(request)
|
|
147
|
+
params = request.params
|
|
148
|
+
return true if params["otp_attempt"].present?
|
|
149
|
+
|
|
150
|
+
params.each_value.any? { |value| value.is_a?(Hash) && value["otp_attempt"].present? }
|
|
151
|
+
rescue StandardError
|
|
152
|
+
false
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def method_for_strategy(strategy_name)
|
|
156
|
+
Sessions.config.strategy_methods.merge(STRATEGY_METHODS).each do |substring, method|
|
|
157
|
+
return method if strategy_name.include?(substring)
|
|
158
|
+
end
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def from_google_sign_in(request)
|
|
163
|
+
flash = request.respond_to?(:flash) ? request.flash : nil
|
|
164
|
+
return unless flash && flash["google_sign_in"].present?
|
|
165
|
+
|
|
166
|
+
{ method: "oauth", provider: "google", detail: {} }
|
|
167
|
+
rescue StandardError
|
|
168
|
+
# Requests outside the Flash middleware (rack tests, API stacks) raise
|
|
169
|
+
# when the flash hash is unavailable — there's just no signal here.
|
|
170
|
+
nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# A POST that exchanged a password for a session IS a password login —
|
|
174
|
+
# covers the omakase SessionsController and hand-rolled password forms.
|
|
175
|
+
def from_password_post(request)
|
|
176
|
+
return unless request.respond_to?(:post?) && request.post?
|
|
177
|
+
|
|
178
|
+
params = request.params
|
|
179
|
+
return unless params.is_a?(Hash) || params.respond_to?(:[])
|
|
180
|
+
return unless password_param?(params)
|
|
181
|
+
|
|
182
|
+
{ method: "password", provider: nil, detail: {} }
|
|
183
|
+
rescue StandardError
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def password_param?(params)
|
|
188
|
+
return true if params["password"].present?
|
|
189
|
+
|
|
190
|
+
# Devise nests credentials under the scope: user[password]
|
|
191
|
+
params.each_value.any? { |value| value.is_a?(Hash) && value["password"].present? }
|
|
192
|
+
rescue StandardError
|
|
193
|
+
false
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def normalize_method(method)
|
|
197
|
+
name = method.to_s
|
|
198
|
+
METHODS.include?(name) ? name : name.presence || "unknown"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def normalize_provider(provider)
|
|
202
|
+
return nil if provider.nil?
|
|
203
|
+
|
|
204
|
+
name = provider.to_s
|
|
205
|
+
PROVIDER_ALIASES.fetch(name, name)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|