Skip to content
Merged
Show file tree
Hide file tree
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
38 changes: 38 additions & 0 deletions app/models/concerns/email_address_callback.rb
Original file line number Diff line number Diff line change
@@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the reasoning behind this callback?

15 changes: 15 additions & 0 deletions app/models/email_address.rb
Original file line number Diff line number Diff line change
@@ -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
10 changes: 4 additions & 6 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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
Expand Down Expand Up @@ -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
3 changes: 3 additions & 0 deletions config/environments/test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions db/migrate/20180906181420_create_email_address_table.rb
Original file line number Diff line number Diff line change
@@ -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
15 changes: 14 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions spec/factories/email_addresses.rb
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions spec/models/email_address_spec.rb
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions spec/models/user_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down