DEV: Improve robustness of associate_accounts_controller

This handles a few edge cases which are extremely rare (due to the UI layout), but still technically possible:

- Ensure users are authenticated before attempting association.

- Add a message and logic for when a user already has an association for a given auth provider.
This commit is contained in:
David Taylor 2021-08-05 17:36:34 +01:00
parent 2cae29f644
commit 46dc189850
6 changed files with 64 additions and 29 deletions

View File

@ -3,9 +3,14 @@ import { ajax } from "discourse/lib/ajax";
import { next } from "@ember/runloop"; import { next } from "@ember/runloop";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
import showModal from "discourse/lib/show-modal"; import showModal from "discourse/lib/show-modal";
import cookie from "discourse/lib/cookie";
export default DiscourseRoute.extend({ export default DiscourseRoute.extend({
beforeModel() { beforeModel(transition) {
if (!this.currentUser) {
cookie("destination_url", transition.intent.url);
return this.replaceWith("login");
}
const params = this.paramsFor("associate-account"); const params = this.paramsFor("associate-account");
this.replaceWith(`preferences.account`, this.currentUser).then(() => this.replaceWith(`preferences.account`, this.currentUser).then(() =>
next(() => next(() =>

View File

@ -10,14 +10,24 @@
</div> </div>
{{/if}} {{/if}}
{{#if model.account_description}} {{#if model.existing_account_description}}
{{i18n "user.associated_accounts.confirm_description.account_specific" <p>
provider=(i18n (concat "login." model.provider_name ".name")) {{i18n "user.associated_accounts.confirm_description.disconnect"
account_description=model.account_description}} provider=(i18n (concat "login." model.provider_name ".name"))
{{else}} account_description=model.existing_account_description}}
{{i18n "user.associated_accounts.confirm_description.generic" </p>
provider=(i18n (concat "login." model.provider_name ".name"))}}
{{/if}} {{/if}}
<p>
{{#if model.account_description}}
{{i18n "user.associated_accounts.confirm_description.account_specific"
provider=(i18n (concat "login." model.provider_name ".name"))
account_description=model.account_description}}
{{else}}
{{i18n "user.associated_accounts.confirm_description.generic"
provider=(i18n (concat "login." model.provider_name ".name"))}}
{{/if}}
</p>
{{/d-modal-body}} {{/d-modal-body}}
<div class="modal-footer"> <div class="modal-footer">

View File

@ -3,41 +3,51 @@
class Users::AssociateAccountsController < ApplicationController class Users::AssociateAccountsController < ApplicationController
SECURE_SESSION_PREFIX ||= "omniauth_reconnect" SECURE_SESSION_PREFIX ||= "omniauth_reconnect"
before_action :ensure_logged_in
def connect_info def connect_info
auth = get_auth_hash account_description = authenticator.description_for_auth_hash(auth_hash)
existing_account_description = authenticator.description_for_user(current_user).presence
provider_name = auth.provider render json: {
authenticator = Discourse.enabled_authenticators.find { |a| a.name == provider_name } token: params[:token],
raise Discourse::InvalidAccess.new(I18n.t('authenticator_not_found')) if authenticator.nil? provider_name: auth_hash.provider,
account_description: account_description,
account_description = authenticator.description_for_auth_hash(auth) existing_account_description: existing_account_description
}
render json: { token: params[:token], provider_name: provider_name, account_description: account_description }
end end
def connect def connect
auth = get_auth_hash if authenticator.description_for_user(current_user).present? && authenticator.can_revoke?
secure_session[self.class.key(params[:token])] = nil authenticator.revoke(current_user)
end
provider_name = auth.provider DiscourseEvent.trigger(:before_auth, authenticator, auth_hash, session, cookies, request)
authenticator = Discourse.enabled_authenticators.find { |a| a.name == provider_name }
raise Discourse::InvalidAccess.new(I18n.t('authenticator_not_found')) if authenticator.nil?
DiscourseEvent.trigger(:before_auth, authenticator, auth, session, cookies, request)
auth_result = authenticator.after_authenticate(auth, existing_account: current_user) auth_result = authenticator.after_authenticate(auth, existing_account: current_user)
DiscourseEvent.trigger(:after_auth, authenticator, auth_result, session, cookies, request) DiscourseEvent.trigger(:after_auth, authenticator, auth_result, session, cookies, request)
secure_session[self.class.key(params[:token])] = nil
render json: success_json render json: success_json
end end
private private
def get_auth_hash def auth_hash
token = params[:token] @auth_hash ||= begin
json = secure_session[self.class.key(token)] token = params[:token]
raise Discourse::NotFound if json.nil? json = secure_session[self.class.key(token)]
raise Discourse::NotFound if json.nil?
OmniAuth::AuthHash.new(JSON.parse(json)) OmniAuth::AuthHash.new(JSON.parse(json))
end
end
def authenticator
provider_name = auth_hash.provider
authenticator = Discourse.enabled_authenticators.find { |a| a.name == provider_name }
raise Discourse::InvalidAccess.new(I18n.t('authenticator_not_found')) if authenticator.nil?
raise Discourse::InvalidAccess.new(I18n.t('authenticator_no_connect')) if !authenticator.can_connect_existing_user?
authenticator
end end
def self.key(token) def self.key(token)

View File

@ -1359,6 +1359,7 @@ en:
not_connected: "(not connected)" not_connected: "(not connected)"
confirm_modal_title: "Connect %{provider} Account" confirm_modal_title: "Connect %{provider} Account"
confirm_description: confirm_description:
disconnect: "Your existing %{provider} account '%{account_description}' will be disconnected."
account_specific: "Your %{provider} account '%{account_description}' will be used for authentication." account_specific: "Your %{provider} account '%{account_description}' will be used for authentication."
generic: "Your %{provider} account will be used for authentication." generic: "Your %{provider} account will be used for authentication."

View File

@ -286,6 +286,7 @@ en:
not_found: "The requested URL or resource could not be found." not_found: "The requested URL or resource could not be found."
invalid_access: "You are not permitted to view the requested resource." invalid_access: "You are not permitted to view the requested resource."
authenticator_not_found: "Authentication method does not exist, or has been disabled." authenticator_not_found: "Authentication method does not exist, or has been disabled."
authenticator_no_connect: "This authentication provider does not allow connection to an existing forum account."
invalid_api_credentials: "You are not permitted to view the requested resource. The API username or key is invalid." invalid_api_credentials: "You are not permitted to view the requested resource. The API username or key is invalid."
provider_not_enabled: "You are not permitted to view the requested resource. The authentication provider is not enabled." provider_not_enabled: "You are not permitted to view the requested resource. The authentication provider is not enabled."
provider_not_found: "You are not permitted to view the requested resource. The authentication provider does not exist." provider_not_found: "You are not permitted to view the requested resource. The authentication provider does not exist."

View File

@ -96,11 +96,19 @@ RSpec.describe Users::AssociateAccountsController do
end end
it "returns the correct response for non-existent tokens" do it "returns the correct response for non-existent tokens" do
sign_in(user)
get "/associate/12345678901234567890123456789012.json" get "/associate/12345678901234567890123456789012.json"
expect(response.status).to eq(404) expect(response.status).to eq(404)
get "/associate/shorttoken.json" get "/associate/shorttoken.json"
expect(response.status).to eq(404) expect(response.status).to eq(404)
end end
it "requires login" do
# XHR should 403
get "/associate/#{SecureRandom.hex}.json"
expect(response.status).to eq(403)
end
end end
end end