Skip to content

Disallow confirmation token reuse #98

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/controllers/concerns/authentication.rb
Original file line number Diff line number Diff line change
@@ -53,6 +53,6 @@ def user_signed_in?
end

def store_location
session[:user_return_to] = request.original_url if request.get? && request.local?
session[:user_return_to] = request.original_url if request.get?
end
end
11 changes: 10 additions & 1 deletion app/controllers/confirmations_controller.rb
Original file line number Diff line number Diff line change
@@ -13,7 +13,16 @@ def create
end

def edit
@user = User.find_signed(params[:confirmation_token], purpose: :confirm_email)
expected_email = params[:email].strip.downcase

# It's possible that the email address that we're confirming is the
# primary "email" field or the secondary "unconfirmed_email" field.
# We need to check both fields to find the user, but will only check
# the primary field if the secondary field is null.
@user = (User.where(unconfirmed_email: expected_email).
or(User.where(email: expected_email, unconfirmed_email: nil))).
find_signed(params[:confirmation_token],
purpose: User.email_confirmation_purpose_for(expected_email))
if @user.present? && @user.unconfirmed_or_reconfirming?
if @user.confirm!
login @user
3 changes: 2 additions & 1 deletion app/mailers/user_mailer.rb
Original file line number Diff line number Diff line change
@@ -6,9 +6,10 @@ class UserMailer < ApplicationMailer
#
# en.user_mailer.confirmation.subject
#
def confirmation(user, confirmation_token)
def confirmation(user, confirmation_token, confirmable_email)
@user = user
@confirmation_token = confirmation_token
@confirmable_email = confirmable_email

mail to: @user.confirmable_email, subject: "Confirmation Instructions"
end
13 changes: 11 additions & 2 deletions app/models/user.rb
Original file line number Diff line number Diff line change
@@ -53,8 +53,17 @@ def confirmable_email
end
end

def self.email_confirmation_purpose_for(email)
"confirm_email: #{email}"
end

def email_confirmation_purpose
self.class.email_confirmation_purpose_for(confirmable_email)
end

def generate_confirmation_token
signed_id expires_in: CONFIRMATION_TOKEN_EXPIRATION, purpose: :confirm_email
signed_id expires_in: CONFIRMATION_TOKEN_EXPIRATION,
purpose: email_confirmation_purpose
end

def generate_password_reset_token
@@ -63,7 +72,7 @@ def generate_password_reset_token

def send_confirmation_email!
confirmation_token = generate_confirmation_token
UserMailer.confirmation(self, confirmation_token).deliver_now
UserMailer.confirmation(self, confirmation_token, confirmable_email).deliver_now
end

def send_password_reset_email!
2 changes: 1 addition & 1 deletion app/views/user_mailer/confirmation.html.erb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<h1>Confirmation Instructions</h1>

<%= link_to "Click here to confirm your email.", edit_confirmation_url(@confirmation_token) %>
<%= link_to "Click here to confirm your email.", edit_confirmation_url(@confirmation_token, email: @confirmable_email) %>
2 changes: 1 addition & 1 deletion app/views/user_mailer/confirmation.text.erb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
Confirmation Instructions

<%= edit_confirmation_url(@confirmation_token) %>
<%= edit_confirmation_url(@confirmation_token, email: @confirmable_email) %>
35 changes: 28 additions & 7 deletions test/controllers/confirmations_controller_test.rb
Original file line number Diff line number Diff line change
@@ -11,7 +11,7 @@ class ConfirmationsControllerTest < ActionDispatch::IntegrationTest
freeze_time do
confirmation_token = @unconfirmed_user.generate_confirmation_token

get edit_confirmation_path(confirmation_token)
get edit_confirmation_path(confirmation_token, email: @unconfirmed_user.confirmable_email)

assert @unconfirmed_user.reload.confirmed?
assert_equal Time.now, @unconfirmed_user.confirmed_at
@@ -26,7 +26,7 @@ class ConfirmationsControllerTest < ActionDispatch::IntegrationTest
freeze_time do
confirmation_token = @reconfirmed_user.generate_confirmation_token

get edit_confirmation_path(confirmation_token)
get edit_confirmation_path(confirmation_token, email: unconfirmed_email)

assert @reconfirmed_user.reload.confirmed?
assert_equal Time.current, @reconfirmed_user.reload.confirmed_at
@@ -44,7 +44,7 @@ class ConfirmationsControllerTest < ActionDispatch::IntegrationTest
freeze_time do
confirmation_token = @reconfirmed_user.generate_confirmation_token

get edit_confirmation_path(confirmation_token)
get edit_confirmation_path(confirmation_token, email: @confirmed_user.confirmable_email)

assert_equal original_email, @reconfirmed_user.reload.email
assert_redirected_to new_confirmation_path
@@ -56,7 +56,7 @@ class ConfirmationsControllerTest < ActionDispatch::IntegrationTest
confirmation_token = @unconfirmed_user.generate_confirmation_token

travel_to 601.seconds.from_now do
get edit_confirmation_path(confirmation_token)
get edit_confirmation_path(confirmation_token, email: @unconfirmed_user.confirmable_email)

assert_nil @unconfirmed_user.reload.confirmed_at
assert_not @unconfirmed_user.reload.confirmed?
@@ -66,7 +66,15 @@ class ConfirmationsControllerTest < ActionDispatch::IntegrationTest
end

test "should redirect if confirmation link is incorrect" do
get edit_confirmation_path("not_a_real_token")
get edit_confirmation_path("not_a_real_token", email: @unconfirmed_user.confirmable_email)
assert_redirected_to new_confirmation_path
assert_not_nil flash[:alert]
end

test "should redirect if confirmation email is incorrect" do
confirmation_token = @unconfirmed_user.generate_confirmation_token

get edit_confirmation_path(confirmation_token, email: "not_the_email@gmail.com")
assert_redirected_to new_confirmation_path
assert_not_nil flash[:alert]
end
@@ -99,7 +107,7 @@ class ConfirmationsControllerTest < ActionDispatch::IntegrationTest

login @confirmed_user

get edit_confirmation_path(confirmation_token)
get edit_confirmation_path(confirmation_token, email: @confirmed_user.confirmable_email)

assert_not_equal Time.current, @confirmed_user.reload.confirmed_at
assert_redirected_to new_confirmation_path
@@ -115,7 +123,7 @@ class ConfirmationsControllerTest < ActionDispatch::IntegrationTest

confirmation_token = @reconfirmed_user.generate_confirmation_token

get edit_confirmation_path(confirmation_token)
get edit_confirmation_path(confirmation_token, email: unconfirmed_email)

assert_equal Time.current, @reconfirmed_user.reload.confirmed_at
assert @reconfirmed_user.reload.confirmed?
@@ -126,6 +134,19 @@ class ConfirmationsControllerTest < ActionDispatch::IntegrationTest
end
end

test "should prevent reuse of confirmation token" do
login @unconfirmed_user

confirmation_token = @unconfirmed_user.generate_confirmation_token

get edit_confirmation_path(confirmation_token, email: @unconfirmed_user.confirmable_email)
assert @unconfirmed_user.reload.confirmed?
@unconfirmed_user.update(unconfirmed_email: "not_the_same@example.com")

get edit_confirmation_path(confirmation_token, email: @unconfirmed_user.unconfirmed_email)
assert_redirected_to new_confirmation_path
end

test "should prevent authenticated user from submitting the confirmation form" do
login @confirmed_user

6 changes: 6 additions & 0 deletions test/controllers/sessions_controller_test.rb
Original file line number Diff line number Diff line change
@@ -44,6 +44,12 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest

assert_not_nil current_user
assert_not_nil cookies[:remember_token]

remember_me_cookie = cookies.get_cookie("remember_token")

assert remember_me_cookie.http_only?
assert remember_me_cookie.secure?
assert_equal "Strict", remember_me_cookie.to_h["SameSite"]
end

test "should forget user when logging out" do
2 changes: 1 addition & 1 deletion test/mailers/user_mailer_test.rb
Original file line number Diff line number Diff line change
@@ -7,7 +7,7 @@ class UserMailerTest < ActionMailer::TestCase

test "confirmation" do
confirmation_token = @user.generate_confirmation_token
mail = UserMailer.confirmation(@user, confirmation_token)
mail = UserMailer.confirmation(@user, confirmation_token, @user.unconfirmed_email)
assert_equal "Confirmation Instructions", mail.subject
assert_equal [@user.email], mail.to
assert_equal [User::MAILER_FROM_EMAIL], mail.from
Loading