identity-vault/app/models/identity/email_change_request.rb
2026-02-17 12:41:58 -05:00

218 lines
6.2 KiB
Ruby

# == Schema Information
#
# Table name: identity_email_change_requests
#
# id :bigint not null, primary key
# cancelled_at :datetime
# completed_at :datetime
# expires_at :datetime not null
# new_email :string not null
# new_email_token_bidx :string
# new_email_token_ciphertext :text
# new_email_verified_at :datetime
# old_email :string not null
# old_email_token_bidx :string
# old_email_token_ciphertext :text
# old_email_verified_at :datetime
# requested_from_ip :string
# created_at :datetime not null
# updated_at :datetime not null
# identity_id :bigint not null
#
# Indexes
#
# idx_email_change_requests_identity_completed (identity_id,completed_at)
# index_identity_email_change_requests_on_identity_id (identity_id)
# index_identity_email_change_requests_on_new_email_token_bidx (new_email_token_bidx)
# index_identity_email_change_requests_on_old_email_token_bidx (old_email_token_bidx)
#
# Foreign Keys
#
# fk_rails_... (identity_id => identities.id)
#
class Identity::EmailChangeRequest < ApplicationRecord
include PublicIdentifiable
EXPIRATION = 24.hours
set_public_id_prefix "emc"
has_paper_trail
belongs_to :identity
alias_method :to_param, :public_id
has_encrypted :old_email_token
blind_index :old_email_token
has_encrypted :new_email_token
blind_index :new_email_token
validates :new_email, :old_email, :expires_at, presence: true
validate :validate_new_email
validate :new_email_not_taken
validate :new_email_different_from_old
scope :pending, -> { where(completed_at: nil, cancelled_at: nil).where("expires_at > ?", Time.current) }
scope :completed, -> { where.not(completed_at: nil) }
before_validation :normalize_emails
before_validation :set_defaults, on: :create
before_create :generate_tokens
after_create :track_email_change_requested
def pending?
completed_at.nil? && cancelled_at.nil? && !expired?
end
def completed?
completed_at.present?
end
def cancelled?
cancelled_at.present?
end
def expired?
expires_at < Time.current
end
def old_email_verified?
old_email_verified_at.present?
end
def new_email_verified?
new_email_verified_at.present?
end
def both_emails_verified?
old_email_verified? && new_email_verified?
end
def verify_old_email!(token, verified_from_ip: nil)
return false unless pending?
return false unless ActiveSupport::SecurityUtils.secure_compare(old_email_token.to_s, token.to_s)
transaction do
update!(old_email_verified_at: Time.current, old_email_verified_from_ip: verified_from_ip)
identity.create_activity :email_change_verified_old,
owner: identity,
recipient: identity,
parameters: { old_email: old_email, new_email: new_email }
complete_if_ready!
end
true
end
def verify_new_email!(token, verified_from_ip: nil)
return false unless pending?
return false unless ActiveSupport::SecurityUtils.secure_compare(new_email_token.to_s, token.to_s)
transaction do
update!(new_email_verified_at: Time.current, new_email_verified_from_ip: verified_from_ip)
identity.create_activity :email_change_verified_new,
owner: identity,
recipient: identity,
parameters: { old_email: old_email, new_email: new_email }
complete_if_ready!
end
true
end
def cancel!
return false unless pending?
transaction do
update!(cancelled_at: Time.current)
identity.create_activity :email_change_cancelled,
owner: identity,
recipient: identity,
parameters: { old_email: old_email, new_email: new_email }
end
true
end
def complete_if_ready!
with_lock do
return unless both_emails_verified?
return unless pending?
return if completed?
identity.update!(primary_email: new_email)
update!(completed_at: Time.current)
identity.create_activity :email_changed,
owner: identity,
recipient: identity,
parameters: { old_email: old_email, new_email: new_email }
end
EmailChangeMailer.email_changed_notification(self).deliver_later
end
def send_verification_emails!
EmailChangeMailer.verify_old_email(self).deliver_later
EmailChangeMailer.verify_new_email(self).deliver_later
end
private
def normalize_emails
self.new_email = new_email.to_s.strip.downcase.presence
self.old_email = old_email.to_s.strip.downcase.presence
end
def set_defaults
self.expires_at ||= EXPIRATION.from_now
self.old_email ||= identity&.primary_email
end
def generate_tokens
self.old_email_token ||= SecureRandom.urlsafe_base64(32)
self.new_email_token ||= SecureRandom.urlsafe_base64(32)
end
def new_email_not_taken
return unless new_email.present?
existing = Identity.where.not(id: identity_id).find_by(primary_email: new_email)
errors.add(:new_email, "is already taken by another account") if existing
end
def new_email_different_from_old
return unless new_email.present? && old_email.present?
if new_email.downcase == old_email.downcase
errors.add(:new_email, "can't be your current email, ya goof!")
end
end
def track_email_change_requested
identity.create_activity :email_change_requested,
owner: identity,
recipient: identity,
parameters: { old_email: old_email, new_email: new_email }
end
def validate_new_email
return unless new_email.present?
address = ValidEmail2::Address.new(new_email)
unless address.valid?
errors.add(:new_email, I18n.t("errors.attributes.new_email.invalid_format", default: "is invalid"))
return
end
return unless Rails.env.production?
if address.disposable?
errors.add(:new_email, I18n.t("errors.attributes.new_email.temporary", default: "cannot be a temporary email"))
return
end
unless address.valid_mx?
errors.add(:new_email, I18n.t("errors.attributes.new_email.no_mx_record", default: "domain does not accept email"))
end
end
end