0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-09 21:45:25 +08:00
discourse/app/controllers/users/omniauth_callbacks_controller.rb
Chris Alberti aa157f5ea5
FIX: Delete destination_url cookie when it's used to set origin param during redirect to social auth (#36194)
When we navigate out to a social auth method, we set the origin param on
that request to the value of the destination_url.

Upon returning to Discourse, that origin param is returned and [we use
it to
set](4dd3becec1/config/initializers/009-omniauth.rb (L24))
server_session["destination_url"].

Then during the [omniauth
callback](4dd3becec1/app/controllers/users/omniauth_callbacks_controller.rb (L43)),
we use the server_session["destination_url"] value to redirect back to
the page the destination_url cookie originally pointed to.

The problem is that in this scenario, the destination_url cookie never
gets deleted. This was causing a bug with Discourse ID in a corner case
where [the discourse-login plugin sets the destination_url
cookie](646e32f4a6/config/initializers/doorkeeper.rb (L38))
to a url that contains an OAuth state parameter which can become stale,
and the cookie keeps getting re-used for redirection but not deleted,
resulting in an error message "Sorry, the authorization timed out, or
you have switched browsers. Please try again." which the user can't
escape.

The solution is to make sure that when we return to the omniauth callback we always clean up both the server_session[:destination_url] and cookies[:destination_url]
2025-11-25 09:15:03 -06:00

228 lines
8.1 KiB
Ruby
Vendored

# frozen_string_literal: true
class Users::OmniauthCallbacksController < ApplicationController
skip_before_action :redirect_to_login_if_required, :redirect_to_profile_if_required
layout "no_ember"
# need to be able to call this
skip_before_action :check_xhr
# this is the only spot where we allow CSRF, our openid / oauth redirect
# will not have a CSRF token, however the payload is all validated so its safe
skip_before_action :verify_authenticity_token, only: :complete
# These are usually GET requests but some providers use POST requests
allow_in_staff_writes_only_mode :complete
def confirm_request
self.class.find_authenticator(params[:provider])
render locals: { hide_auth_buttons: true }
end
def complete
raise Discourse::ReadOnly if @readonly_mode && !@staff_writes_only_mode
raise Discourse::NotFound unless auth = request.env["omniauth.auth"]
auth[:session] = session
authenticator = self.class.find_authenticator(params[:provider])
if session.delete(:auth_reconnect) && authenticator.can_connect_existing_user? && current_user
return redirect_to persist_auth_token(auth)
else
DiscourseEvent.trigger(:before_auth, authenticator, auth, session, cookies, request)
@auth_result = authenticator.after_authenticate(auth)
@auth_result.user = nil if @auth_result&.user&.staged # Treat staged users the same as unregistered users
DiscourseEvent.trigger(:after_auth, authenticator, @auth_result, session, cookies, request)
end
preferred_origin = request.env["omniauth.origin"]
session.delete(:destination_url) # Clean up old values. TODO: Remove after March 2026
if server_session[:destination_url].present?
preferred_origin = server_session[:destination_url]
elsif SiteSetting.enable_discourse_connect_provider && payload = cookies.delete(:sso_payload)
preferred_origin = session_sso_provider_url + "?" + payload
elsif cookies[:destination_url].present?
preferred_origin = cookies[:destination_url]
end
server_session.delete(:destination_url)
cookies.delete(:destination_url)
if preferred_origin.present?
parsed =
begin
URI.parse(preferred_origin)
rescue URI::Error
end
if valid_origin?(parsed)
@origin = +"#{parsed.path}"
@origin << "?#{parsed.query}" if parsed.query
end
end
@origin = Discourse.base_path("/") if @origin.blank?
@auth_result.destination_url = @origin
@auth_result.authenticator_name = authenticator.name
return render_auth_result_failure if @auth_result.failed?
raise Discourse::ReadOnly if @staff_writes_only_mode && !@auth_result.user&.staff?
complete_response_data
return render_auth_result_failure if @auth_result.failed?
client_hash = @auth_result.to_client_hash
if authenticator.can_connect_existing_user? &&
(SiteSetting.enable_local_logins || Discourse.enabled_authenticators.count > 1)
# There is more than one login method, and users are allowed to manage associations themselves
client_hash[:associate_url] = persist_auth_token(auth)
end
cookies["_bypass_cache"] = true
cookies[:authentication_data] = { value: client_hash.to_json, path: Discourse.base_path("/") }
# If the user account doesn't already exist, and this signup is part of a user-api-key request, direct
# the user to the homepage first so that signup can be completed. They'll be redirected to the user-api-key
# route once signup is complete. Hopefully, this can be simplified once fullscreen login/signup is everywhere.
if !current_user && @origin.start_with?("/user-api-key/new")
redirect_to path("/")
else
redirect_to @origin
end
end
def valid_origin?(uri)
return false if uri.nil?
return false if uri.host.present? && uri.host != Discourse.current_hostname
return false if uri.path.start_with?("#{Discourse.base_path}/auth/")
return false if uri.path.start_with?("#{Discourse.base_path}/login")
true
end
ALLOWED_FAILURE_ERRORS = %w[csrf_detected request_error invalid_iat].index_by { _1 }
def failure
error_name = params[:message].to_s.gsub(/[^\w-]/, "").presence
error = ALLOWED_FAILURE_ERRORS.fetch(error_name, "generic")
if error == "generic"
provider_name = params[:provider].presence || params[:strategy].presence
provider = Discourse.enabled_authenticators.find { _1.name == provider_name }&.display_name
if provider.blank? && Discourse.enabled_authenticators.one?
provider = Discourse.enabled_authenticators[0].display_name
end
error = provider.present? ? "generic_with_provider" : "generic_without_provider"
end
flash[:error] = I18n.t("login.omniauth_error.#{error}", provider:).html_safe
render "failure"
end
def self.find_authenticator(name)
if SiteSetting.enable_discourse_connect
raise Discourse::InvalidAccess.new(I18n.t("authenticator_not_found"))
end
Discourse.enabled_authenticators.each do |authenticator|
return authenticator if authenticator.name == name
end
raise Discourse::InvalidAccess.new(I18n.t("authenticator_not_found"))
end
protected
def render_auth_result_failure
flash[:error] = @auth_result.failed_reason.html_safe
render "failure"
end
def complete_response_data
if @auth_result.user
user_found(@auth_result.user)
elsif invite_required?
@auth_result.requires_invite = true
else
server_session[:authentication] = @auth_result.session_data
end
end
def invite_required?
if SiteSetting.invite_only?
path = Discourse.route_for(@origin)
return true unless path
return true if path[:controller] != "invites" && path[:action] != "show"
!Invite.exists?(invite_key: path[:id])
end
end
def user_found(user)
if user.has_any_second_factor_methods_enabled? &&
SiteSetting.enforce_second_factor_on_external_auth
@auth_result.omniauth_disallow_totp = true
@auth_result.email = user.email
return
end
handle_account_activation(user)
end
def handle_account_activation(user)
# automatically activate any account if a provider marked the email valid
if @auth_result.email_valid && @auth_result.email == user.email
if !user.active || !user.email_confirmed?
user.update!(password: SecureRandom.hex)
# Ensure there is an active email token
if !EmailToken.where(email: user.email, confirmed: true).exists? &&
!user.email_tokens.active.where(email: user.email).exists?
user.email_tokens.create!(email: user.email, scope: EmailToken.scopes[:signup])
end
user.activate
end
if user.registration_ip_address.blank?
user.update!(registration_ip_address: request.remote_ip)
end
end
if ScreenedIpAddress.should_block?(request.remote_ip)
@auth_result.not_allowed_from_ip_address = true
elsif ScreenedIpAddress.block_admin_login?(user, request.remote_ip)
@auth_result.admin_not_allowed_from_ip_address = true
elsif Guardian.new(user).can_access_forum? && user.active # log on any account that is active with forum access
begin
user.save! if @auth_result.apply_user_attributes!
@auth_result.apply_associated_attributes!
rescue ActiveRecord::RecordInvalid => e
@auth_result.failed = true
@auth_result.failed_reason = e.record.errors.full_messages.join(", ")
return
end
log_on_user(user, { authenticated_with_oauth: true })
Invite.invalidate_for_email(user.email) # invite link can't be used to log in anymore
server_session.delete(:authentication) # don't carry around old auth info
@auth_result.authenticated = true
else
if SiteSetting.must_approve_users? && !user.approved?
@auth_result.awaiting_approval = true
else
@auth_result.awaiting_activation = true
end
end
end
def persist_auth_token(auth)
secret = SecureRandom.hex
key = Users::AssociateAccountsController.key(secret)
server_session.set(key, auth.to_json, expires: 10.minutes)
"#{Discourse.base_path}/associate/#{secret}"
end
end