Use Neopets username as base name for new NeoPass accounts, if possible
Yay, we got the API endpoint for this! The `linkage` scope is the key. Rather than pulling back the specific fallback behavior we had wrote for usernames before, which was slightly different and involved appending `neopass` in there too (e.g. `matchu-neopass-1234`), I figured let's just use a lot of the same logic, and just use the preferred name as the base name. (I figure the `neopass` suffix isn't that useful anyway, `matchu-1234` kinda looks better tbh! And it's all fallback stuff that I expect serious users to replace, anyway.)
This commit is contained in:
parent
9ed34fa042
commit
644b181ed0
5 changed files with 115 additions and 11 deletions
|
@ -1,4 +1,4 @@
|
||||||
class NeopassConnectionsController < ApplicationController
|
class NeoPassConnectionsController < ApplicationController
|
||||||
def destroy
|
def destroy
|
||||||
@user = load_user
|
@user = load_user
|
||||||
|
|
||||||
|
|
|
@ -156,9 +156,26 @@ class AuthUser < AuthRecord
|
||||||
find_or_create_by!(provider: auth.provider, uid: auth.uid) do |user|
|
find_or_create_by!(provider: auth.provider, uid: auth.uid) do |user|
|
||||||
# This account is new! Let's do the initial setup.
|
# This account is new! Let's do the initial setup.
|
||||||
|
|
||||||
# TODO: Can we somehow get the Neopets username if one exists, instead
|
# First, get the user's Neopets username if possible, as a base for the
|
||||||
# of just using total randomness?
|
# name we create for them. (This is an async task under the hood, which
|
||||||
user.name = build_unique_username
|
# 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)
|
||||||
|
|
||||||
# Copy the email address from their Neopets account to their DTI
|
# Copy the email address from their Neopets account to their DTI
|
||||||
# account, unless they already have a DTI account with this email, in
|
# account, unless they already have a DTI account with this email, in
|
||||||
|
@ -183,17 +200,28 @@ class AuthUser < AuthRecord
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.build_unique_username
|
def self.build_unique_username(preferred_name = nil)
|
||||||
# Start with a base name like "neopass-kougra-".
|
# If there's no preferred name provided (ideally the user's Neopets
|
||||||
random_species_name = Species.all.pluck(:name).sample
|
# username), start with a base name like `neopass-kougra`.
|
||||||
base_name = "neopass-#{random_species_name}"
|
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.
|
# Fetch the list of names that already start with that.
|
||||||
name_query = sanitize_sql_like(base_name) + "%"
|
name_query = sanitize_sql_like(base_name) + "%"
|
||||||
similar_names = where("name LIKE ?", name_query).pluck(:name).to_set
|
similar_names = where("name LIKE ?", name_query).pluck(:name).to_set
|
||||||
|
|
||||||
# Shuffle the list of four-digit numbers to create 10000 possible names,
|
# If this was the user's preferred name (rather than a random one), try
|
||||||
# then use the first one that's not already claimed.
|
# 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
|
potential_names = (0..9999).map { |n| "#{base_name}-#{n}" }.shuffle
|
||||||
name = potential_names.find { |name| !similar_names.include?(name) }
|
name = potential_names.find { |name| !similar_names.include?(name) }
|
||||||
return name unless name.nil?
|
return name unless name.nil?
|
||||||
|
|
73
app/services/neopass.rb
Normal file
73
app/services/neopass.rb
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
require "async/http/internet/instance"
|
||||||
|
|
||||||
|
# While most of our NeoPass logic is built into Devise -> OmniAuth -> OIDC
|
||||||
|
# OmniAuth plugin, NeoPass also offers some supplemental APIs that we use here.
|
||||||
|
module NeoPass
|
||||||
|
# Share a pool of persistent connections, rather than reconnecting on
|
||||||
|
# each request. (This library does that automatically!)
|
||||||
|
INTERNET = Async::HTTP::Internet.instance
|
||||||
|
|
||||||
|
def self.load_main_neopets_username(access_token)
|
||||||
|
linkages = load_linkages(access_token)
|
||||||
|
|
||||||
|
begin
|
||||||
|
# Get the Neopets.com linkages, sorted with your "main" account to the
|
||||||
|
# front. (In theory, if there are any Neopets.com linkages, then one
|
||||||
|
# should be main—but let's be resilient and be prepared for none of them
|
||||||
|
# to be marked as such!)
|
||||||
|
neopets_linkages = linkages.
|
||||||
|
select { |l| l.fetch("client_name") == "Neopets.com" }.
|
||||||
|
sort_by { |l| l.fetch("extra", {}).fetch("main") == true ? 0 : 1 }
|
||||||
|
|
||||||
|
# Read the username from that linkage, if any.
|
||||||
|
neopets_linkages.first.try { |l| l.fetch("user_identifier") }
|
||||||
|
rescue KeyError => error
|
||||||
|
raise UnexpectedResponseFormat,
|
||||||
|
"missing field #{error.key} in NeoPass linkage response"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
LINKAGE_URL = "https://oidc.neopets.com/linkage/all"
|
||||||
|
def self.load_linkages(access_token)
|
||||||
|
response = Sync do
|
||||||
|
response = INTERNET.get(LINKAGE_URL, [
|
||||||
|
["User-Agent", Rails.configuration.user_agent_for_neopets],
|
||||||
|
["Authorization", "Bearer #{access_token}"],
|
||||||
|
])
|
||||||
|
end
|
||||||
|
|
||||||
|
if response.status != 200
|
||||||
|
raise ResponseNotOK.new(response.status),
|
||||||
|
"expected status 200 but got #{response.status} (#{LINKAGE_URL})"
|
||||||
|
end
|
||||||
|
|
||||||
|
linkages_str = response.body.read
|
||||||
|
|
||||||
|
begin
|
||||||
|
linkages = JSON.parse(linkages_str)
|
||||||
|
rescue JSON::ParserError
|
||||||
|
Rails.logger.debug "Unexpected NeoPass linkage response:\n#{linkages_str}"
|
||||||
|
raise UnexpectedResponseFormat,
|
||||||
|
"failed to parse NeoPass linkage response as JSON"
|
||||||
|
end
|
||||||
|
|
||||||
|
if !linkages.is_a? Array
|
||||||
|
Rails.logger.debug "Unexpected NeoPass linkage response:\n#{linkages_str}"
|
||||||
|
raise UnexpectedResponseFormat,
|
||||||
|
"NeoPass linkage response was not an array of linkages"
|
||||||
|
end
|
||||||
|
|
||||||
|
linkages
|
||||||
|
end
|
||||||
|
|
||||||
|
class ResponseNotOK < StandardError
|
||||||
|
attr_reader :status
|
||||||
|
def initialize(status)
|
||||||
|
super
|
||||||
|
@status = status
|
||||||
|
end
|
||||||
|
end
|
||||||
|
class UnexpectedResponseFormat < StandardError;end
|
||||||
|
end
|
|
@ -278,7 +278,7 @@ Devise.setup do |config|
|
||||||
|
|
||||||
# We'll request only basic info, and we'll "discover" most of the server's
|
# We'll request only basic info, and we'll "discover" most of the server's
|
||||||
# configuration by reading its `/.well-known/openid_configuation` endpoint.
|
# configuration by reading its `/.well-known/openid_configuation` endpoint.
|
||||||
scope: [:openid, :email],
|
scope: [:openid, :email, :linkage],
|
||||||
response_type: :code,
|
response_type: :code,
|
||||||
issuer: Rails.configuration.neopass_origin,
|
issuer: Rails.configuration.neopass_origin,
|
||||||
discovery: true,
|
discovery: true,
|
||||||
|
|
|
@ -18,4 +18,7 @@
|
||||||
ActiveSupport::Inflector.inflections(:en) do |inflect|
|
ActiveSupport::Inflector.inflections(:en) do |inflect|
|
||||||
# Teach Zeitwerk that `RocketAMF` is what to expect in `lib/rocketamf`.
|
# Teach Zeitwerk that `RocketAMF` is what to expect in `lib/rocketamf`.
|
||||||
inflect.acronym "RocketAMF"
|
inflect.acronym "RocketAMF"
|
||||||
|
|
||||||
|
# Teach Zeitwerk that `NeoPass` is what to expect in `app/services/neopass.rb`.
|
||||||
|
inflect.acronym "NeoPass"
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in a new issue