feat: add cookie_from and csrf_token_from
This commit is contained in:
1 parent
bcfebe6a51
commit
40bbd83458
1 file changed
+139
@@ -0,0 +1,139 @@
|
|||||||
|
# Runs ONE phase of the SESSION secret rotation lifecycle against
|
||||||
|
# examples/blog, over a real HTTP request cycle. Always invoked as a brand
|
||||||
|
# new `ruby` process by blog_session_secret_rotation_demo.rb (never required
|
||||||
|
# directly) - Application#enable_sessions reads PRISM_BLOG_SESSION_SECRET/
|
||||||
|
# PRISM_BLOG_SESSION_PREVIOUS_SECRETS once at boot, so rotating them for real
|
||||||
|
# needs a fresh process, not just a different in-process config call.
|
||||||
|
#
|
||||||
|
# Mirrors blog_rotation_worker.rb's three-phase shape (provision -> rotate
|
||||||
|
# -> retire), but for Prism::Middleware::Session/Prism::CookieJar's
|
||||||
|
# `previous_secrets:` instead of Prism::Orm::Encryption's `previous_keys:`.
|
||||||
|
# The key difference this phase split deliberately surfaces: an encrypted
|
||||||
|
# attribute migrates onto the new key the moment its row is next SAVED,
|
||||||
|
# while a session cookie only gets re-signed onto the new secret when the
|
||||||
|
# session itself is next MUTATED - a page view alone (no session write)
|
||||||
|
# leaves the cookie signed under whatever secret was current when it was
|
||||||
|
# last written. User A creates a post during the transition window (a real
|
||||||
|
# session write, via the flash notice it sets) and survives full cutover;
|
||||||
|
# user B never does anything else after signing up and is correctly logged
|
||||||
|
# out once the secret they were signed in under is fully retired.
|
||||||
|
$LOAD_PATH.unshift(File.expand_path('lib', __dir__))
|
||||||
|
require 'prism'
|
||||||
|
require 'net/http'
|
||||||
|
require 'fileutils'
|
||||||
|
require_relative 'examples/blog/app'
|
||||||
|
|
||||||
|
COOKIE_A_V1_PATH = File.join(DB_DIR, 'cookie_a_v1.txt')
|
||||||
|
COOKIE_B_V1_PATH = File.join(DB_DIR, 'cookie_b_v1.txt')
|
||||||
|
COOKIE_A_V2_PATH = File.join(DB_DIR, 'cookie_a_v2.txt')
|
||||||
|
|
||||||
|
EMAIL_A = 'rotation-a@example.com'.freeze
|
||||||
|
EMAIL_B = 'rotation-b@example.com'.freeze
|
||||||
|
PASSWORD = 'correct horse battery staple v2'.freeze
|
||||||
|
|
||||||
|
def cookie_from(response)
|
||||||
|
lines = response.get_fields('set-cookie') || []
|
||||||
|
lines.map { |line| line.split(';').first }.join('; ')
|
||||||
|
end
|
||||||
|
|
||||||
|
def csrf_token_from(html)
|
||||||
|
html[/name="csrf-token" content="([^"]+)"/, 1]
|
||||||
|
end
|
||||||
|
|
||||||
|
def sign_up(http, email)
|
||||||
|
home = http.get('/')
|
||||||
|
cookie = cookie_from(home)
|
||||||
|
token = csrf_token_from(home.body)
|
||||||
|
body = URI.encode_www_form(
|
||||||
|
'user[email]' => email, 'user[password]' => PASSWORD, 'user[password_confirmation]' => PASSWORD,
|
||||||
|
'authenticity_token' => token
|
||||||
|
)
|
||||||
|
response = http.post('/users', body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie)
|
||||||
|
raise "expected sign-up for #{email} to redirect, got #{response.code}" unless response.code == '302'
|
||||||
|
cookie_from(response)
|
||||||
|
end
|
||||||
|
|
||||||
|
phase = ARGV[0]
|
||||||
|
|
||||||
|
server = Prism::Server.new(Blog, port: 0).start
|
||||||
|
port = server.port
|
||||||
|
|
||||||
|
case phase
|
||||||
|
when 'signup'
|
||||||
|
Net::HTTP.start('127.0.0.1', port) do |http|
|
||||||
|
File.write(COOKIE_A_V1_PATH, sign_up(http, EMAIL_A))
|
||||||
|
puts 'signup: user A signed up and signed in under v1 - will write to their session during the rotation window'
|
||||||
|
|
||||||
|
File.write(COOKIE_B_V1_PATH, sign_up(http, EMAIL_B))
|
||||||
|
puts 'signup: user B signed up and signed in under v1 too - deliberately left untouched from here on, stranded on v1'
|
||||||
|
end
|
||||||
|
when 'rotate'
|
||||||
|
Net::HTTP.start('127.0.0.1', port) do |http|
|
||||||
|
cookie_a = File.read(COOKIE_A_V1_PATH).strip
|
||||||
|
cookie_b = File.read(COOKIE_B_V1_PATH).strip
|
||||||
|
|
||||||
|
# The app is now running with v2 as the CURRENT session secret and v1
|
||||||
|
# only as a previous_secrets fallback - both users' cookies were never
|
||||||
|
# touched since 'signup' wrote them under v1, so both still working here
|
||||||
|
# is the real proof that Middleware::Session's rotation fallback holds
|
||||||
|
# over a genuine request cycle, not just a unit-test fixture.
|
||||||
|
edit_a = http.get('/account/edit', 'Cookie' => cookie_a)
|
||||||
|
raise "expected user A's v1-signed session to still authenticate via previous_secrets, got #{edit_a.code}" unless edit_a.code == '200'
|
||||||
|
|
||||||
|
edit_b = http.get('/account/edit', 'Cookie' => cookie_b)
|
||||||
|
raise "expected user B's v1-signed session to still authenticate via previous_secrets, got #{edit_b.code}" unless edit_b.code == '200'
|
||||||
|
puts 'rotate: both v1-signed sessions still authenticate under v2-current/v1-previous'
|
||||||
|
|
||||||
|
# Creating a post is a real session WRITE (it sets flash[:notice] before
|
||||||
|
# redirecting) - the natural way an ordinary user's session actually
|
||||||
|
# gets re-signed onto the current secret, exactly as
|
||||||
|
# Prism::Session.decode's rotation comment describes ("the next time it
|
||||||
|
# changes at all"). Unlike Orm::Encryption there is no explicit "resave"
|
||||||
|
# step to call; any mutating request does it for free.
|
||||||
|
edit_token = csrf_token_from(edit_a.body)
|
||||||
|
create_body = URI.encode_www_form(
|
||||||
|
'post[title]' => 'Written during the secret rotation window',
|
||||||
|
'post[body]' => 'This post exists to force a session write while v2 is current.',
|
||||||
|
'authenticity_token' => edit_token
|
||||||
|
)
|
||||||
|
created = http.post('/posts', create_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie_a)
|
||||||
|
raise "expected creating a post to redirect, got #{created.code}" unless created.code == '302'
|
||||||
|
|
||||||
|
cookie_a_v2 = cookie_from(created)
|
||||||
|
raise 'expected the flash-notice write to actually re-sign the session cookie' if cookie_a_v2.empty?
|
||||||
|
raise 'expected the re-signed cookie to differ from the original v1-signed one' if cookie_a_v2 == cookie_a
|
||||||
|
File.write(COOKIE_A_V2_PATH, cookie_a_v2)
|
||||||
|
puts 'rotate: user A wrote to their session (created a post) and got re-signed onto v2 in the same response'
|
||||||
|
|
||||||
|
# Deliberately NOT touched again: user B's cookie stays exactly as
|
||||||
|
# 'signup' left it, still signed under v1 only.
|
||||||
|
puts 'rotate: user B deliberately left untouched (still v1-signed on the wire)'
|
||||||
|
end
|
||||||
|
when 'post_rotation_final'
|
||||||
|
Net::HTTP.start('127.0.0.1', port) do |http|
|
||||||
|
cookie_a_v2 = File.read(COOKIE_A_V2_PATH).strip
|
||||||
|
cookie_b_v1 = File.read(COOKIE_B_V1_PATH).strip
|
||||||
|
|
||||||
|
# v1 has been fully retired (PRISM_BLOG_SESSION_PREVIOUS_SECRETS is
|
||||||
|
# unset for this phase) - user A's session, re-signed onto v2 during the
|
||||||
|
# 'rotate' phase, must still authenticate with nothing but v2 configured.
|
||||||
|
edit_a = http.get('/account/edit', 'Cookie' => cookie_a_v2)
|
||||||
|
raise "expected user A's rotated (v2-signed) session to survive full cutover, got #{edit_a.code}" unless edit_a.code == '200'
|
||||||
|
puts 'post_rotation_final: user A (session written during the transition window) still authenticated with v1 fully retired'
|
||||||
|
|
||||||
|
# The risk this whole scenario documents: a session nobody ever wrote to
|
||||||
|
# again during the transition window is still signed under the secret
|
||||||
|
# that just got fully retired - Prism::Session.decode has nothing left
|
||||||
|
# that verifies it, so it silently comes back as an empty session (not
|
||||||
|
# a crash), and the very next protected page redirects to the login
|
||||||
|
# screen exactly as it would for any other signed-out visitor.
|
||||||
|
edit_b = http.get('/account/edit', 'Cookie' => cookie_b_v1)
|
||||||
|
raise "expected user B's never-rewritten v1-only session to be logged out after full cutover, got #{edit_b.code}" unless edit_b.code == '302'
|
||||||
|
raise 'expected the logged-out redirect to point at the login page' unless edit_b['Location'].to_s.end_with?('/session/new')
|
||||||
|
puts 'post_rotation_final: user B (never wrote to their session again) correctly logged out after v1 was fully retired'
|
||||||
|
end
|
||||||
|
else
|
||||||
|
raise "unknown phase #{phase.inspect} (expected signup, rotate, or post_rotation_final)"
|
||||||
|
end
|
||||||
|
|
||||||
|
server.stop
|
||||||
Reference in new issue
Block a user