2023-08-03 17:14:09 -07:00
|
|
|
class AuthUser < AuthRecord
|
|
|
|
self.table_name = 'users'
|
2023-08-06 15:52:05 -07:00
|
|
|
|
2023-08-06 18:03:06 -07:00
|
|
|
devise :database_authenticatable, :encryptable, :registerable, :validatable,
|
2024-03-14 15:34:24 -07:00
|
|
|
:rememberable, :trackable, :recoverable, :omniauthable,
|
2024-03-14 16:13:31 -07:00
|
|
|
omniauth_providers: [:neopass]
|
2023-08-06 15:52:05 -07:00
|
|
|
|
|
|
|
validates :name, presence: true, uniqueness: {case_sensitive: false},
|
2024-04-01 05:47:00 -07:00
|
|
|
length: {maximum: 30}
|
2024-04-08 05:33:58 -07:00
|
|
|
|
|
|
|
validates :uid, uniqueness: {scope: :provider, allow_nil: true}
|
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
|
|
|
|
|
|
|
validate :has_at_least_one_login_method
|
2023-08-06 16:08:13 -07:00
|
|
|
|
2023-08-06 16:23:22 -07:00
|
|
|
has_one :user, foreign_key: :remote_id, inverse_of: :auth_user
|
2024-04-08 03:46:41 -07:00
|
|
|
|
|
|
|
# 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? }
|
2023-08-06 16:23:22 -07:00
|
|
|
|
|
|
|
# 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.
|
2023-08-06 18:35:52 -07:00
|
|
|
after_create :create_user!
|
|
|
|
after_update :sync_name_with_user!, if: :saved_change_to_name?
|
2023-08-06 16:23:22 -07:00
|
|
|
|
2023-08-06 18:35:52 -07:00
|
|
|
def create_user!
|
|
|
|
User.create!(name: name, auth_server_id: 1, remote_id: id)
|
2023-08-06 16:08:13 -07:00
|
|
|
end
|
2023-08-06 16:23:22 -07:00
|
|
|
|
2023-08-06 18:35:52 -07:00
|
|
|
def sync_name_with_user!
|
2023-08-06 16:23:22 -07:00
|
|
|
user.name = name
|
|
|
|
user.save!
|
|
|
|
end
|
2024-03-14 19:11:06 -07:00
|
|
|
|
|
|
|
def uses_omniauth?
|
|
|
|
provider? && uid?
|
|
|
|
end
|
|
|
|
|
|
|
|
def email_required?
|
2024-04-07 08:42:41 -07:00
|
|
|
# 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
|
2024-03-14 19:11:06 -07:00
|
|
|
end
|
|
|
|
|
|
|
|
def password_required?
|
Oops, stop requiring a new password whenever AuthUser is changed
Ah right, I went and checked the Devise source code, and the default
implementation for `password_required?` is a bit trickier than I
expected:
```ruby
def password_required?
!persisted? || !password.nil? || !password_confirmation.nil?
end
```
Looks like `super` does a good enough job here, though! (I'm actually
kinda surprised, I wasn't sure how Ruby's `super` rules worked, and
this isn't a subclass thing—or maybe it is, maybe the `devise` method
adds a mixin? Idk! But it does what I expect, so, great!)
So now, we require the password if 1) Devise doesn't see a UI reason
not to, *and* 2) the user isn't using OmniAuth (i.e. NeoPass).
This had caused a bug where it was impossible to use the Settings page
*without* changing your password! (The form says it's okay to leave it
blank, which stopped being true! But now it's fixed!)
2024-03-14 19:19:56 -07:00
|
|
|
super && !uses_omniauth?
|
2024-03-14 19:11:06 -07:00
|
|
|
end
|
|
|
|
|
2024-04-07 08:12:38 -07:00
|
|
|
def uses_neopass?
|
2024-04-07 07:17:33 -07:00
|
|
|
provider == "neopass"
|
|
|
|
end
|
|
|
|
|
|
|
|
def neopass_friendly_id
|
|
|
|
neopass_email || uid
|
|
|
|
end
|
|
|
|
|
2024-04-07 08:27:02 -07:00
|
|
|
def uses_password?
|
|
|
|
encrypted_password?
|
|
|
|
end
|
|
|
|
|
2024-04-09 06:20:13 -07:00
|
|
|
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
|
|
|
|
|
2024-04-08 05:33:58 -07:00
|
|
|
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
|
|
|
|
|
2024-04-07 07:52:23 -07:00
|
|
|
def disconnect_neopass
|
|
|
|
# If there's no NeoPass, we're already done!
|
2024-04-07 08:12:38 -07:00
|
|
|
return true if !uses_neopass?
|
2024-04-07 07:52:23 -07:00
|
|
|
|
|
|
|
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
|
|
|
|
|
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
|
|
|
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
|
|
|
|
|
2024-04-08 05:33:58 -07:00
|
|
|
def self.find_by_omniauth(auth)
|
|
|
|
find_by(provider: auth.provider, uid: auth.uid)
|
|
|
|
end
|
|
|
|
|
2024-03-14 19:11:06 -07:00
|
|
|
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|
|
2024-04-07 07:17:07 -07:00
|
|
|
# This account is new! Let's do the initial setup.
|
|
|
|
|
2024-04-09 07:48:13 -07:00
|
|
|
# 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
|
|
|
|
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)
|
2024-03-14 19:11:06 -07:00
|
|
|
|
|
|
|
# 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
|
2024-04-09 05:45:39 -07:00
|
|
|
|
|
|
|
# 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
|
2024-04-07 07:17:07 -07:00
|
|
|
end.tap do |user|
|
2024-04-08 05:00:27 -07:00
|
|
|
# If this account already existed, make sure we've saved the latest
|
2024-04-09 05:45:39 -07:00
|
|
|
# email to `neopass_email`. (In practice, this *shouldn't* ever change
|
|
|
|
# after initial setup, because NeoPass emails are immutable? But why
|
|
|
|
# not be resilient!)
|
2024-04-08 05:00:27 -07:00
|
|
|
unless user.previously_new_record?
|
|
|
|
user.update!(neopass_email: auth.info.email)
|
|
|
|
end
|
2024-03-14 19:11:06 -07:00
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2024-04-09 07:48:13 -07:00
|
|
|
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
|
2024-04-01 05:57:06 -07:00
|
|
|
|
|
|
|
# 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
|
|
|
|
|
2024-04-09 07:48:13 -07:00
|
|
|
# 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.
|
2024-04-01 05:57:06 -07:00
|
|
|
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??)"
|
2024-03-14 19:11:06 -07:00
|
|
|
end
|
|
|
|
|
2024-04-08 05:33:58 -07:00
|
|
|
class AuthAlreadyConnected < ArgumentError;end
|
2024-03-14 19:11:06 -07:00
|
|
|
class MissingAuthInfoError < ArgumentError;end
|
2023-08-03 17:14:09 -07:00
|
|
|
end
|