diff --git a/.gitignore b/.gitignore index f329d9bde..6a71678ad 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,5 @@ yarn-debug.log* # Heroku stuff .env + +/vendor/bundle diff --git a/Gemfile b/Gemfile index f90fdd686..ed251e2d7 100644 --- a/Gemfile +++ b/Gemfile @@ -29,6 +29,10 @@ gem 'active_storage_validations' # Authentication gem 'devise' +gem 'omniauth' +gem 'omniauth-rails_csrf_protection' +gem 'omniauth-google-oauth2' +gem 'omniauth-discord' gem 'authority' # Billing diff --git a/Gemfile.lock b/Gemfile.lock index 21b609256..2784747a7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -138,7 +138,11 @@ GEM activesupport (>= 6.1) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) + anonymous_loader (0.1.3) + version_gem (~> 1.1, >= 1.1.14) ansi (1.5.0) + auth-sanitizer (0.2.3) + version_gem (~> 1.1, >= 1.1.14) authority (3.3.0) activesupport (>= 3.0.0) autoprefixer-rails (10.4.21.0) @@ -1788,8 +1792,8 @@ GEM bullet (8.0.5) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) - bundler-audit (0.9.2) - bundler (>= 1.2.0, < 3) + bundler-audit (0.9.3) + bundler (>= 1.2.0) thor (~> 1.0) byebug (12.0.0) chartkick (5.1.5) @@ -1899,6 +1903,8 @@ GEM globalid (1.2.1) activesupport (>= 6.1) hashdiff (1.2.0) + hashie (5.1.0) + logger html-pipeline (2.14.3) activesupport (>= 2) nokogiri (>= 1.4) @@ -1997,6 +2003,8 @@ GEM minitest (>= 5.0) ruby-progressbar multi_json (1.15.0) + multi_xml (0.9.1) + bigdecimal (>= 3.1, < 5) multipart-post (2.4.1) mustache (1.1.1) mutex_m (0.3.0) @@ -2018,7 +2026,35 @@ GEM racc (~> 1.4) nokogiri (1.18.8-x86_64-linux-gnu) racc (~> 1.4) + oauth2 (2.0.25) + anonymous_loader (~> 0.1, >= 0.1.3) + auth-sanitizer (~> 0.2, >= 0.2.3) + faraday (>= 0.17.3, < 4.0) + jwt (>= 1.0, < 4.0) + logger (~> 1.2) + multi_xml (~> 0.5) + rack (>= 1.2, < 4) + snaky_hash (~> 2.0, >= 2.0.7) + version_gem (~> 1.1, >= 1.1.14) observer (0.1.2) + omniauth (2.1.4) + hashie (>= 3.4.6) + logger + rack (>= 2.2.3) + rack-protection + omniauth-discord (1.2.0) + omniauth-oauth2 (~> 1.6) + omniauth-google-oauth2 (1.1.3) + jwt (>= 2.0) + oauth2 (~> 2.0) + omniauth (~> 2.0) + omniauth-oauth2 (~> 1.8) + omniauth-oauth2 (1.9.0) + oauth2 (>= 2.0.2, < 3) + omniauth (~> 2.0) + omniauth-rails_csrf_protection (2.0.1) + actionpack (>= 4.2) + omniauth (~> 2.0) opus-ruby (1.0.1) ffi orm_adapter (0.5.0) @@ -2057,6 +2093,9 @@ GEM rack (2.2.13) rack-mini-profiler (3.3.1) rack (>= 1.2.0) + rack-protection (3.2.0) + base64 (>= 0.1.0) + rack (~> 2.2, >= 2.2.4) rack-proxy (0.7.7) rack rack-test (2.2.0) @@ -2189,6 +2228,9 @@ GEM redis-client (>= 0.22.2) sin_lru_redux (2.5.2) slack-notifier (2.4.0) + snaky_hash (2.0.7) + hashie (>= 0.1.0, < 6) + version_gem (~> 1.1, >= 1.1.14) spring (4.3.0) sprockets (4.2.2) concurrent-ruby (~> 1.0) @@ -2230,6 +2272,7 @@ GEM uglifier (4.2.1) execjs (>= 0.3.0, < 3) uniform_notifier (1.16.0) + version_gem (1.1.15) warden (1.2.9) rack (>= 2.0.9) web-console (4.2.1) @@ -2306,6 +2349,10 @@ DEPENDENCIES meta-tags mini_magick minitest-reporters (~> 1.1) + omniauth + omniauth-discord + omniauth-google-oauth2 + omniauth-rails_csrf_protection onebox! paperclip paranoia @@ -2350,7 +2397,7 @@ DEPENDENCIES word_count_analyzer RUBY VERSION - ruby 3.2.3p157 + ruby 3.2.3p157 BUNDLED WITH - 2.4.19 + 4.0.9 diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb new file mode 100644 index 000000000..36e8d32f3 --- /dev/null +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -0,0 +1,59 @@ +## +# Handles OAuth callbacks from external login providers (Google, Discord). +# One shared handler covers every provider: sign in if the identity is already +# linked, link it to the signed-in (or email-matched) account, or create a +# brand-new account. +class OmniauthCallbacksController < Devise::OmniauthCallbacksController + PROVIDER_NAMES = { + 'google_oauth2' => 'Google', + 'discord' => 'Discord' + }.freeze + + def handle_oauth_callback + auth = request.env['omniauth.auth'] + + # A signed-in user clicking a provider button is linking that provider to + # their account, not logging in. + return link_provider_to_current_user(auth) if user_signed_in? + + user = User.from_omniauth(auth) + + if user.persisted? + if user.new_oauth_signup + UserOnboardingService.link_pending_contributor_invites(user) + UserOnboardingService.record_referral(user, session.delete(:referral)) + end + + set_flash_message(:notice, :success, kind: provider_name) if is_navigational_format? + sign_in_and_redirect user, event: :authentication + else + error_details = " (#{user.errors.full_messages.to_sentence.downcase})" if user.errors.any? + flash[:alert] = "We couldn't create an account from your #{provider_name} login#{error_details}. Please sign up below instead." + redirect_to new_user_registration_url + end + end + + alias google_oauth2 handle_oauth_callback + alias discord handle_oauth_callback + + private + + def link_provider_to_current_user(auth) + existing = UserAuthentication.find_by(provider: auth.provider, uid: auth.uid) + + if existing.nil? + current_user.user_authentications.create(provider: auth.provider, uid: auth.uid) + flash[:notice] = "Your #{provider_name} account is now linked. You can use it to log in from now on." + elsif existing.user_id == current_user.id + flash[:notice] = "Your #{provider_name} account is already linked." + else + flash[:alert] = "That #{provider_name} account is already linked to a different Notebook.ai account." + end + + redirect_to user_more_actions_path(current_user) + end + + def provider_name + PROVIDER_NAMES.fetch(request.env.dig('omniauth.auth', 'provider').to_s, 'external') + end +end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index e95421edb..9c15f3b60 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -95,38 +95,10 @@ def set_navbar_actions protected def add_account - # Tie any universe contributor invites with this email to this user - if resource.persisted? - potential_contributor_records = Contributor.where(email: resource.email.downcase, user_id: nil) - - if potential_contributor_records.any? - potential_contributor_records.update_all(user_id: resource.id) - - # Create a notification letting the user know about each collaboration! - potential_contributor_records.each do |contributorship| - resource.notifications.create( - message_html: "
You have been added as a contributor to the #{contributorship.universe.name} universe.
", - icon: Universe.icon, - icon_color: Universe.color, - happened_at: DateTime.current, - passthrough_link: Rails.application.routes.url_helpers.universe_path(contributorship.universe), - reference_code: 'contributor-added' - ) - end - end - end + UserOnboardingService.link_pending_contributor_invites(resource) - # If the user was created in the last 60 seconds, report it to Slack - if resource.persisted? - if params[:user].key? :referral_code - referral_code = ReferralCode.where(code: params[:user][:referral_code]).first - - Referral.create( - referrer_id: referral_code.user.id, - referred_id: resource.id, - associated_code_id: referral_code.id - ) if referral_code.present? - end + if params[:user].key? :referral_code + UserOnboardingService.record_referral(resource, params[:user][:referral_code]) end end diff --git a/app/controllers/user_authentications_controller.rb b/app/controllers/user_authentications_controller.rb new file mode 100644 index 000000000..5ebe59b72 --- /dev/null +++ b/app/controllers/user_authentications_controller.rb @@ -0,0 +1,26 @@ +## +# Lets a signed-in user disconnect a linked OAuth provider from their account. +# (Connecting happens through the OmniAuth flow in OmniauthCallbacksController.) +class UserAuthenticationsController < ApplicationController + before_action :authenticate_user! + + def destroy + authentication = current_user.user_authentications.find_by(id: params[:id]) + + if authentication.nil? + redirect_to user_more_actions_path(current_user), alert: "We couldn't find that linked account." + return + end + + # Don't let users strand themselves: their last linked account can only be + # removed once they've set a password they actually know. + if current_user.oauth_only? && current_user.user_authentications.count == 1 + redirect_to user_more_actions_path(current_user), + alert: "You haven't set a password yet, so this linked account is your only way to log in. Set a password first, then disconnect it." + return + end + + authentication.destroy + redirect_to user_more_actions_path(current_user), notice: "Disconnected. You can no longer use that account to log in." + end +end diff --git a/app/helpers/devise_helper.rb b/app/helpers/devise_helper.rb index ca61f0d23..73212a373 100644 --- a/app/helpers/devise_helper.rb +++ b/app/helpers/devise_helper.rb @@ -2,7 +2,7 @@ module DeviseHelper def devise_error_messages! resource.errors.full_messages.map { |msg| content_tag(:li, msg + '.') }.join.html_safe end - + def resource_name :user end @@ -14,4 +14,13 @@ def resource def devise_mapping @devise_mapping ||= Devise.mappings[:user] end + + # OAuth providers that have credentials configured, so login/signup pages + # only show buttons that will actually work. + def configured_oauth_providers + { + google_oauth2: ENV['GOOGLE_OAUTH_CLIENT_ID'], + discord: ENV['DISCORD_CLIENT_ID'] + }.select { |_provider, client_id| client_id.present? }.keys + end end \ No newline at end of file diff --git a/app/models/users/user.rb b/app/models/users/user.rb index 642efc56a..653291a9a 100644 --- a/app/models/users/user.rb +++ b/app/models/users/user.rb @@ -6,7 +6,8 @@ class User < ApplicationRecord # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable and :omniauthable devise :database_authenticatable, :registerable, - :recoverable, :rememberable, :trackable, :validatable + :recoverable, :rememberable, :trackable, :validatable, + :omniauthable, omniauth_providers: [:google_oauth2, :discord] include HasContent include Authority::UserAbilities @@ -43,6 +44,59 @@ def on_premium_plan? has_many :contributors, dependent: :destroy + has_many :user_authentications, dependent: :destroy + + # Providers that guarantee verified email addresses, so an OAuth login can + # be safely matched to an existing account by email alone. + EMAIL_VERIFIED_OAUTH_PROVIDERS = %w(google_oauth2 discord).freeze + + # Set when from_omniauth creates a brand-new account, so callers can run + # new-account onboarding. (previously_new_record? can't be used for this: + # after_create hooks like initialize_secure_code update the row again, + # which resets it.) + attr_accessor :new_oauth_signup + + # Finds (or creates) the user for an OmniAuth callback. Returns a persisted + # user with the authentication linked, or an unpersisted user (with errors) + # when account creation fails (e.g. the provider sent no email). + def self.from_omniauth(auth) + authentication = UserAuthentication.find_by(provider: auth.provider, uid: auth.uid) + return authentication.user if authentication + + email = auth.info.email&.downcase + user = nil + + if email.present? && EMAIL_VERIFIED_OAUTH_PROVIDERS.include?(auth.provider.to_s) + user = User.find_by(email: email) + end + + if user.nil? + user = User.new( + email: email, + name: auth.info.name, + password: Devise.friendly_token[0, 20], + password_automatically_set: true + ) + user.new_oauth_signup = user.save + end + + user.user_authentications.create(provider: auth.provider, uid: auth.uid) if user.persisted? + user + end + + # True when the user signed up through OAuth and has never chosen their own + # password (so their only way into the account is a linked provider). + def oauth_only? + password_automatically_set? && user_authentications.any? + end + + # Once a user sets a real password (e.g. through the reset-password email), + # they're no longer dependent on their linked providers to log in. + after_update :clear_password_automatically_set, if: :saved_change_to_encrypted_password? + def clear_password_automatically_set + update_column(:password_automatically_set, false) if password_automatically_set? + end + has_one :referral_code, dependent: :destroy has_many :referrals, foreign_key: :referrer_id, dependent: :destroy def referrer diff --git a/app/models/users/user_authentication.rb b/app/models/users/user_authentication.rb new file mode 100644 index 000000000..6f516e6bc --- /dev/null +++ b/app/models/users/user_authentication.rb @@ -0,0 +1,9 @@ +## +# Links a User to an external OAuth identity (e.g. Google, Discord). +# A user may have multiple authentications, one per provider account. +class UserAuthentication < ApplicationRecord + belongs_to :user + + validates :provider, presence: true + validates :uid, presence: true, uniqueness: { scope: :provider } +end diff --git a/app/services/user_onboarding_service.rb b/app/services/user_onboarding_service.rb new file mode 100644 index 000000000..0e0448299 --- /dev/null +++ b/app/services/user_onboarding_service.rb @@ -0,0 +1,43 @@ +## +# Post-signup housekeeping shared by every account-creation path +# (email/password registration and OAuth signups alike). +class UserOnboardingService < Service + # Tie any universe contributor invites with this email to this user + def self.link_pending_contributor_invites(user) + return unless user.persisted? + + # Load the records up front: after update_all below, the user_id: nil + # condition no longer matches them, so re-running the query would find + # nothing to notify about. + potential_contributor_records = Contributor.where(email: user.email.downcase, user_id: nil).to_a + return unless potential_contributor_records.any? + + Contributor.where(id: potential_contributor_records.map(&:id)).update_all(user_id: user.id) + + # Create a notification letting the user know about each collaboration! + potential_contributor_records.each do |contributorship| + user.notifications.create( + message_html: "
You have been added as a contributor to the #{contributorship.universe.name} universe.
", + icon: Universe.icon, + icon_color: Universe.color, + happened_at: DateTime.current, + passthrough_link: Rails.application.routes.url_helpers.universe_path(contributorship.universe), + reference_code: 'contributor-added' + ) + end + end + + # Credit the referrer when the new user signed up through a referral link + def self.record_referral(user, code) + return unless user.persisted? && code.present? + + referral_code = ReferralCode.where(code: code).first + return if referral_code.nil? + + Referral.create( + referrer_id: referral_code.user.id, + referred_id: user.id, + associated_code_id: referral_code.id + ) + end +end diff --git a/app/views/devise/registrations/more_actions.html.erb b/app/views/devise/registrations/more_actions.html.erb index b36d1eb09..903c513af 100644 --- a/app/views/devise/registrations/more_actions.html.erb +++ b/app/views/devise/registrations/more_actions.html.erb @@ -37,6 +37,10 @@
+ <% if configured_oauth_providers.any? %> + <%= render partial: 'devise/registrations/panes/connected_accounts' %> + <% end %> + <%= form_for(current_user, as: :user, url: registration_path(:user), html: { method: :put, id: "settings-form" }) do |f| %> <% if current_user.errors.any? %>
diff --git a/app/views/devise/registrations/new.html.erb b/app/views/devise/registrations/new.html.erb index 3509aaa9f..a9672903b 100644 --- a/app/views/devise/registrations/new.html.erb +++ b/app/views/devise/registrations/new.html.erb @@ -10,6 +10,8 @@ <%= image_tag 'logos/both-small.webp', class: 'object-cover w-full h-full' %>
+ <%= render 'devise/shared/oauth_buttons' %> + <%= form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| %> <% if resource.errors.any? %>
diff --git a/app/views/devise/registrations/panes/_connected_accounts.html.erb b/app/views/devise/registrations/panes/_connected_accounts.html.erb new file mode 100644 index 000000000..bfa7f1347 --- /dev/null +++ b/app/views/devise/registrations/panes/_connected_accounts.html.erb @@ -0,0 +1,47 @@ +
+
+

Connected accounts

+

+ Link an external account to log into Notebook.ai with a single click. +

+
+ + <% if current_user.oauth_only? %> +
+ You signed up with a linked account, so you haven't set a Notebook.ai password yet. + To add one (or before disconnecting your only linked account), use + <%= link_to 'Forgot your password?', new_user_password_path, class: 'font-medium underline' %> + to set a password for <%= current_user.email %>. +
+ <% end %> + +
+ <% { google_oauth2: 'Google', discord: 'Discord' }.each do |provider, provider_label| %> + <% next unless configured_oauth_providers.include?(provider) %> + <% authentication = current_user.user_authentications.find { |ua| ua.provider == provider.to_s } %> + +
+ <%= render 'devise/shared/provider_icon', provider: provider %> +
+
<%= provider_label %>
+
+ <%= authentication ? 'Connected' : 'Not connected' %> +
+
+ + <% if authentication %> + <% if current_user.oauth_only? && current_user.user_authentications.length == 1 %> + Disconnect + <% else %> + <%= button_to 'Disconnect', user_authentication_path(authentication), method: :delete, + class: 'px-4 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-notebook-blue cursor-pointer' %> + <% end %> + <% else %> + <%= button_to 'Connect', omniauth_authorize_path(:user, provider), + class: 'inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-notebook-blue hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-notebook-blue cursor-pointer' %> + <% end %> +
+ <% end %> +
+
diff --git a/app/views/devise/sessions/_form.html.erb b/app/views/devise/sessions/_form.html.erb index 27cec696d..51a47c31f 100644 --- a/app/views/devise/sessions/_form.html.erb +++ b/app/views/devise/sessions/_form.html.erb @@ -3,6 +3,8 @@ Sign in with email
--> +<%= render 'devise/shared/oauth_buttons' %> + <%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb index 92f593fc5..da9385f54 100644 --- a/app/views/devise/sessions/new.html.erb +++ b/app/views/devise/sessions/new.html.erb @@ -5,26 +5,6 @@ <%= image_tag 'logos/both-small.webp', class: 'w-full' %>
- -
<%= render 'devise/sessions/form' %>
diff --git a/app/views/devise/shared/_links.html.erb b/app/views/devise/shared/_links.html.erb index 0dca69ea5..51681e8f5 100644 --- a/app/views/devise/shared/_links.html.erb +++ b/app/views/devise/shared/_links.html.erb @@ -18,8 +18,5 @@ <%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name) %>
<% end -%> -<%- if devise_mapping.omniauthable? %> - <%- resource_class.omniauth_providers.each do |provider| %> - <%= link_to "Sign in with #{OmniAuth::Utils.camelize(provider)}", omniauth_authorize_path(resource_name, provider) %>
- <% end -%> -<% end -%> +<%# OAuth provider buttons render via devise/shared/_oauth_buttons instead + (OmniAuth 2.x requires POST, so the old GET link_to helpers won't work) %> diff --git a/app/views/devise/shared/_oauth_buttons.html.erb b/app/views/devise/shared/_oauth_buttons.html.erb new file mode 100644 index 000000000..0d6d95c35 --- /dev/null +++ b/app/views/devise/shared/_oauth_buttons.html.erb @@ -0,0 +1,27 @@ +<% if configured_oauth_providers.any? %> +
+ <% if configured_oauth_providers.include?(:google_oauth2) %> + <%= button_to omniauth_authorize_path(:user, :google_oauth2), + form_class: 'w-full mb-3', + class: 'w-full inline-flex items-center justify-center px-4 py-3 bg-white text-blueGray-700 text-sm font-bold uppercase rounded shadow hover:shadow-md border border-blueGray-200 outline-none focus:outline-none ease-linear transition-all duration-150 cursor-pointer' do %> + <%= render 'devise/shared/provider_icon', provider: :google_oauth2 %> + Continue with Google + <% end %> + <% end %> + + <% if configured_oauth_providers.include?(:discord) %> + <%= button_to omniauth_authorize_path(:user, :discord), + form_class: 'w-full mb-3', + class: 'w-full inline-flex items-center justify-center px-4 py-3 bg-[#5865F2] text-white text-sm font-bold uppercase rounded shadow hover:shadow-md hover:bg-[#4752c4] outline-none focus:outline-none ease-linear transition-all duration-150 cursor-pointer' do %> + <%= render 'devise/shared/provider_icon', provider: :discord, fill: 'currentColor' %> + Continue with Discord + <% end %> + <% end %> + +
+
+ or +
+
+
+<% end %> diff --git a/app/views/devise/shared/_provider_icon.html.erb b/app/views/devise/shared/_provider_icon.html.erb new file mode 100644 index 000000000..d032d9e09 --- /dev/null +++ b/app/views/devise/shared/_provider_icon.html.erb @@ -0,0 +1,6 @@ +<% case provider.to_s %> +<% when 'google_oauth2' %> + +<% when 'discord' %> + +<% end %> diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 31d928f2e..fa80ce08c 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -239,6 +239,8 @@ # Add a new OmniAuth provider. Check the wiki for more information on setting # up on your models and hooks. # config.omniauth :github, 'APP_ID', 'APP_SECRET', scope: 'user,public_repo' + config.omniauth :google_oauth2, ENV['GOOGLE_OAUTH_CLIENT_ID'], ENV['GOOGLE_OAUTH_CLIENT_SECRET'] + config.omniauth :discord, ENV['DISCORD_CLIENT_ID'], ENV['DISCORD_CLIENT_SECRET'], scope: 'identify email' # ==> Warden configuration # If you want to use other strategies, that are not supported by Devise, or diff --git a/config/routes.rb b/config/routes.rb index 9548a5d5e..3fae8eb42 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -98,7 +98,12 @@ post 'customization/toggle_content_type' # User-centric stuff - devise_for :users, :controllers => { registrations: 'registrations', sessions: 'sessions' } + devise_for :users, :controllers => { + registrations: 'registrations', + sessions: 'sessions', + omniauth_callbacks: 'omniauth_callbacks' + } + resources :user_authentications, only: [:destroy] resources :users do devise_scope :user do get 'preferences', to: 'registrations#preferences' diff --git a/db/migrate/20260814000001_create_user_authentications.rb b/db/migrate/20260814000001_create_user_authentications.rb new file mode 100644 index 000000000..c23658334 --- /dev/null +++ b/db/migrate/20260814000001_create_user_authentications.rb @@ -0,0 +1,13 @@ +class CreateUserAuthentications < ActiveRecord::Migration[6.1] + def change + create_table :user_authentications do |t| + t.references :user, null: false, foreign_key: true + t.string :provider, null: false + t.string :uid, null: false + + t.timestamps + end + + add_index :user_authentications, [:provider, :uid], unique: true + end +end diff --git a/db/migrate/20260814000002_add_password_automatically_set_to_users.rb b/db/migrate/20260814000002_add_password_automatically_set_to_users.rb new file mode 100644 index 000000000..3ac496bde --- /dev/null +++ b/db/migrate/20260814000002_add_password_automatically_set_to_users.rb @@ -0,0 +1,5 @@ +class AddPasswordAutomaticallySetToUsers < ActiveRecord::Migration[6.1] + def change + add_column :users, :password_automatically_set, :boolean, default: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 92a834cd2..06a212733 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2026_04_01_171734) do +ActiveRecord::Schema.define(version: 2026_08_14_000002) do create_table "active_storage_attachments", force: :cascade do |t| t.string "name", null: false @@ -3693,6 +3693,16 @@ t.index ["user_id"], name: "index_universes_on_user_id" end + create_table "user_authentications", force: :cascade do |t| + t.integer "user_id", null: false + t.string "provider", null: false + t.string "uid", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["provider", "uid"], name: "index_user_authentications_on_provider_and_uid", unique: true + t.index ["user_id"], name: "index_user_authentications_on_user_id" + end + create_table "user_blockings", force: :cascade do |t| t.integer "user_id", null: false t.integer "blocked_user_id", null: false @@ -3774,6 +3784,7 @@ t.integer "followers_count", default: 0, null: false t.integer "following_count", default: 0, null: false t.string "time_zone", default: "UTC", null: false + t.boolean "password_automatically_set", default: false t.index ["deleted_at", "username"], name: "index_users_on_deleted_at_and_username" t.index ["deleted_at"], name: "index_users_on_deleted_at" t.index ["followers_count"], name: "index_users_on_followers_count" @@ -4266,6 +4277,7 @@ add_foreign_key "towns", "users" add_foreign_key "traditions", "universes" add_foreign_key "traditions", "users" + add_foreign_key "user_authentications", "users" add_foreign_key "user_blockings", "users" add_foreign_key "user_content_type_activators", "users" add_foreign_key "user_followings", "users" diff --git a/test/controllers/omniauth_callbacks_controller_test.rb b/test/controllers/omniauth_callbacks_controller_test.rb new file mode 100644 index 000000000..99fb24f8b --- /dev/null +++ b/test/controllers/omniauth_callbacks_controller_test.rb @@ -0,0 +1,134 @@ +require 'test_helper' + +class OmniauthCallbacksControllerTest < ActionDispatch::IntegrationTest + include Devise::Test::IntegrationHelpers + + setup do + OmniAuth.config.test_mode = true + end + + teardown do + OmniAuth.config.mock_auth[:google_oauth2] = nil + OmniAuth.config.mock_auth[:discord] = nil + OmniAuth.config.test_mode = false + end + + def mock_oauth(provider: :google_oauth2, uid: 'mock-uid', email: 'mock.user@example.com', name: 'Mock User') + OmniAuth.config.mock_auth[provider] = OmniAuth::AuthHash.new( + provider: provider.to_s, + uid: uid, + info: { email: email, name: name } + ) + end + + def complete_oauth_flow(provider: :google_oauth2) + post "/users/auth/#{provider}" + follow_redirect! + end + + def assert_signed_in(user) + assert_equal [user.id], session['warden.user.user.key']&.first, + "Expected #{user.email} to be signed in" + end + + test "signs in a user whose authentication is already linked" do + authentication = user_authentications(:user_one_google) + mock_oauth(uid: authentication.uid, email: 'anything@example.com') + + complete_oauth_flow + assert_redirected_to root_path + assert_signed_in authentication.user + end + + test "signs in an existing user by verified email and links the authentication" do + user = users(:two) + mock_oauth(uid: 'fresh-google-uid', email: user.email) + + assert_no_difference 'User.count' do + complete_oauth_flow + end + + assert_redirected_to root_path + assert_signed_in user + assert user.user_authentications.exists?(provider: 'google_oauth2', uid: 'fresh-google-uid') + end + + test "creates and signs in a brand-new user" do + mock_oauth(uid: 'new-user-uid', email: 'brand.new@example.com', name: 'Brand New') + + assert_difference 'User.count', 1 do + complete_oauth_flow + end + + assert_redirected_to root_path + + new_user = User.find_by(email: 'brand.new@example.com') + assert new_user.present? + assert_signed_in new_user + assert new_user.password_automatically_set? + assert new_user.user_authentications.exists?(provider: 'google_oauth2', uid: 'new-user-uid') + end + + test "new OAuth signups get pending contributor invites linked" do + universe_owner = users(:one) + universe = Universe.create!(name: 'Shared World', user: universe_owner) + Contributor.create!(universe: universe, email: 'invited.writer@example.com', user: nil) + + mock_oauth(uid: 'invited-uid', email: 'invited.writer@example.com') + complete_oauth_flow + + new_user = User.find_by(email: 'invited.writer@example.com') + assert new_user.present? + assert_equal new_user.id, Contributor.find_by(universe: universe).user_id + assert new_user.notifications.exists?(reference_code: 'contributor-added') + end + + test "works for discord as well" do + mock_oauth(provider: :discord, uid: 'discord-uid', email: 'discord.user@example.com') + + assert_difference 'User.count', 1 do + complete_oauth_flow(provider: :discord) + end + + new_user = User.find_by(email: 'discord.user@example.com') + assert new_user.user_authentications.exists?(provider: 'discord', uid: 'discord-uid') + end + + test "redirects to signup when the provider sends no email" do + mock_oauth(uid: 'no-email-uid', email: nil) + + assert_no_difference 'User.count' do + complete_oauth_flow + end + + assert_redirected_to new_user_registration_url + assert flash[:alert].present? + end + + test "a signed-in user linking a new provider gets it attached to their account" do + user = users(:two) + sign_in user + mock_oauth(provider: :discord, uid: 'link-me-uid', email: user.email) + + assert_no_difference 'User.count' do + complete_oauth_flow(provider: :discord) + end + + assert_redirected_to user_more_actions_path(user) + assert user.user_authentications.exists?(provider: 'discord', uid: 'link-me-uid') + end + + test "a signed-in user cannot link a provider account already linked elsewhere" do + authentication = user_authentications(:user_one_google) + user = users(:two) + sign_in user + mock_oauth(uid: authentication.uid, email: user.email) + + complete_oauth_flow + + assert_redirected_to user_more_actions_path(user) + assert_not user.user_authentications.exists?(provider: 'google_oauth2') + assert_equal users(:one).id, authentication.reload.user_id + assert flash[:alert].present? + end +end diff --git a/test/controllers/user_authentications_controller_test.rb b/test/controllers/user_authentications_controller_test.rb new file mode 100644 index 000000000..a765215ac --- /dev/null +++ b/test/controllers/user_authentications_controller_test.rb @@ -0,0 +1,57 @@ +require 'test_helper' + +class UserAuthenticationsControllerTest < ActionDispatch::IntegrationTest + include Devise::Test::IntegrationHelpers + + test "requires login" do + delete user_authentication_path(user_authentications(:user_one_google)) + assert_redirected_to new_user_session_path + end + + test "a user can disconnect a linked provider when they know their password" do + user = users(:one) + authentication = user_authentications(:user_one_google) + sign_in user + + assert_difference 'UserAuthentication.count', -1 do + delete user_authentication_path(authentication) + end + + assert_redirected_to user_more_actions_path(user) + end + + test "an oauth-only user cannot disconnect their last linked provider" do + user = users(:one) + user.update_column(:password_automatically_set, true) + authentication = user_authentications(:user_one_google) + sign_in user + + assert_no_difference 'UserAuthentication.count' do + delete user_authentication_path(authentication) + end + + assert flash[:alert].present? + end + + test "an oauth-only user can disconnect one of several linked providers" do + user = users(:one) + user.update_column(:password_automatically_set, true) + second = user.user_authentications.create!(provider: 'discord', uid: 'second-uid') + sign_in user + + assert_difference 'UserAuthentication.count', -1 do + delete user_authentication_path(second) + end + end + + test "a user cannot disconnect another user's authentication" do + sign_in users(:two) + authentication = user_authentications(:user_one_google) + + assert_no_difference 'UserAuthentication.count' do + delete user_authentication_path(authentication) + end + + assert flash[:alert].present? + end +end diff --git a/test/fixtures/user_authentications.yml b/test/fixtures/user_authentications.yml new file mode 100644 index 000000000..b87dc2bad --- /dev/null +++ b/test/fixtures/user_authentications.yml @@ -0,0 +1,6 @@ +# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +user_one_google: + user_id: 1 + provider: google_oauth2 + uid: google-uid-user-one diff --git a/test/models/user_authentication_test.rb b/test/models/user_authentication_test.rb new file mode 100644 index 000000000..dc489d394 --- /dev/null +++ b/test/models/user_authentication_test.rb @@ -0,0 +1,20 @@ +require 'test_helper' + +class UserAuthenticationTest < ActiveSupport::TestCase + test "requires provider and uid" do + authentication = UserAuthentication.new(user: users(:one)) + assert_not authentication.valid? + assert authentication.errors[:provider].any? + assert authentication.errors[:uid].any? + end + + test "uid must be unique per provider" do + existing = user_authentications(:user_one_google) + + duplicate = UserAuthentication.new(user: users(:two), provider: existing.provider, uid: existing.uid) + assert_not duplicate.valid? + + same_uid_other_provider = UserAuthentication.new(user: users(:two), provider: 'discord', uid: existing.uid) + assert same_uid_other_provider.valid? + end +end diff --git a/test/models/user_omniauth_test.rb b/test/models/user_omniauth_test.rb new file mode 100644 index 000000000..d2b422895 --- /dev/null +++ b/test/models/user_omniauth_test.rb @@ -0,0 +1,63 @@ +require 'test_helper' + +class UserOmniauthTest < ActiveSupport::TestCase + def auth_hash(provider: 'google_oauth2', uid: 'new-uid-123', email: 'oauth.user@example.com', name: 'OAuth User') + OmniAuth::AuthHash.new( + provider: provider, + uid: uid, + info: { email: email, name: name } + ) + end + + test "returns the linked user when the authentication already exists" do + authentication = user_authentications(:user_one_google) + + user = User.from_omniauth(auth_hash(uid: authentication.uid, email: 'different@example.com')) + assert_equal authentication.user, user + end + + test "links to an existing account by verified email" do + existing = users(:two) + + user = User.from_omniauth(auth_hash(uid: 'brand-new-uid', email: existing.email)) + assert_equal existing, user + assert existing.user_authentications.exists?(provider: 'google_oauth2', uid: 'brand-new-uid') + assert_not user.password_automatically_set? + end + + test "matches existing accounts case-insensitively by email" do + existing = users(:two) + + user = User.from_omniauth(auth_hash(uid: 'case-uid', email: existing.email.upcase)) + assert_equal existing, user + end + + test "creates a new account when no user matches" do + assert_difference 'User.count', 1 do + user = User.from_omniauth(auth_hash) + assert user.persisted? + assert_equal 'oauth.user@example.com', user.email + assert_equal 'OAuth User', user.name + assert user.password_automatically_set? + assert user.oauth_only? + assert user.user_authentications.exists?(provider: 'google_oauth2', uid: 'new-uid-123') + end + end + + test "returns an unpersisted user when the provider sends no email" do + assert_no_difference 'User.count' do + user = User.from_omniauth(auth_hash(email: nil)) + assert_not user.persisted? + assert user.errors[:email].any? + end + end + + test "oauth_only? is false once the user changes their password" do + user = User.from_omniauth(auth_hash(uid: 'pw-test-uid', email: 'pw.test@example.com')) + assert user.oauth_only? + + user.update(password: 'a-real-password', password_confirmation: 'a-real-password') + assert_not user.reload.password_automatically_set? + assert_not user.oauth_only? + end +end