summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/models/user.rb9
-rw-r--r--changelogs/unreleased/security-fix-email-confirmation-bug.yml5
-rw-r--r--spec/factories/users.rb4
-rw-r--r--spec/models/user_spec.rb102
4 files changed, 117 insertions, 3 deletions
diff --git a/app/models/user.rb b/app/models/user.rb
index b2d3978551e..81316f81818 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -237,9 +237,10 @@ class User < ApplicationRecord
if previous_changes.key?('email')
# Grab previous_email here since previous_changes changes after
# #update_emails_with_primary_email and #update_notification_email are called
+ previous_confirmed_at = previous_changes.key?('confirmed_at') ? previous_changes['confirmed_at'][0] : confirmed_at
previous_email = previous_changes[:email][0]
- update_emails_with_primary_email(previous_email)
+ update_emails_with_primary_email(previous_confirmed_at, previous_email)
update_invalid_gpg_signatures
if previous_email == notification_email
@@ -811,13 +812,15 @@ class User < ApplicationRecord
# By using an `after_commit` instead of `after_update`, we avoid the recursive callback
# scenario, though it then requires us to use the `previous_changes` hash
# rubocop: disable CodeReuse/ServiceClass
- def update_emails_with_primary_email(previous_email)
+ def update_emails_with_primary_email(previous_confirmed_at, previous_email)
primary_email_record = emails.find_by(email: email)
Emails::DestroyService.new(self, user: self).execute(primary_email_record) if primary_email_record
# the original primary email was confirmed, and we want that to carry over. We don't
# have access to the original confirmation values at this point, so just set confirmed_at
- Emails::CreateService.new(self, user: self, email: previous_email).execute(confirmed_at: confirmed_at)
+ Emails::CreateService.new(self, user: self, email: previous_email).execute(confirmed_at: previous_confirmed_at)
+
+ update_columns(confirmed_at: primary_email_record.confirmed_at) if primary_email_record&.confirmed_at
end
# rubocop: enable CodeReuse/ServiceClass
diff --git a/changelogs/unreleased/security-fix-email-confirmation-bug.yml b/changelogs/unreleased/security-fix-email-confirmation-bug.yml
new file mode 100644
index 00000000000..ce66a255616
--- /dev/null
+++ b/changelogs/unreleased/security-fix-email-confirmation-bug.yml
@@ -0,0 +1,5 @@
+---
+title: Fix confirming unverified emails with soft email confirmation flow enabled
+merge_request:
+author:
+type: security
diff --git a/spec/factories/users.rb b/spec/factories/users.rb
index 2f5cc404143..7e121b10632 100644
--- a/spec/factories/users.rb
+++ b/spec/factories/users.rb
@@ -48,6 +48,10 @@ FactoryBot.define do
after(:build) { |user, _| user.block! }
end
+ trait :unconfirmed do
+ confirmed_at { nil }
+ end
+
trait :with_avatar do
avatar { fixture_file_upload('spec/fixtures/dk.png') }
end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 94a3f6bafea..abc52263298 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -916,6 +916,108 @@ describe User do
expect(@user.emails.count).to eq 1
expect(@user.emails.first.confirmed_at).not_to eq nil
end
+
+ context 'when the first email was unconfirmed and the second email gets confirmed' do
+ let_it_be(:user) { create(:user, :unconfirmed, email: 'should-be-unconfirmed@test.com') }
+
+ before do
+ user.update!(email: 'should-be-confirmed@test.com')
+ user.confirm
+ end
+
+ it 'updates user.email' do
+ expect(user.email).to eq('should-be-confirmed@test.com')
+ end
+
+ it 'confirms user.email' do
+ expect(user).to be_confirmed
+ end
+
+ it 'keeps the unconfirmed email unconfirmed' do
+ email = user.emails.first
+
+ expect(email.email).to eq('should-be-unconfirmed@test.com')
+ expect(email).not_to be_confirmed
+ end
+
+ it 'has only one email association' do
+ expect(user.emails.size).to be(1)
+ end
+ end
+ end
+
+ context 'when an existing email record is set as primary' do
+ let(:user) { create(:user, email: 'confirmed@test.com') }
+
+ context 'when it is unconfirmed' do
+ let(:originally_unconfirmed_email) { 'should-stay-unconfirmed@test.com' }
+
+ before do
+ user.emails << create(:email, email: originally_unconfirmed_email, confirmed_at: nil)
+
+ user.update!(email: originally_unconfirmed_email)
+ end
+
+ it 'keeps the user confirmed' do
+ expect(user).to be_confirmed
+ end
+
+ it 'keeps the original email' do
+ expect(user.email).to eq('confirmed@test.com')
+ end
+
+ context 'when the email gets confirmed' do
+ before do
+ user.confirm
+ end
+
+ it 'keeps the user confirmed' do
+ expect(user).to be_confirmed
+ end
+
+ it 'updates the email' do
+ expect(user.email).to eq(originally_unconfirmed_email)
+ end
+ end
+ end
+
+ context 'when it is confirmed' do
+ let!(:old_confirmed_email) { user.email }
+ let(:confirmed_email) { 'already-confirmed@test.com' }
+
+ before do
+ user.emails << create(:email, :confirmed, email: confirmed_email)
+
+ user.update!(email: confirmed_email)
+ end
+
+ it 'keeps the user confirmed' do
+ expect(user).to be_confirmed
+ end
+
+ it 'updates the email' do
+ expect(user.email).to eq(confirmed_email)
+ end
+
+ it 'moves the old email' do
+ email = user.reload.emails.first
+
+ expect(email.email).to eq(old_confirmed_email)
+ expect(email).to be_confirmed
+ end
+ end
+ end
+
+ context 'when unconfirmed user deletes a confirmed additional email' do
+ let(:user) { create(:user, :unconfirmed) }
+
+ before do
+ user.emails << create(:email, :confirmed)
+ end
+
+ it 'does not affect the confirmed status' do
+ expect { user.emails.confirmed.destroy_all }.not_to change { user.confirmed? } # rubocop: disable Cop/DestroyAll
+ end
end
describe '#update_notification_email' do