mirror of
https://github.com/discourse/discourse.git
synced 2026-08-09 21:45:25 +08:00
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]
228 lines
8.1 KiB
Ruby
Vendored
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
|