Files
prism/blog_session_secret_rotation_worker.rb

140 lines
7.2 KiB
Ruby

# 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