impress/app/models/auth_user.rb
Emi Matchu 58d86cf3ac Prevent user from removing all their login methods
Oh right, if you can remove your email, there's a way to fully lock out
your account:

1. Create account via NeoPass, so no password is set.
2. Ensure you have an email saved, then disconnect NeoPass.
3. Remove the email.
4. Now you have no NeoPass, no email, and no password!

In this change, we add a validation that requires an account to always
have at least one login method. This works well for the case described
above, and also helps offer server-side validation to the "can't
disconnect NeoPass until you have an email and password" stuff that
previously was only enforced by disabling the button.

That is, the following procedure could also lock you out before,
whereas now it raises the "Whoops, there was an error disconnecting
your NeoPass from your account, sorry." message:

1. Create account via NeoPass, so no password is set.
2. Ensure you have an email saved, so "Disconnect" button is enabled.
3. Open a new browser tab, and remove the email.
4. In the original browser tab, click "Disconnect".
2024-04-09 06:40:56 -07:00

216 lines
No EOL
8.1 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.
# TODO: Can we somehow get the Neopets username if one exists, instead
# of just using total randomness?
user.name = build_unique_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
# Start with a base name like "neopass-kougra-".
random_species_name = Species.all.pluck(:name).sample
base_name = "neopass-#{random_species_name}"
# 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
# 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