mirror of
https://github.com/discourse/discourse.git
synced 2024-12-14 15:53:43 +08:00
SECURITY: Add confirmation screen when connecting associated accounts
This commit is contained in:
parent
90a1aa5536
commit
9cfe3f9948
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
|
@ -20,6 +20,7 @@ export default Ember.Controller.extend(
|
||||||
this._super(...arguments);
|
this._super(...arguments);
|
||||||
|
|
||||||
this.saveAttrNames = ["name", "title"];
|
this.saveAttrNames = ["name", "title"];
|
||||||
|
this.set("revoking", {});
|
||||||
},
|
},
|
||||||
|
|
||||||
canEditName: setting("enable_names"),
|
canEditName: setting("enable_names"),
|
||||||
|
@ -32,6 +33,8 @@ export default Ember.Controller.extend(
|
||||||
|
|
||||||
showAllAuthTokens: false,
|
showAllAuthTokens: false,
|
||||||
|
|
||||||
|
revoking: null,
|
||||||
|
|
||||||
cannotDeleteAccount: Ember.computed.not("currentUser.can_delete_account"),
|
cannotDeleteAccount: Ember.computed.not("currentUser.can_delete_account"),
|
||||||
deleteDisabled: Ember.computed.or(
|
deleteDisabled: Ember.computed.or(
|
||||||
"model.isSaving",
|
"model.isSaving",
|
||||||
|
@ -200,7 +203,7 @@ export default Ember.Controller.extend(
|
||||||
},
|
},
|
||||||
|
|
||||||
revokeAccount(account) {
|
revokeAccount(account) {
|
||||||
this.set("revoking", true);
|
this.set(`revoking.${account.name}`, true);
|
||||||
|
|
||||||
this.model
|
this.model
|
||||||
.revokeAssociatedAccount(account.name)
|
.revokeAssociatedAccount(account.name)
|
||||||
|
@ -212,7 +215,7 @@ export default Ember.Controller.extend(
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(popupAjaxError)
|
.catch(popupAjaxError)
|
||||||
.finally(() => this.set("revoking", false));
|
.finally(() => this.set(`revoking.${account.name}`, false));
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleShowAllAuthTokens() {
|
toggleShowAllAuthTokens() {
|
||||||
|
|
|
@ -29,7 +29,7 @@ const LoginMethod = Ember.Object.extend({
|
||||||
authUrl += "?reconnect=true";
|
authUrl += "?reconnect=true";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fullScreenLogin) {
|
if (reconnect || fullScreenLogin) {
|
||||||
document.cookie = "fsl=true";
|
document.cookie = "fsl=true";
|
||||||
window.location = authUrl;
|
window.location = authUrl;
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -178,6 +178,7 @@ export default function() {
|
||||||
this.route("signup", { path: "/signup" });
|
this.route("signup", { path: "/signup" });
|
||||||
this.route("login", { path: "/login" });
|
this.route("login", { path: "/login" });
|
||||||
this.route("email-login", { path: "/session/email-login/:token" });
|
this.route("email-login", { path: "/session/email-login/:token" });
|
||||||
|
this.route("associate-account", { path: "/associate/:token" });
|
||||||
this.route("login-preferences");
|
this.route("login-preferences");
|
||||||
this.route("forgot-password", { path: "/password-reset" });
|
this.route("forgot-password", { path: "/password-reset" });
|
||||||
this.route("faq", { path: "/faq" });
|
this.route("faq", { path: "/faq" });
|
||||||
|
|
|
@ -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)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
|
@ -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>
|
45
app/controllers/users/associate_accounts_controller.rb
Normal file
45
app/controllers/users/associate_accounts_controller.rb
Normal 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
|
|
@ -28,18 +28,10 @@ class Users::OmniauthCallbacksController < ApplicationController
|
||||||
provider = DiscoursePluginRegistry.auth_providers.find { |p| p.name == params[:provider] }
|
provider = DiscoursePluginRegistry.auth_providers.find { |p| p.name == params[:provider] }
|
||||||
|
|
||||||
if session.delete(:auth_reconnect) && authenticator.can_connect_existing_user? && current_user
|
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
|
# Save to redis, with a secret token, then redirect to confirmation screen
|
||||||
@auth_result = authenticator.after_authenticate(auth, existing_account: current_user)
|
token = SecureRandom.hex
|
||||||
if provider&.full_screen_login || cookies['fsl']
|
$redis.setex "#{Users::AssociateAccountsController::REDIS_PREFIX}_#{current_user.id}_#{token}", 10.minutes, auth.to_json
|
||||||
cookies.delete('fsl')
|
return redirect_to Discourse.base_uri("/associate/#{token}")
|
||||||
return redirect_to Discourse.base_uri("/my/preferences/account")
|
|
||||||
else
|
|
||||||
@auth_result.authenticated = true
|
|
||||||
return respond_to do |format|
|
|
||||||
format.html
|
|
||||||
format.json { render json: @auth_result.to_client_hash }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
else
|
else
|
||||||
@auth_result = authenticator.after_authenticate(auth)
|
@auth_result = authenticator.after_authenticate(auth)
|
||||||
end
|
end
|
||||||
|
|
|
@ -1012,7 +1012,10 @@ en:
|
||||||
title: "Associated Accounts"
|
title: "Associated Accounts"
|
||||||
connect: "Connect"
|
connect: "Connect"
|
||||||
revoke: "Revoke"
|
revoke: "Revoke"
|
||||||
|
cancel: "Cancel"
|
||||||
not_connected: "(not connected)"
|
not_connected: "(not connected)"
|
||||||
|
confirm_modal_title: "Connect %{provider} Account"
|
||||||
|
confirm_description: "Your %{provider} account '%{account_description}' will be used for authentication."
|
||||||
|
|
||||||
name:
|
name:
|
||||||
title: "Name"
|
title: "Name"
|
||||||
|
|
|
@ -585,6 +585,8 @@ Discourse::Application.routes.draw do
|
||||||
|
|
||||||
match "/auth/:provider/callback", to: "users/omniauth_callbacks#complete", via: [:get, :post]
|
match "/auth/:provider/callback", to: "users/omniauth_callbacks#complete", via: [:get, :post]
|
||||||
match "/auth/failure", to: "users/omniauth_callbacks#failure", via: [:get, :post]
|
match "/auth/failure", to: "users/omniauth_callbacks#failure", via: [:get, :post]
|
||||||
|
get "/associate/:token", to: "users/associate_accounts#connect_info", constraints: { token: /\h{32}/ }
|
||||||
|
post "/associate/:token", to: "users/associate_accounts#connect", constraints: { token: /\h{32}/ }
|
||||||
|
|
||||||
resources :clicks do
|
resources :clicks do
|
||||||
collection do
|
collection do
|
||||||
|
|
|
@ -40,6 +40,13 @@ class Auth::Authenticator
|
||||||
""
|
""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# return a string describing the connected account
|
||||||
|
# for a given OmniAuth::AuthHash. Used in the confirmation screen
|
||||||
|
# when connecting accounts
|
||||||
|
def description_for_auth_hash(user)
|
||||||
|
""
|
||||||
|
end
|
||||||
|
|
||||||
# can authorisation for this provider be revoked?
|
# can authorisation for this provider be revoked?
|
||||||
def can_revoke?
|
def can_revoke?
|
||||||
false
|
false
|
||||||
|
|
|
@ -2,9 +2,15 @@
|
||||||
|
|
||||||
class Auth::ManagedAuthenticator < Auth::Authenticator
|
class Auth::ManagedAuthenticator < Auth::Authenticator
|
||||||
def description_for_user(user)
|
def description_for_user(user)
|
||||||
info = UserAssociatedAccount.find_by(provider_name: name, user_id: user.id)&.info
|
associated_account = UserAssociatedAccount.find_by(provider_name: name, user_id: user.id)
|
||||||
return "" if info.nil?
|
return "" if associated_account.nil?
|
||||||
info["email"] || info["nickname"] || info["name"] || I18n.t("associated_accounts.connected")
|
description_for_auth_hash(associated_account) || I18n.t("associated_accounts.connected")
|
||||||
|
end
|
||||||
|
|
||||||
|
def description_for_auth_hash(auth_token)
|
||||||
|
return if auth_token&.info.nil?
|
||||||
|
info = auth_token.info
|
||||||
|
info["email"] || info["nickname"] || info["name"]
|
||||||
end
|
end
|
||||||
|
|
||||||
# These three methods are designed to be overriden by child classes
|
# These three methods are designed to be overriden by child classes
|
||||||
|
|
89
spec/requests/associate_accounts_spec.rb
Normal file
89
spec/requests/associate_accounts_spec.rb
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Users::AssociateAccountsController do
|
||||||
|
fab!(:user) { Fabricate(:user) }
|
||||||
|
fab!(:user2) { Fabricate(:user) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
OmniAuth.config.test_mode = true
|
||||||
|
end
|
||||||
|
|
||||||
|
after do
|
||||||
|
Rails.application.env_config["omniauth.auth"] = OmniAuth.config.mock_auth[:google_oauth2] = nil
|
||||||
|
OmniAuth.config.test_mode = false
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when attempting reconnect' do
|
||||||
|
before do
|
||||||
|
SiteSetting.enable_google_oauth2_logins = true
|
||||||
|
OmniAuth.config.mock_auth[:google_oauth2] = OmniAuth::AuthHash.new(
|
||||||
|
provider: 'google_oauth2',
|
||||||
|
uid: '12345',
|
||||||
|
info: {
|
||||||
|
email: 'someemail@test.com',
|
||||||
|
},
|
||||||
|
extra: {
|
||||||
|
raw_info: {
|
||||||
|
email_verified: true,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
Rails.application.env_config["omniauth.auth"] = OmniAuth.config.mock_auth[:google_oauth2]
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'should work correctly' do
|
||||||
|
sign_in(user)
|
||||||
|
|
||||||
|
# Reconnect flow:
|
||||||
|
get "/auth/google_oauth2?reconnect=true"
|
||||||
|
expect(response.status).to eq(302)
|
||||||
|
expect(session[:auth_reconnect]).to eq(true)
|
||||||
|
|
||||||
|
OmniAuth.config.mock_auth[:google_oauth2].uid = "123456"
|
||||||
|
get "/auth/google_oauth2/callback.json"
|
||||||
|
expect(response.status).to eq(302)
|
||||||
|
|
||||||
|
expect(session[:current_user_id]).to eq(user.id) # Still logged in
|
||||||
|
expect(UserAssociatedAccount.count).to eq(0) # Reconnect has not yet happened
|
||||||
|
|
||||||
|
# Request associate info
|
||||||
|
uri = URI.parse(response.redirect_url)
|
||||||
|
get "#{uri.path}.json"
|
||||||
|
data = JSON.parse(response.body)
|
||||||
|
expect(data["provider_name"]).to eq("google_oauth2")
|
||||||
|
expect(data["account_description"]).to eq("someemail@test.com")
|
||||||
|
|
||||||
|
# Request as different user, should not work
|
||||||
|
sign_in(user2)
|
||||||
|
get "#{uri.path}.json"
|
||||||
|
expect(response.status).to eq(404)
|
||||||
|
|
||||||
|
# Back to first user
|
||||||
|
sign_in(user)
|
||||||
|
get "#{uri.path}.json"
|
||||||
|
data = JSON.parse(response.body)
|
||||||
|
expect(data["provider_name"]).to eq("google_oauth2")
|
||||||
|
|
||||||
|
# Make the connection
|
||||||
|
post "#{uri.path}.json"
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expect(UserAssociatedAccount.count).to eq(1)
|
||||||
|
|
||||||
|
# Token cannot be reused
|
||||||
|
get "#{uri.path}.json"
|
||||||
|
expect(response.status).to eq(404)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns the correct response for non-existant tokens" do
|
||||||
|
get "/associate/12345678901234567890123456789012.json"
|
||||||
|
expect(response.status).to eq(404)
|
||||||
|
|
||||||
|
get "/associate/shorttoken.json"
|
||||||
|
expect(response.status).to eq(404)
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
end
|
|
@ -460,7 +460,7 @@ RSpec.describe Users::OmniauthCallbacksController do
|
||||||
expect(UserAssociatedAccount.count).to eq(2)
|
expect(UserAssociatedAccount.count).to eq(2)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'should reconnect if parameter supplied' do
|
it 'should redirect to associate URL if parameter supplied' do
|
||||||
# Log in normally
|
# Log in normally
|
||||||
get "/auth/google_oauth2?reconnect=true"
|
get "/auth/google_oauth2?reconnect=true"
|
||||||
expect(response.status).to eq(302)
|
expect(response.status).to eq(302)
|
||||||
|
@ -483,10 +483,11 @@ RSpec.describe Users::OmniauthCallbacksController do
|
||||||
|
|
||||||
OmniAuth.config.mock_auth[:google_oauth2].uid = "123456"
|
OmniAuth.config.mock_auth[:google_oauth2].uid = "123456"
|
||||||
get "/auth/google_oauth2/callback.json"
|
get "/auth/google_oauth2/callback.json"
|
||||||
expect(response.status).to eq(200)
|
expect(response.status).to eq(302)
|
||||||
expect(JSON.parse(response.body)["authenticated"]).to eq(true)
|
expect(response.redirect_url).to start_with("http://test.localhost/associate/")
|
||||||
|
|
||||||
expect(session[:current_user_id]).to eq(user.id)
|
expect(session[:current_user_id]).to eq(user.id)
|
||||||
expect(UserAssociatedAccount.count).to eq(1)
|
expect(UserAssociatedAccount.count).to eq(0) # Reconnect has not yet happened
|
||||||
end
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in New Issue
Block a user