Compare commits
No commits in common. "644b181ed00bccc48b57f7776f43b47794b6e2c1" and "0a046ed9c1aaa68656083d506dd95f55e091403f" have entirely different histories.
644b181ed0
...
0a046ed9c1
11 changed files with 39 additions and 193 deletions
|
@ -14,9 +14,7 @@ class AuthUsersController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
# For the edit form, the auth user *is* the persisted auth user.
|
@auth_user = current_auth_user
|
||||||
@persisted_auth_user = current_auth_user
|
|
||||||
@auth_user = @persisted_auth_user
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def new
|
def new
|
||||||
|
@ -24,18 +22,15 @@ class AuthUsersController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
# When updating, we hold onto the original `@persisted_auth_user`, then
|
|
||||||
# make our changes to `@auth_user`. That way, the form can check the *live*
|
|
||||||
# value of `uses_password?` to decide whether to show the "Current
|
|
||||||
# password" field, instead of getting thrown off if the password changed
|
|
||||||
# but the record didn't get saved.
|
|
||||||
#
|
|
||||||
# HACK: Is there a way to get the kind of copy we want for real? `dup`
|
|
||||||
# actually returns a *new* unsaved record with the same attributes.
|
|
||||||
@auth_user = load_auth_user
|
@auth_user = load_auth_user
|
||||||
@persisted_auth_user = @auth_user.dup
|
|
||||||
|
|
||||||
if @auth_user.update_with_password(auth_user_params)
|
# If the user has a password, then the `current_password` field is required
|
||||||
|
# when updating. If not, then it's not!
|
||||||
|
success = @auth_user.uses_password? ?
|
||||||
|
@auth_user.update_with_password(auth_user_params) :
|
||||||
|
@auth_user.update(auth_user_params)
|
||||||
|
|
||||||
|
if success
|
||||||
# NOTE: Changing the password will sign you out, so make sure we stay
|
# NOTE: Changing the password will sign you out, so make sure we stay
|
||||||
# signed in!
|
# signed in!
|
||||||
bypass_sign_in @auth_user, scope: :auth_user
|
bypass_sign_in @auth_user, scope: :auth_user
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
class NeoPassConnectionsController < ApplicationController
|
class NeopassConnectionsController < ApplicationController
|
||||||
def destroy
|
def destroy
|
||||||
@user = load_user
|
@user = load_user
|
||||||
|
|
||||||
|
|
|
@ -69,17 +69,8 @@ module ApplicationHelper
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Add ?neopass=1 to the URL, or set the `neopass_access_secret=1` cookie, to
|
|
||||||
# view NeoPass features. (NOTE: In production, this is a secret value
|
|
||||||
# instead!)
|
|
||||||
#
|
|
||||||
# NOTE: We intentionally don't e.g. set the cookie just because you went to
|
|
||||||
# the secret URL once, to avoid demo users getting confused about
|
|
||||||
# whether NeoPass is publicly available: if they go to the public page,
|
|
||||||
# they should NOT see NeoPass anymore, rather than think it's live!
|
|
||||||
def can_use_neopass
|
def can_use_neopass
|
||||||
params[:neopass] == Rails.configuration.neopass_access_secret ||
|
params[:neopass] == Rails.configuration.neopass_access_secret
|
||||||
cookies[:neopass_access_secret] == Rails.configuration.neopass_access_secret
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def contact_email
|
def contact_email
|
||||||
|
|
|
@ -9,8 +9,6 @@ class AuthUser < AuthRecord
|
||||||
length: {maximum: 30}
|
length: {maximum: 30}
|
||||||
|
|
||||||
validates :uid, uniqueness: {scope: :provider, allow_nil: true}
|
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
|
has_one :user, foreign_key: :remote_id, inverse_of: :auth_user
|
||||||
|
|
||||||
|
@ -83,23 +81,6 @@ class AuthUser < AuthRecord
|
||||||
encrypted_password?
|
encrypted_password?
|
||||||
end
|
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)
|
def connect_omniauth!(auth)
|
||||||
raise MissingAuthInfoError, "Email missing" if auth.info.email.blank?
|
raise MissingAuthInfoError, "Email missing" if auth.info.email.blank?
|
||||||
|
|
||||||
|
@ -138,13 +119,6 @@ class AuthUser < AuthRecord
|
||||||
end
|
end
|
||||||
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)
|
def self.find_by_omniauth(auth)
|
||||||
find_by(provider: auth.provider, uid: auth.uid)
|
find_by(provider: auth.provider, uid: auth.uid)
|
||||||
end
|
end
|
||||||
|
@ -156,26 +130,9 @@ 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.
|
||||||
|
|
||||||
# First, get the user's Neopets username if possible, as a base for the
|
# TODO: Can we somehow get the Neopets username if one exists, instead
|
||||||
# name we create for them. (This is an async task under the hood, which
|
# of just using total randomness?
|
||||||
# means we can wrap it in a `with_timeout` block!)
|
user.name = build_unique_username
|
||||||
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,16 +140,14 @@ class AuthUser < AuthRecord
|
||||||
# password recovery!)
|
# password recovery!)
|
||||||
email_exists = AuthUser.where(email: auth.info.email).exists?
|
email_exists = AuthUser.where(email: auth.info.email).exists?
|
||||||
user.email = auth.info.email unless 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|
|
end.tap do |user|
|
||||||
# If this account already existed, make sure we've saved the latest
|
# If this account already existed, make sure we've saved the latest
|
||||||
# email to `neopass_email`. (In practice, this *shouldn't* ever change
|
# email to `neopass_email`.
|
||||||
# after initial setup, because NeoPass emails are immutable? But why
|
#
|
||||||
# not be resilient!)
|
# We track this separately from `email`, which the user can edit, to
|
||||||
|
# use in the Settings UI to indicate what NeoPass you're linked to. (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?
|
unless user.previously_new_record?
|
||||||
user.update!(neopass_email: auth.info.email)
|
user.update!(neopass_email: auth.info.email)
|
||||||
end
|
end
|
||||||
|
@ -200,28 +155,17 @@ class AuthUser < AuthRecord
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.build_unique_username(preferred_name = nil)
|
def self.build_unique_username
|
||||||
# If there's no preferred name provided (ideally the user's Neopets
|
# Start with a base name like "neopass-kougra-".
|
||||||
# username), start with a base name like `neopass-kougra`.
|
random_species_name = Species.all.pluck(:name).sample
|
||||||
if preferred_name.present?
|
base_name = "neopass-#{random_species_name}"
|
||||||
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
|
||||||
|
|
||||||
# If this was the user's preferred name (rather than a random one), try
|
# Shuffle the list of four-digit numbers to create 10000 possible names,
|
||||||
# using the preferred name itself, if not already taken.
|
# then use the first one that's not already claimed.
|
||||||
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?
|
||||||
|
|
|
@ -158,9 +158,7 @@ class Pet < ApplicationRecord
|
||||||
# Return the response body as a `HashWithIndifferentAccess`.
|
# Return the response body as a `HashWithIndifferentAccess`.
|
||||||
def self.send_amfphp_request(request, timeout: 10)
|
def self.send_amfphp_request(request, timeout: 10)
|
||||||
begin
|
begin
|
||||||
response = request.post(timeout: timeout, headers: {
|
response = request.post(timeout: timeout)
|
||||||
"User-Agent" => Rails.configuration.user_agent_for_neopets,
|
|
||||||
})
|
|
||||||
rescue RocketAMFExtensions::RemoteGateway::AMFError => e
|
rescue RocketAMFExtensions::RemoteGateway::AMFError => e
|
||||||
raise DownloadError, e.message
|
raise DownloadError, e.message
|
||||||
rescue RocketAMFExtensions::RemoteGateway::ConnectionError => e
|
rescue RocketAMFExtensions::RemoteGateway::ConnectionError => e
|
||||||
|
|
|
@ -1,73 +0,0 @@
|
||||||
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
|
|
|
@ -72,9 +72,7 @@ module NeopetsMediaArchive
|
||||||
# We use this in the `swf_assets:manifests:load` task to perform many
|
# We use this in the `swf_assets:manifests:load` task to perform many
|
||||||
# requests in parallel!
|
# requests in parallel!
|
||||||
Sync do
|
Sync do
|
||||||
response = INTERNET.get(uri, [
|
response = INTERNET.get(uri)
|
||||||
["User-Agent", Rails.configuration.user_agent_for_neopets],
|
|
||||||
])
|
|
||||||
if response.status != 200
|
if response.status != 200
|
||||||
raise ResponseNotOK.new(response.status),
|
raise ResponseNotOK.new(response.status),
|
||||||
"expected status 200 but got #{response.status} (#{uri})"
|
"expected status 200 but got #{response.status} (#{uri})"
|
||||||
|
|
|
@ -36,9 +36,7 @@ module OwlsValueGuide
|
||||||
|
|
||||||
url = ITEMDATA_URL_TEMPLATE.expand(item_name: item_name)
|
url = ITEMDATA_URL_TEMPLATE.expand(item_name: item_name)
|
||||||
begin
|
begin
|
||||||
res = get(url, headers: {
|
res = get(url)
|
||||||
"User-Agent" => Rails.configuration.user_agent_for_neopets,
|
|
||||||
})
|
|
||||||
rescue StandardError => error
|
rescue StandardError => error
|
||||||
raise NetworkError, "Couldn't connect to Owls: #{error.message}"
|
raise NetworkError, "Couldn't connect to Owls: #{error.message}"
|
||||||
end
|
end
|
||||||
|
|
|
@ -39,7 +39,7 @@
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<%# Current password is only required if you have one! %>
|
<%# Current password is only required if you have one! %>
|
||||||
<% if @persisted_auth_user.uses_password? %>
|
<% if @auth_user.uses_password? %>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<%= f.label :current_password %>
|
<%= f.label :current_password %>
|
||||||
|
@ -55,14 +55,13 @@
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% if @persisted_auth_user.uses_neopass? %>
|
<% if @auth_user.uses_neopass? %>
|
||||||
<%= form_with url: user_neopass_connection_path(@auth_user.user),
|
<%= form_with url: user_neopass_connection_path(@auth_user.user),
|
||||||
method: :delete, class: "settings-form", data: {
|
method: :delete, class: "settings-form", data: {
|
||||||
turbo_confirm: "Are you sure? Without a NeoPass, you'll need to use " +
|
turbo_confirm: "Are you sure? Without a NeoPass, you'll need to use " +
|
||||||
"your password or your recovery email " +
|
"your password or your recovery email \"#{@auth_user.email}\" to " +
|
||||||
"\"#{@persisted_auth_user.email}\" to log in again.\n\nMake sure " +
|
"log in again.\n\nMake sure you have everything all set up first! " +
|
||||||
"you have everything all set up first! Otherwise, you might be " +
|
"Otherwise, you might be locked out of this account forever!"
|
||||||
"locked out of this account forever!"
|
|
||||||
} do |form|
|
} do |form|
|
||||||
%>
|
%>
|
||||||
<h2>Your NeoPass</h2>
|
<h2>Your NeoPass</h2>
|
||||||
|
@ -70,7 +69,7 @@
|
||||||
<strong>
|
<strong>
|
||||||
NeoPass ID:
|
NeoPass ID:
|
||||||
</strong>
|
</strong>
|
||||||
<%= @persisted_auth_user.neopass_friendly_id %>
|
<%= @auth_user.neopass_friendly_id %>
|
||||||
</section>
|
</section>
|
||||||
<section class="neopass-explanation">
|
<section class="neopass-explanation">
|
||||||
<p>
|
<p>
|
||||||
|
@ -79,24 +78,23 @@
|
||||||
you can still use "Forgot your password?" to recover your Dress to
|
you can still use "Forgot your password?" to recover your Dress to
|
||||||
Impress account, using the Email saved in "Your info".
|
Impress account, using the Email saved in "Your info".
|
||||||
</p>
|
</p>
|
||||||
<% if !@persisted_auth_user.uses_password? && !@persisted_auth_user.email? %>
|
<% if !@auth_user.uses_password? && !@auth_user.email %>
|
||||||
<p>
|
<p>
|
||||||
You can't remove this NeoPass yet, because you need to either set a
|
You can't remove this NeoPass yet, because you need to either set a
|
||||||
password or a recovery email first. (Ideally both!)
|
password or a recovery email first. (Ideally both!)
|
||||||
</p>
|
</p>
|
||||||
<% elsif !@persisted_auth_user.uses_password? %>
|
<% elsif !@auth_user.uses_password? %>
|
||||||
<p>
|
<p>
|
||||||
Be extra careful here! Your account doesn't have a password set.
|
Be extra careful here! Your account doesn't have a password set.
|
||||||
</p>
|
</p>
|
||||||
<% elsif !@persisted_auth_user.email? %>
|
<% elsif !@auth_user.email? %>
|
||||||
<p>
|
<p>
|
||||||
Be extra careful here! Your account doesn't have an email set.
|
Be extra careful here! Your account doesn't have an email set.
|
||||||
</p>
|
</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
</section>
|
</section>
|
||||||
<%= form.submit "Disconnect your NeoPass",
|
<%= form.submit "Disconnect your NeoPass",
|
||||||
disabled: !@persisted_auth_user.uses_password? &&
|
disabled: !@auth_user.uses_password? && !@auth_user.email? %>
|
||||||
!@persisted_auth_user.email? %>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
<% elsif can_use_neopass %>
|
<% elsif can_use_neopass %>
|
||||||
<%= form_with url: auth_user_neopass_omniauth_authorize_path(intent: "connect"),
|
<%= form_with url: auth_user_neopass_omniauth_authorize_path(intent: "connect"),
|
||||||
|
|
|
@ -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, :linkage],
|
scope: [:openid, :email],
|
||||||
response_type: :code,
|
response_type: :code,
|
||||||
issuer: Rails.configuration.neopass_origin,
|
issuer: Rails.configuration.neopass_origin,
|
||||||
discovery: true,
|
discovery: true,
|
||||||
|
|
|
@ -18,7 +18,4 @@
|
||||||
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