Hold TOTP enrollment secret server-side and email user on activation

TOTP enrollment previously round-tripped the generated secret through a
hidden form field and saved whatever the client submitted, letting an
attacker with session access enroll a 2FA device they control by posting
their own secret plus a matching code. Stash the secret in the session
at GET /totp/new, read it only from the session at POST /totp, and drop
the hidden field from the view. Notify the user by email on successful
enrollment so unauthorized activations are visible even if a new vector
appears later.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
Dan Milne
2026-04-20 18:17:50 +10:00
parent 93d8381214
commit b876e02c3a
7 changed files with 138 additions and 3 deletions

View File

@@ -12,6 +12,10 @@ class TotpController < ApplicationController
@totp_secret = ROTP::Base32.random
@provisioning_uri = ROTP::TOTP.new(@totp_secret, issuer: "Clinch").provisioning_uri(@user.email_address)
# Hold the secret server-side until the user confirms it with a valid code.
# The view no longer round-trips it through a hidden form field.
session[:pending_totp_secret] = @totp_secret
# Generate QR code
require "rqrcode"
@qr_code = RQRCode::QRCode.new(@provisioning_uri)
@@ -19,9 +23,14 @@ class TotpController < ApplicationController
# POST /totp - Verify TOTP code and enable 2FA
def create
totp_secret = params[:totp_secret]
totp_secret = session[:pending_totp_secret]
code = params[:code]
unless totp_secret
redirect_to new_totp_path, alert: "Your TOTP setup session expired. Please start again."
return
end
# Verify the code works
totp = ROTP::TOTP.new(totp_secret)
if totp.verify(code, drift_behind: 30, drift_ahead: 30)
@@ -30,6 +39,10 @@ class TotpController < ApplicationController
plain_codes = @user.send(:generate_backup_codes) # Use private method from User model
@user.save!
# Consume the pending secret and notify the user that 2FA is now active
session.delete(:pending_totp_secret)
TotpMailer.enabled(@user).deliver_later
# Store plain codes temporarily in session for display after redirect
session[:temp_backup_codes] = plain_codes