SECURITY: Add confirmation screen when connecting associated accounts

This commit is contained in:
David Taylor
2019-07-17 12:34:02 +01:00
parent da4c1c5afc
commit 0a6cae654b
15 changed files with 235 additions and 27 deletions

View File

@@ -0,0 +1,26 @@
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import ModalFunctionality from "discourse/mixins/modal-functionality";
export default Ember.Controller.extend(ModalFunctionality, {
actions: {
finishConnect() {
ajax({
url: `/associate/${encodeURIComponent(this.model.token)}`,
type: "POST"
})
.then(result => {
if (result.success) {
this.transitionToRoute(
"preferences.account",
this.currentUser.findDetails()
);
this.send("closeModal");
} else {
this.set("model.error", result.error);
}
})
.catch(popupAjaxError);
}
}
});

View File

@@ -20,6 +20,7 @@ export default Ember.Controller.extend(
this._super(...arguments);
this.saveAttrNames = ["name", "title"];
this.set("revoking", {});
},
canEditName: setting("enable_names"),
@@ -32,6 +33,8 @@ export default Ember.Controller.extend(
showAllAuthTokens: false,
revoking: null,
cannotDeleteAccount: Ember.computed.not("currentUser.can_delete_account"),
deleteDisabled: Ember.computed.or(
"model.isSaving",
@@ -202,7 +205,7 @@ export default Ember.Controller.extend(
},
revokeAccount(account) {
this.set("revoking", true);
this.set(`revoking.${account.name}`, true);
this.model
.revokeAssociatedAccount(account.name)
@@ -214,7 +217,7 @@ export default Ember.Controller.extend(
}
})
.catch(popupAjaxError)
.finally(() => this.set("revoking", false));
.finally(() => this.set(`revoking.${account.name}`, false));
},
toggleShowAllAuthTokens() {

View File

@@ -29,7 +29,7 @@ const LoginMethod = Ember.Object.extend({
authUrl += "?reconnect=true";
}
if (fullScreenLogin || this.full_screen_login) {
if (reconnect || fullScreenLogin || this.full_screen_login) {
document.cookie = "fsl=true";
window.location = authUrl;
} else {

View File

@@ -178,6 +178,7 @@ export default function() {
this.route("signup", { path: "/signup" });
this.route("login", { path: "/login" });
this.route("email-login", { path: "/session/email-login/:token" });
this.route("associate-account", { path: "/associate/:token" });
this.route("login-preferences");
this.route("forgot-password", { path: "/password-reset" });
this.route("faq", { path: "/faq" });

View File

@@ -0,0 +1,16 @@
import { ajax } from "discourse/lib/ajax";
import showModal from "discourse/lib/show-modal";
import { popupAjaxError } from "discourse/lib/ajax-error";
export default Discourse.Route.extend({
beforeModel() {
const params = this.paramsFor("associate-account");
this.replaceWith(`preferences.account`, this.currentUser).then(() =>
Ember.run.next(() =>
ajax(`/associate/${encodeURIComponent(params.token)}`)
.then(model => showModal("associate-account-confirm", { model }))
.catch(popupAjaxError)
)
);
}
});

View File

@@ -0,0 +1,21 @@
{{#d-modal-body
rawTitle=(
i18n "user.associated_accounts.confirm_modal_title"
provider=(i18n (concat "login." model.provider_name ".name"))
)
}}
{{#if model.error}}
<div class='alert alert-error'>
{{model.error}}
</div>
{{/if}}
{{i18n "user.associated_accounts.confirm_description"
provider=(i18n (concat "login." model.provider_name ".name"))
account_description=model.account_description}}
{{/d-modal-body}}
<div class="modal-footer">
{{d-button label="user.associated_accounts.connect" action=(action "finishConnect") class="btn-primary" icon="plug"}}
{{d-button label="user.associated_accounts.cancel" action=(action "closeModal")}}
</div>

View File

@@ -111,9 +111,7 @@
<td>{{authProvider.account.description}}</td>
<td>
{{#if authProvider.method.can_revoke}}
{{#conditional-loading-spinner condition=revoking size='small'}}
{{d-button action=(action "revokeAccount") actionParam=authProvider.account title="user.associated_accounts.revoke" class="btn-danger no-text" icon="trash-alt" }}
{{/conditional-loading-spinner}}
{{d-button action=(action "revokeAccount") actionParam=authProvider.account title="user.associated_accounts.revoke" class="btn-danger no-text" icon="trash-alt" disabled=(get revoking authProvider.method.name) }}
{{/if}}
</td>
</tr>

View File

@@ -0,0 +1,45 @@
# frozen_string_literal: true
class Users::AssociateAccountsController < ApplicationController
REDIS_PREFIX ||= "omniauth_reconnect"
##
# Presents a confirmation screen to the user. Accessed via GET, with no CSRF checks
def connect_info
auth = get_auth_hash
provider_name = auth.provider
authenticator = Discourse.enabled_authenticators.find { |a| a.name == provider_name }
raise Discourse::InvalidAccess.new(I18n.t('authenticator_not_found')) if authenticator.nil?
account_description = authenticator.description_for_auth_hash(auth)
render json: { token: params[:token], provider_name: provider_name, account_description: account_description }
end
##
# Presents a confirmation screen to the user. Accessed via GET, with no CSRF checks
def connect
auth = get_auth_hash
$redis.del "#{REDIS_PREFIX}_#{current_user&.id}_#{params[:token]}"
provider_name = auth.provider
authenticator = Discourse.enabled_authenticators.find { |a| a.name == provider_name }
raise Discourse::InvalidAccess.new(I18n.t('authenticator_not_found')) if authenticator.nil?
auth_result = authenticator.after_authenticate(auth, existing_account: current_user)
DiscourseEvent.trigger(:after_auth, authenticator, auth_result)
render json: success_json
end
private
def get_auth_hash
token = params[:token]
json = $redis.get "#{REDIS_PREFIX}_#{current_user&.id}_#{token}"
raise Discourse::NotFound if json.nil?
OmniAuth::AuthHash.new(JSON.parse(json))
end
end

View File

@@ -28,20 +28,10 @@ class Users::OmniauthCallbacksController < ApplicationController
provider = DiscoursePluginRegistry.auth_providers.find { |p| p.name == params[:provider] }
if session.delete(:auth_reconnect) && authenticator.can_connect_existing_user? && current_user
# If we're reconnecting, don't actually try and log the user in
@auth_result = authenticator.after_authenticate(auth, existing_account: current_user)
if provider&.full_screen_login || cookies['fsl']
cookies.delete('fsl')
DiscourseEvent.trigger(:after_auth, authenticator, @auth_result)
return redirect_to Discourse.base_uri("/my/preferences/account")
else
@auth_result.authenticated = true
DiscourseEvent.trigger(:after_auth, authenticator, @auth_result)
return respond_to do |format|
format.html
format.json { render json: @auth_result.to_client_hash }
end
end
# Save to redis, with a secret token, then redirect to confirmation screen
token = SecureRandom.hex
$redis.setex "#{Users::AssociateAccountsController::REDIS_PREFIX}_#{current_user.id}_#{token}", 10.minutes, auth.to_json
return redirect_to Discourse.base_uri("/associate/#{token}")
else
@auth_result = authenticator.after_authenticate(auth)
DiscourseEvent.trigger(:after_auth, authenticator, @auth_result)