From 3ea893715726c3516b3b222c77f3cc9d8ee3c1c6 Mon Sep 17 00:00:00 2001 From: Bianca Nenciu Date: Wed, 24 Nov 2021 11:30:06 +0200 Subject: [PATCH] FEATURE: Add email normalization rules setting (#14593) When this setting is turned on, it will check that normalized emails are unique. Normalized emails are emails without any dots or plus aliases. This setting can be used to block use of aliases of the same email address. --- app/jobs/onceoff/migrate_normalized_emails.rb | 11 +++++++ app/models/user_email.rb | 31 ++++++++++++++----- config/locales/server.en.yml | 1 + config/site_settings.yml | 2 ++ ...2406_add_normalized_email_to_user_email.rb | 13 ++++++++ spec/models/user_email_spec.rb | 26 ++++++++++++++++ 6 files changed, 76 insertions(+), 8 deletions(-) create mode 100644 app/jobs/onceoff/migrate_normalized_emails.rb create mode 100644 db/migrate/20211013092406_add_normalized_email_to_user_email.rb diff --git a/app/jobs/onceoff/migrate_normalized_emails.rb b/app/jobs/onceoff/migrate_normalized_emails.rb new file mode 100644 index 00000000000..4b1ff23c0fe --- /dev/null +++ b/app/jobs/onceoff/migrate_normalized_emails.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Jobs + class MigrateNormalizedEmails < ::Jobs::Onceoff + def execute_onceoff(args) + ::UserEmail.find_each do |user_email| + user_email.update(normalized_email: user_email.normalize_email) + end + end + end +end diff --git a/app/models/user_email.rb b/app/models/user_email.rb index 6ab955c95a7..4bb2e64e9ab 100644 --- a/app/models/user_email.rb +++ b/app/models/user_email.rb @@ -7,6 +7,7 @@ class UserEmail < ActiveRecord::Base attr_accessor :skip_validate_unique_email before_validation :strip_downcase_email + before_validation :normalize_email validates :email, presence: true validates :email, email: true, if: :validate_email? @@ -17,6 +18,14 @@ class UserEmail < ActiveRecord::Base scope :secondary, -> { where(primary: false) } + def normalize_email + self.normalized_email = if self.email.present? + username, domain = self.email.split('@', 2) + username = username.gsub('.', '').gsub(/\+.*/, '') + "#{username}@#{domain}" + end + end + private def strip_downcase_email @@ -37,9 +46,13 @@ class UserEmail < ActiveRecord::Base end def unique_email - if self.class.where("lower(email) = ?", email).exists? - self.errors.add(:email, :taken) + email_exists = if SiteSetting.normalize_emails? + self.class.where("lower(email) = ? OR lower(normalized_email) = ?", email, normalized_email).exists? + else + self.class.where("lower(email) = ?", email).exists? end + + self.errors.add(:email, :taken) if email_exists end def user_id_not_changed @@ -55,16 +68,18 @@ end # # Table name: user_emails # -# id :integer not null, primary key -# user_id :integer not null -# email :string(513) not null -# primary :boolean default(FALSE), not null -# created_at :datetime not null -# updated_at :datetime not null +# id :integer not null, primary key +# user_id :integer not null +# email :string(513) not null +# primary :boolean default(FALSE), not null +# created_at :datetime not null +# updated_at :datetime not null +# normalized_email :string # # Indexes # # index_user_emails_on_email (lower((email)::text)) UNIQUE +# index_user_emails_on_normalized_email (lower((normalized_email)::text)) # index_user_emails_on_user_id (user_id) # index_user_emails_on_user_id_and_primary (user_id,primary) UNIQUE WHERE "primary" # diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 191dcf3f4a9..6db14aa86bb 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1649,6 +1649,7 @@ en: allow_index_in_robots_txt: "Specify in robots.txt that this site is allowed to be indexed by web search engines. In exceptional cases you can permanently override robots.txt." blocked_email_domains: "A pipe-delimited list of email domains that users are not allowed to register accounts with. Example: mailinator.com|trashmail.net" allowed_email_domains: "A pipe-delimited list of email domains that users MUST register accounts with. WARNING: Users with email domains other than those listed will not be allowed!" + normalize_emails: "Check if normalized email is unique. Normalized email removes all dots from the username and everything between + and @ symbols." auto_approve_email_domains: "Users with email addresses from this list of domains will be automatically approved." hide_email_address_taken: "Don't inform users that an account exists with a given email address during signup and from the forgot password form." log_out_strict: "When logging out, log out ALL sessions for the user on all devices" diff --git a/config/site_settings.yml b/config/site_settings.yml index 2b319294446..9ea8cf78497 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -510,6 +510,8 @@ login: default: "" type: list list_type: simple + normalize_emails: + default: false auto_approve_email_domains: default: "" type: list diff --git a/db/migrate/20211013092406_add_normalized_email_to_user_email.rb b/db/migrate/20211013092406_add_normalized_email_to_user_email.rb new file mode 100644 index 00000000000..76d022674f6 --- /dev/null +++ b/db/migrate/20211013092406_add_normalized_email_to_user_email.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class AddNormalizedEmailToUserEmail < ActiveRecord::Migration[6.1] + def change + add_column :user_emails, :normalized_email, :string + execute "CREATE INDEX index_user_emails_on_normalized_email ON user_emails (LOWER(normalized_email))" + end + + def down + execute "DROP INDEX index_user_emails_on_normalized_email" + drop_column :user_emails, :normalized_email, :string + end +end diff --git a/spec/models/user_email_spec.rb b/spec/models/user_email_spec.rb index ac821c7a5df..84440b9ce7c 100644 --- a/spec/models/user_email_spec.rb +++ b/spec/models/user_email_spec.rb @@ -26,6 +26,32 @@ describe UserEmail do end end + describe 'normalized_email' do + it 'checks if normalized email is unique' do + SiteSetting.normalize_emails = true + + user_email = user.user_emails.create(email: "a.b+c@example.com", primary: false) + expect(user_email.normalized_email).to eq("ab@example.com") + expect(user_email).to be_valid + + user_email = user.user_emails.create(email: "a.b+d@example.com", primary: false) + expect(user_email.normalized_email).to eq("ab@example.com") + expect(user_email).not_to be_valid + end + + it 'does not check uniqueness if email normalization is not enabled' do + SiteSetting.normalize_emails = false + + user_email = user.user_emails.create(email: "a.b+c@example.com", primary: false) + expect(user_email.normalized_email).to eq("ab@example.com") + expect(user_email).to be_valid + + user_email = user.user_emails.create(email: "a.b+d@example.com", primary: false) + expect(user_email.normalized_email).to eq("ab@example.com") + expect(user_email).to be_valid + end + end + context "indexes" do it "allows only one primary email" do expect {