Emi Matchu
acb52cb870
Just a bit more clarity of grouping! I'm also thinking about extracting modeling APIs into a service file like this too, in which case I think this would help clarify what it is.
244 lines
No EOL
9.3 KiB
Ruby
244 lines
No EOL
9.3 KiB
Ruby
class AuthUser < AuthRecord
|
|
self.table_name = 'users'
|
|
|
|
devise :database_authenticatable, :encryptable, :registerable, :validatable,
|
|
:rememberable, :trackable, :recoverable, :omniauthable,
|
|
omniauth_providers: [:neopass]
|
|
|
|
validates :name, presence: true, uniqueness: {case_sensitive: false},
|
|
length: {maximum: 30}
|
|
|
|
validates :uid, uniqueness: {scope: :provider, allow_nil: true}
|
|
|
|
validate :has_at_least_one_login_method
|
|
|
|
has_one :user, foreign_key: :remote_id, inverse_of: :auth_user
|
|
|
|
# If the email is blank, ensure that it's `nil` rather than an empty string,
|
|
# or else the database's uniqueness constraint will object to multiple users
|
|
# who all have the empty string as their email.
|
|
before_validation { self.email = nil if email.blank? }
|
|
|
|
# It's important to keep AuthUser and User in sync. When we create an AuthUser
|
|
# (e.g. through the registration process), we create a matching User, too. And
|
|
# when the AuthUser's name changes, we update User to match.
|
|
#
|
|
# TODO: Should we sync deletions too? We don't do deletions anywhere in app
|
|
# right now, so I'll hold off to avoid leaving dead code around.
|
|
after_create :create_user!
|
|
after_update :sync_name_with_user!, if: :saved_change_to_name?
|
|
|
|
def create_user!
|
|
User.create!(name: name, auth_server_id: 1, remote_id: id)
|
|
end
|
|
|
|
def sync_name_with_user!
|
|
user.name = name
|
|
user.save!
|
|
end
|
|
|
|
def uses_omniauth?
|
|
provider? && uid?
|
|
end
|
|
|
|
def email_required?
|
|
# Email is required when creating a new account from scratch, but it isn't
|
|
# required when creating a new account via third-party login (e.g. it's
|
|
# already taken). It's also okay to remove your email address, though this
|
|
if new_record?
|
|
# When creating a new account, email is required when building it from
|
|
# scratch, but not required when using third-party login. This is mainly
|
|
# because third-party login can't reliably offer an unused email!
|
|
!uses_omniauth?
|
|
else
|
|
# TODO: I had wanted to make email required if you already have one, to
|
|
# make it harder to accidentally remove? I expected
|
|
# `email_before_last_save` to be the way to check this, but it
|
|
# seemed to be `nil` when calling this, go figure! For now, we're
|
|
# allowing email to be removed.
|
|
#
|
|
# NOTE: This is important for the case where you're disconnecting a
|
|
# NeoPass, but you don't have an email set, because your NeoPass
|
|
# email already belonged to another account. I don't think it makes
|
|
# sense to require people to add an alternate real email address in
|
|
# order to be able to disconnect a NeoPass from a DTI account they
|
|
# maybe even created by accident!
|
|
false
|
|
end
|
|
end
|
|
|
|
def password_required?
|
|
super && !uses_omniauth?
|
|
end
|
|
|
|
def uses_neopass?
|
|
provider == "neopass"
|
|
end
|
|
|
|
def neopass_friendly_id
|
|
neopass_email || uid
|
|
end
|
|
|
|
def uses_password?
|
|
encrypted_password?
|
|
end
|
|
|
|
def update_with_password(params)
|
|
# If this account already uses passwords, use Devise's default behavior.
|
|
return super(params) if uses_password?
|
|
|
|
# Otherwise, we implement similar logic, but skipping the check for
|
|
# `current_password`: Bulk-assign most attributes, but only set the
|
|
# password if it's non-empty.
|
|
self.attributes = params.except(:password, :password_confirmation,
|
|
:current_password)
|
|
if params[:password].present?
|
|
self.password = params[:password]
|
|
self.password_confirmation = params[:password_confirmation]
|
|
end
|
|
|
|
self.save
|
|
end
|
|
|
|
def connect_omniauth!(auth)
|
|
raise MissingAuthInfoError, "Email missing" if auth.info.email.blank?
|
|
|
|
begin
|
|
update!(provider: auth.provider, uid: auth.uid,
|
|
neopass_email: auth.info.email)
|
|
rescue ActiveRecord::RecordInvalid
|
|
# If this auth is already bound to another account, raise a specific
|
|
# error about it, instead of the normal error.
|
|
if errors.where(:uid).any? { |e| e.type == :taken }
|
|
raise AuthAlreadyConnected, "there's already an account with " +
|
|
"provider #{auth.provider}, uid #{auth.uid}"
|
|
end
|
|
raise
|
|
end
|
|
end
|
|
|
|
def disconnect_neopass
|
|
# If there's no NeoPass, we're already done!
|
|
return true if !uses_neopass?
|
|
|
|
begin
|
|
# Remove all of the NeoPass fields, and return whether we were
|
|
# successful. (I don't know why it wouldn't be, but let's be resilient!)
|
|
#
|
|
# NOTE: I considered leaving `neopass_email` in place, to help us support
|
|
# users who accidentally got locked out… but I think it's more
|
|
# important to respect data privacy and not be holding onto an
|
|
# email address the user doesn't realize we have!
|
|
update(provider: nil, uid: nil, neopass_email: nil)
|
|
rescue => error
|
|
# If something strange happens, log it and gracefully return `false`!
|
|
Sentry.capture_exception error
|
|
Rails.logger.error error
|
|
false
|
|
end
|
|
end
|
|
|
|
def has_at_least_one_login_method
|
|
if !uses_password? && !email? && !uses_neopass?
|
|
errors.add(:base, "You must have either a password, an email, or a " +
|
|
"NeoPass. Otherwise, you can't log in!")
|
|
end
|
|
end
|
|
|
|
def self.find_by_omniauth(auth)
|
|
find_by(provider: auth.provider, uid: auth.uid)
|
|
end
|
|
|
|
def self.from_omniauth(auth)
|
|
raise MissingAuthInfoError, "Email missing" if auth.info.email.blank?
|
|
|
|
transaction do
|
|
find_or_create_by!(provider: auth.provider, uid: auth.uid) do |user|
|
|
# This account is new! Let's do the initial setup.
|
|
|
|
# First, get the user's Neopets username if possible, as a base for the
|
|
# name we create for them. (This is an async task under the hood, which
|
|
# means we can wrap it in a `with_timeout` block!)
|
|
neopets_username = Sync do |task|
|
|
task.with_timeout(5) do
|
|
Neopets::NeoPass.load_main_neopets_username(auth.credentials.token)
|
|
end
|
|
rescue Async::TimeoutError
|
|
nil # If the request times out, just move on!
|
|
rescue => error
|
|
# If the request fails, log it and move on. (Could be a service
|
|
# outage, or a change on NeoPass's end that we're not in sync with.)
|
|
Rails.logger.error error
|
|
Sentry.capture_exception error
|
|
nil
|
|
end
|
|
|
|
# Generate a unique username for this user, based on their Neopets
|
|
# username, if they have one.
|
|
user.name = build_unique_username(neopets_username)
|
|
|
|
# Copy the email address from their Neopets account to their DTI
|
|
# account, unless they already have a DTI account with this email, in
|
|
# which case, ignore it. (It's primarily for their own convenience with
|
|
# password recovery!)
|
|
email_exists = AuthUser.where(email: auth.info.email).exists?
|
|
user.email = auth.info.email unless email_exists
|
|
|
|
# Additionally, regardless of whether we save it as `email`, we also
|
|
# save the email address as `neopass_email`, to use in the Settings UI
|
|
# to indicate what NeoPass you're linked to.
|
|
user.neopass_email = auth.info.email
|
|
end.tap do |user|
|
|
# If this account already existed, make sure we've saved the latest
|
|
# email to `neopass_email`. (In practice, this *shouldn't* ever change
|
|
# after initial setup, because NeoPass emails are immutable? But why
|
|
# not be resilient!)
|
|
unless user.previously_new_record?
|
|
user.update!(neopass_email: auth.info.email)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def self.build_unique_username(preferred_name = nil)
|
|
# If there's no preferred name provided (ideally the user's Neopets
|
|
# username), start with a base name like `neopass-kougra`.
|
|
if preferred_name.present?
|
|
base_name = preferred_name
|
|
else
|
|
random_species_name = Species.all.pluck(:name).sample
|
|
base_name = "neopass-#{random_species_name}"
|
|
end
|
|
|
|
# Fetch the list of names that already start with that.
|
|
name_query = sanitize_sql_like(base_name) + "%"
|
|
similar_names = where("name LIKE ?", name_query).pluck(:name).to_set
|
|
|
|
# If this was the user's preferred name (rather than a random one), try
|
|
# using the preferred name itself, if not already taken.
|
|
if preferred_name.present? && !similar_names.include?(preferred_name)
|
|
return preferred_name
|
|
end
|
|
|
|
# Otherwise, shuffle the list of four-digit numbers to create 10000
|
|
# possible names, then use the first one that's not already claimed.
|
|
potential_names = (0..9999).map { |n| "#{base_name}-#{n}" }.shuffle
|
|
name = potential_names.find { |name| !similar_names.include?(name) }
|
|
return name unless name.nil?
|
|
|
|
# If that failed, try again but with six digits.
|
|
potential_names = (0..999999).map { |n| "#{base_name}-#{n}" }.shuffle
|
|
name = potential_names.find { |name| !similar_names.include?(name) }
|
|
return name unless name.nil?
|
|
|
|
# If *that* failed, then golly gee, we have millions of NeoPass users
|
|
# running around using the default username. Good for us, I guess?? If so,
|
|
# uhh, let's cross that bridge when we come to it. (At time of writing,
|
|
# there are about 60k total registered DTI users at *all*.)
|
|
raise "Failed to build unique username (all million+ names starting with " +
|
|
"\"#{base_name}\" are taken??)"
|
|
end
|
|
|
|
class AuthAlreadyConnected < ArgumentError;end
|
|
class MissingAuthInfoError < ArgumentError;end
|
|
end |