diff --git a/app/models/concerns/email_address_callback.rb b/app/models/concerns/email_address_callback.rb new file mode 100644 index 00000000000..80e85d3e47c --- /dev/null +++ b/app/models/concerns/email_address_callback.rb @@ -0,0 +1,38 @@ +module EmailAddressCallback + extend ActiveSupport::Concern + + def self.included(base) + base.send(:after_save, :update_email_address) + end + + def update_email_address + if email_address.present? + update_email_address_record + elsif encrypted_email.present? + create_full_email_address_record + end + end + + private + + def update_email_address_record + email_address.update!( + encrypted_email: encrypted_email, + confirmation_token: confirmation_token, + confirmed_at: confirmed_at, + confirmation_sent_at: confirmation_sent_at, + email_fingerprint: email_fingerprint + ) + end + + def create_full_email_address_record + create_email_address!( + user: self, + encrypted_email: encrypted_email, + confirmation_token: confirmation_token, + confirmed_at: confirmed_at, + confirmation_sent_at: confirmation_sent_at, + email_fingerprint: email_fingerprint + ) + end +end diff --git a/app/models/email_address.rb b/app/models/email_address.rb new file mode 100644 index 00000000000..41b5ccd0817 --- /dev/null +++ b/app/models/email_address.rb @@ -0,0 +1,15 @@ +class EmailAddress < ApplicationRecord + include EncryptableAttribute + + encrypted_attribute_without_setter(name: :email) + + belongs_to :user, inverse_of: :email_address + validates :user_id, presence: true + validates :encrypted_email, presence: true + validates :email_fingerprint, presence: true + + def email=(email) + set_encrypted_attribute(name: :email, value: email) + self.email_fingerprint = email.present? ? encrypted_attributes[:email].fingerprint : '' + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 3166f42bf4c..47c291c2093 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -29,6 +29,7 @@ class User < ApplicationRecord # IMPORTANT this comes *after* devise() call. include UserAccessKeyOverrides include UserEncryptedAttributeOverrides + include EmailAddressCallback enum role: { user: 0, tech: 1, admin: 2 } enum otp_delivery_preference: { sms: 0, voice: 1 } @@ -42,6 +43,7 @@ class User < ApplicationRecord has_many :events, dependent: :destroy has_one :account_reset_request, dependent: :destroy has_many :phone_configurations, dependent: :destroy, inverse_of: :user + has_one :email_address, dependent: :destroy, inverse_of: :user has_many :webauthn_configurations, dependent: :destroy validates :x509_dn_uuid, uniqueness: true, allow_nil: true @@ -162,12 +164,8 @@ def send_custom_confirmation_instructions(id = nil, instructions = nil) end def total_mfa_options_enabled - total = [phone_mfa_enabled?, piv_cac_enabled?, totp_enabled?].count { |tf| tf } - total + webauthn_configurations.size - end - - def phone_mfa_enabled? - phone_configurations.any?(&:mfa_enabled?) + phone_configurations.count(&:mfa_enabled?) + webauthn_configurations.size + + [piv_cac_enabled?, totp_enabled?].count { |tf| tf } end end # rubocop:enable Rails/HasManyOrHasOneDependent diff --git a/config/environments/test.rb b/config/environments/test.rb index 9f56c86ad2a..c6a14524e0a 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -37,6 +37,9 @@ Bullet.add_whitelist( type: :n_plus_one_query, class_name: 'User', association: :phone_configurations ) + Bullet.add_whitelist( + type: :n_plus_one_query, class_name: 'User', association: :email_address + ) end config.active_support.test_order = :random diff --git a/db/migrate/20180906181420_create_email_address_table.rb b/db/migrate/20180906181420_create_email_address_table.rb new file mode 100644 index 00000000000..6a82c5d3ef2 --- /dev/null +++ b/db/migrate/20180906181420_create_email_address_table.rb @@ -0,0 +1,16 @@ +class CreateEmailAddressTable < ActiveRecord::Migration[5.1] + def change + create_table :email_addresses do |t| + t.references :user + t.string :confirmation_token, limit: 255 + t.datetime :confirmed_at + t.datetime :confirmation_sent_at + t.string :email_fingerprint, null: false, default: "" + t.string :encrypted_email, null: false, default: "" + + t.timestamps + + t.index :email_fingerprint, unique: true, where: 'confirmed_at IS NOT NULL' + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 4ca42917948..a77ff741e02 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20180827225542) do +ActiveRecord::Schema.define(version: 20180906181420) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -55,6 +55,19 @@ t.index ["user_id"], name: "index_authorizations_on_user_id" end + create_table "email_addresses", force: :cascade do |t| + t.bigint "user_id" + t.string "confirmation_token", limit: 255 + t.datetime "confirmed_at" + t.datetime "confirmation_sent_at" + t.string "email_fingerprint", default: "", null: false + t.string "encrypted_email", default: "", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["email_fingerprint"], name: "index_email_addresses_on_email_fingerprint", unique: true, where: "(confirmed_at IS NOT NULL)" + t.index ["user_id"], name: "index_email_addresses_on_user_id" + end + create_table "events", force: :cascade do |t| t.integer "user_id", null: false t.integer "event_type", null: false diff --git a/spec/factories/email_addresses.rb b/spec/factories/email_addresses.rb new file mode 100644 index 00000000000..d7f45cff689 --- /dev/null +++ b/spec/factories/email_addresses.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + Faker::Config.locale = :en + + factory :email_address do + confirmed_at { Time.zone.now } + email { 'jd@example.com' } + association :user + end +end diff --git a/spec/models/email_address_spec.rb b/spec/models/email_address_spec.rb new file mode 100644 index 00000000000..4b1fcc7b5f4 --- /dev/null +++ b/spec/models/email_address_spec.rb @@ -0,0 +1,35 @@ +require 'rails_helper' + +describe EmailAddress do + describe 'Associations' do + it { is_expected.to belong_to(:user) } + it { is_expected.to validate_presence_of(:user_id) } + it { is_expected.to validate_presence_of(:encrypted_email) } + it { is_expected.to validate_presence_of(:email_fingerprint) } + end + + let(:email) { 'jd@example.com' } + + let(:email_address) { create(:email_address, email: email) } + + describe 'creation' do + it 'stores an encrypted form of the email address' do + expect(email_address.encrypted_email).to_not be_blank + end + end + + describe 'encrypted attributes' do + it 'decrypts email' do + expect(email_address.email).to eq email + end + + context 'with unnormalized email' do + let(:email) { ' jD@Example.Com ' } + let(:normalized_email) { 'jd@example.com' } + + it 'normalizes email' do + expect(email_address.email).to eq normalized_email + end + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 0a31acfe469..4744c4dbb6b 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -21,6 +21,32 @@ end.to change(ActionMailer::Base.deliveries, :count).by(0) end + describe 'email_address' do + it 'creates an entry for the user when created' do + expect do + User.create(email: 'nobody@nobody.com') + end.to change(EmailAddress, :count).by(1) + end + + it 'mirrors the info from the user object on creation' do + user = create(:user) + email_address = user.email_address + expect(email_address).to be_present + expect(email_address.encrypted_email).to eq user.encrypted_email + expect(email_address.email).to eq user.email + expect(email_address.confirmed_at).to eq user.confirmed_at + end + + it 'mirrors the info from an unconfirmed user object' do + user = create(:user, :unconfirmed) + email_address = user.email_address + expect(email_address).to be_present + expect(email_address.encrypted_email).to eq user.encrypted_email + expect(email_address.email).to eq user.email + expect(email_address.confirmed_at).to be_nil + end + end + describe 'password validations' do it 'allows long phrases that contain common words' do user = create(:user)