Files
prism/blog_email_confirmation_demo.rb

216 lines
14 KiB
Ruby

# 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."