Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,5 @@ yarn-debug.log*

# Heroku stuff
.env

/vendor/bundle
4 changes: 4 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 51 additions & 4 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
59 changes: 59 additions & 0 deletions app/controllers/omniauth_callbacks_controller.rb
Original file line number Diff line number Diff line change
@@ -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
34 changes: 3 additions & 31 deletions app/controllers/registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<div>You have been added as a contributor to the <span class='#{Universe.text_color}'>#{contributorship.universe.name}</span> universe.</div>",
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

Expand Down
26 changes: 26 additions & 0 deletions app/controllers/user_authentications_controller.rb
Original file line number Diff line number Diff line change
@@ -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
11 changes: 10 additions & 1 deletion app/helpers/devise_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
56 changes: 55 additions & 1 deletion app/models/users/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions app/models/users/user_authentication.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading