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

View File

@@ -0,0 +1,7 @@
class TotpMailer < ApplicationMailer
def enabled(user)
@user = user
mail subject: "Two-factor authentication enabled on your account",
to: user.email_address
end
end

View File

@@ -35,8 +35,6 @@
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">Step 2: Verify</h3>
<%= form_with url: totp_path, method: :post, class: "space-y-4" do |form| %>
<%= hidden_field_tag :totp_secret, @totp_secret %>
<div>
<%= label_tag :code, "Verification Code", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= text_field_tag :code,

View File

@@ -0,0 +1,16 @@
<p>Hello,</p>
<p>
Two-factor authentication was just enabled on the Clinch account for
<strong><%= @user.email_address %></strong>.
</p>
<p>
If you did this, you can ignore this email.
</p>
<p>
If you did <strong>not</strong> do this, your account may have been
accessed by someone else. Reset your password immediately and contact
your administrator.
</p>

View File

@@ -0,0 +1,9 @@
Hello,
Two-factor authentication was just enabled on the Clinch account for
<%= @user.email_address %>.
If you did this, you can ignore this email.
If you did NOT do this, your account may have been accessed by someone
else. Reset your password immediately and contact your administrator.