From 8f9ef2073e0f6d12aab0bc3638269df9257c4650 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 1 Sep 2026 21:17:07 +0200 Subject: [PATCH] feat: add cookie_from and csrf_token_from --- blog_email_confirmation_demo.rb | 215 ++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 blog_email_confirmation_demo.rb diff --git a/blog_email_confirmation_demo.rb b/blog_email_confirmation_demo.rb new file mode 100644 index 0000000..bfde1bd --- /dev/null +++ b/blog_email_confirmation_demo.rb @@ -0,0 +1,215 @@ +# End-to-end proof of EmailConfirmationsController (this session's business +# use of Prism::Orm::TokenFor, already generic and tested in isolation by +# test_orm_token_for.rb/test_message_verifier.rb since sessions 100/101) - +# through real HTTP against the actual blog app, not just User#confirmed? +# read in a REPL. Own scratch database directory and own rate limiter +# store, same pattern as blog_password_reset_demo.rb, so this never shares +# data or a throttle budget with blog_demo.rb or any other standalone demo. +$LOAD_PATH.unshift(File.expand_path('lib', __dir__)) +require 'prism' +require 'net/http' +require 'fileutils' + +ROOT = __dir__ +SCRATCH_DB_DIR = File.join(ROOT, 'examples/blog/db/email_confirmation_demo') +FileUtils.rm_rf(SCRATCH_DB_DIR) +ENV['PRISM_BLOG_DB_DIR'] = SCRATCH_DB_DIR + +require_relative 'examples/blog/app' + +server = Prism::Server.new(Blog, port: 0).start +port = server.port +puts "blog_email_confirmation_demo server on port #{port}" +puts '-' * 40 + +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] || html[/name="authenticity_token" value="([^"]+)"/, 1] +end + +def confirmation_path_from(delivery) + path = delivery.html_body[%r{href="http://[^"]*(/email_confirmations/[^"]+)"}, 1] + raise 'expected an absolute confirmation link in the delivered email body' unless path + path +end + +CAROL_EMAIL = 'carol@example.com' +CAROL_NEW_EMAIL = 'carol.new@example.com' +CAROL_PASSWORD = 'correct horse battery staple' +worker = Prism::Jobs::Worker.new(Prism::Jobs.default_queue) + +Net::HTTP.start('127.0.0.1', port) do |http| + new_session_page = http.get('/session/new') + cookie = cookie_from(new_session_page) + token = csrf_token_from(new_session_page.body) + signup_body = URI.encode_www_form( + 'user[email]' => CAROL_EMAIL, 'user[password]' => CAROL_PASSWORD, + 'user[password_confirmation]' => CAROL_PASSWORD, 'authenticity_token' => token + ) + signup = http.post('/users', signup_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie) + raise "expected sign-up to redirect, got #{signup.code}" unless signup.code == '302' + cookie = cookie_from(signup) unless cookie_from(signup).empty? + puts "POST /users (sign up #{CAROL_EMAIL}) -> #{signup.code}" + + worker.work_off + raise 'expected exactly 2 emails from signup (welcome + confirmation instructions)' unless Prism::Mailer.deliveries.size == 2 + raise 'expected the freshly signed-up account to start out unconfirmed' if User.find_by(email: CAROL_EMAIL).confirmed? + puts "worker drained signup's 2 emails; User#confirmed? -> false right after signup" + + puts '-' * 40 + puts 'requesting a fresh confirmation link via the resend form (own account is unconfirmed) - POST #1/3 of the ' \ + 'email_confirmations/ip budget' + Prism::Mailer.deliveries.clear + resend_page = http.get('/email_confirmations/new') + raise "expected GET /email_confirmations/new to render, got #{resend_page.code}" unless resend_page.code == '200' + resend_cookie = cookie_from(resend_page) + resend_token = csrf_token_from(resend_page.body) + + resend_body = URI.encode_www_form('email_confirmation[email]' => CAROL_EMAIL, 'authenticity_token' => resend_token) + resent = http.post('/email_confirmations', resend_body, 'Content-Type' => 'application/x-www-form-urlencoded', + 'Cookie' => resend_cookie) + raise "expected POST /email_confirmations to redirect, got #{resent.code}" unless resent.code == '302' + raise 'expected the redirect to land on the sign-in page' unless resent['location'] == '/session/new' + puts "POST /email_confirmations (email=#{CAROL_EMAIL}) -> #{resent.code}, redirected to #{resent['location']}" + + worker.work_off + raise 'expected exactly one confirmation email from the resend' unless Prism::Mailer.deliveries.size == 1 + delivery = Prism::Mailer.deliveries.last + raise "expected the delivery to go to #{CAROL_EMAIL}, got #{delivery.to.inspect}" unless delivery.to == [CAROL_EMAIL] + confirmation_path = confirmation_path_from(delivery) + # The path segment itself is URL-encoded (the token's own base64 padding + # bytes need escaping in an href) - decode it before comparing against a + # freshly minted token below, which comes back from Ruby unencoded. + mailed_token = URI.decode_www_form_component(confirmation_path.split('/').last) + puts "worker processed the resend job; mailed confirmation link -> #{confirmation_path}" + + puts '-' * 40 + puts 'the envelope is a pure function of id+fingerprint(email)+purpose with no expires_in set (unlike ' \ + 'password_reset), so minting a token for the SAME still-unconfirmed account a second time - here done ' \ + 'directly through the model, not by spending another throttled HTTP request - is byte-identical to the ' \ + 'one that was just mailed, not a fresh, different-looking token' + regenerated_token = User.find_by(email: CAROL_EMAIL).generate_token_for(:email_confirmation) + raise 'expected re-minting the token for the same unconfirmed account to be byte-identical' unless regenerated_token == mailed_token + puts ' User#generate_token_for(:email_confirmation) again -> identical token bytes' + + puts '-' * 40 + puts 'following the mailed link confirms the account' + confirm_page = http.get(confirmation_path) + raise "expected the confirmation link to redirect, got #{confirm_page.code}" unless confirm_page.code == '302' + raise 'expected confirming to redirect to the homepage' unless confirm_page['location'] == '/' + puts "GET #{confirmation_path} -> #{confirm_page.code} Location=#{confirm_page['location']}" + raise 'expected User#confirmed? to flip to true' unless User.find_by(email: CAROL_EMAIL).confirmed? + puts ' User#confirmed? -> true' + + puts '-' * 40 + puts 'revisiting the EXACT SAME confirmation link a second time is harmless (302 to the homepage again) - the ' \ + 'defining difference from PasswordResetsController: that token fingerprints password_digest, which the ' \ + 'reset action itself changes, so a reset link dies after one use; this token fingerprints email, which ' \ + 'confirming does not touch, so the same link keeps confirming (a no-op past the first time) instead of dying' + replayed_confirm = http.get(confirmation_path) + raise "expected the replayed link to still redirect to '/', got #{replayed_confirm.code}/#{replayed_confirm['location']}" \ + unless replayed_confirm.code == '302' && replayed_confirm['location'] == '/' + puts " GET #{confirmation_path} again -> #{replayed_confirm.code} Location=#{replayed_confirm['location']} " \ + '(still alive, not a dead link)' + + puts '-' * 40 + puts "resending for #{CAROL_EMAIL} once ALREADY confirmed sends no mail at all - same enumeration-safe redirect " \ + 'either way, exactly like PasswordResetsController#create for an unknown address, but the branch here is ' \ + '"already confirmed" instead of "no such account". POST #2/3 of the budget.' + no_mail_resend_body = URI.encode_www_form('email_confirmation[email]' => CAROL_EMAIL, 'authenticity_token' => resend_token) + no_mail_resend = http.post('/email_confirmations', no_mail_resend_body, 'Content-Type' => 'application/x-www-form-urlencoded', + 'Cookie' => resend_cookie) + raise "expected the resend-while-confirmed to still redirect, got #{no_mail_resend.code}" unless no_mail_resend.code == '302' + worker.work_off + raise 'expected zero emails for an already-confirmed resend' unless Prism::Mailer.deliveries.size == 1 + puts "POST /email_confirmations (email=#{CAROL_EMAIL}, already confirmed) -> #{no_mail_resend.code}, no new mail sent" + + puts '-' * 40 + puts 'requesting a resend for an email that has NO account - identical response, no enumeration oracle. ' \ + 'POST #3/3 of the budget.' + unknown_body = URI.encode_www_form('email_confirmation[email]' => 'nobody@example.com', 'authenticity_token' => resend_token) + unknown = http.post('/email_confirmations', unknown_body, 'Content-Type' => 'application/x-www-form-urlencoded', + 'Cookie' => resend_cookie) + raise "expected the SAME 302/location for an unknown email, got #{unknown.code}/#{unknown['location']}" unless + unknown.code == '302' && unknown['location'] == resent['location'] + worker.work_off + raise 'expected zero emails for the unknown address' unless Prism::Mailer.deliveries.size == 1 + puts "POST /email_confirmations (unknown email) -> #{unknown.code}, identical redirect, no email actually sent" + + puts '-' * 40 + puts 'a garbage/unknown token redirects to the resend form instead of confirming anything (a GET, not throttled)' + garbage = http.get('/email_confirmations/not-a-real-token') + raise "expected a garbage token to redirect, got #{garbage.code}" unless garbage.code == '302' + raise 'expected a garbage token to redirect to the resend form' unless garbage['location'] == '/email_confirmations/new' + puts "GET /email_confirmations/not-a-real-token -> #{garbage.code} Location=#{garbage['location']}" + + puts '-' * 40 + puts "checking the email_confirmations/ip throttle (app.rb, limit 3/60s) - can't be hammered into an email-bomb. " \ + 'The 3 POSTs above (real resend, already-confirmed resend, unknown-email resend) already spent the whole ' \ + 'budget, so this 4th one from the same IP must be rejected outright.' + throttled = http.post('/email_confirmations', unknown_body, 'Content-Type' => 'application/x-www-form-urlencoded', + 'Cookie' => resend_cookie) + raise "expected this 4th request to be throttled (429), got #{throttled.code}" unless throttled.code == '429' + raise 'the throttled attempt should carry a Retry-After header' if throttled['retry-after'].nil? + puts " POST /email_confirmations (4th, budget exhausted) -> #{throttled.code} (throttled, Retry-After: #{throttled['retry-after']})" + + puts '-' * 40 + puts 'reconfirmable: signing in and changing to a new address un-confirms the account (Devise-style ' \ + 'reconfirmable), and the OLD confirmation link stops working even though it is otherwise still well-formed' + sign_in_page = http.get('/session/new') + sign_in_cookie = cookie_from(sign_in_page) + sign_in_token = csrf_token_from(sign_in_page.body) + sign_in_body = URI.encode_www_form('user[email]' => CAROL_EMAIL, 'user[password]' => CAROL_PASSWORD, + 'authenticity_token' => sign_in_token) + signed_in = http.post('/session', sign_in_body, 'Content-Type' => 'application/x-www-form-urlencoded', + 'Cookie' => sign_in_cookie) + raise "expected sign-in to succeed, got #{signed_in.code}" unless signed_in.code == '302' + session_cookie = cookie_from(signed_in) + session_cookie = sign_in_cookie if session_cookie.empty? + puts "POST /session (#{CAROL_EMAIL}) -> #{signed_in.code}, signed in" + + edit_account_page = http.get('/account/edit', 'Cookie' => session_cookie) + raise "expected GET /account/edit to render, got #{edit_account_page.code}" unless edit_account_page.code == '200' + account_cookie = cookie_from(edit_account_page) + account_cookie = session_cookie if account_cookie.empty? + account_token = csrf_token_from(edit_account_page.body) + + change_email_body = URI.encode_www_form('_method' => 'PATCH', 'user[email]' => CAROL_NEW_EMAIL, + 'authenticity_token' => account_token) + changed = http.post('/account', change_email_body, 'Content-Type' => 'application/x-www-form-urlencoded', + 'Cookie' => account_cookie) + raise "expected the email change to redirect, got #{changed.code}" unless changed.code == '302' + puts "PATCH /account (email -> #{CAROL_NEW_EMAIL}) -> #{changed.code}" + raise 'expected changing email to un-confirm the account' if User.find_by(email: CAROL_NEW_EMAIL).confirmed? + puts ' User#confirmed? -> false again (email changed, reset_confirmation_if_email_changed fired)' + + stale_confirm = http.get(confirmation_path) + raise "expected the OLD confirmation link to now be rejected, got #{stale_confirm.code}/#{stale_confirm['location']}" \ + unless stale_confirm.code == '302' && stale_confirm['location'] == '/email_confirmations/new' + puts " GET #{confirmation_path} (minted for the OLD address) -> #{stale_confirm.code} " \ + "Location=#{stale_confirm['location']} (fingerprint mismatch: token carries the old email, account now " \ + 'holds the new one)' + + # Minted directly through the model, not via another throttled POST + # /email_confirmations - the budget above is already spent for this demo + # process' one IP, and minting is exactly what that endpoint does + # internally anyway (see EmailConfirmationsController#create). + fresh_token = User.find_by(email: CAROL_NEW_EMAIL).generate_token_for(:email_confirmation) + fresh_path = "/email_confirmations/#{fresh_token}" + fresh_confirm = http.get(fresh_path) + raise "expected a token minted for the NEW address to confirm, got #{fresh_confirm.code}" \ + unless fresh_confirm.code == '302' && fresh_confirm['location'] == '/' + raise 'expected User#confirmed? to be true again under the new address' unless User.find_by(email: CAROL_NEW_EMAIL).confirmed? + puts " GET #{fresh_path} (minted directly for #{CAROL_NEW_EMAIL}) -> #{fresh_confirm.code}, confirmed again" +end + +puts '-' * 40 +puts 'blog_email_confirmation_demo done: confirmation instructions are mailed via the real background job queue ' \ + 'right after sign-up, the resend form is enumeration-safe (no mail for an already-confirmed or unknown ' \ + 'address) and throttled, the mailed link confirms the account and stays valid on repeat visits (unlike a ' \ + 'password reset link), and changing to a new address un-confirms the account and invalidates the OLD ' \ + "address's token while a freshly minted one for the new address still works."