2
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2025-09-06 10:50:21 +08:00

FEATURE: List, revoke and reconnect associated accounts. Phase 1 (#6099)

Listing connections is supported for all built-in auth providers. Revoke and reconnect is currently only implemented for Facebook.
This commit is contained in:
David Taylor 2018-07-23 16:51:57 +01:00 committed by GitHub
parent 32062864d3
commit eda1462b3b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 836 additions and 240 deletions

View file

@ -40,6 +40,18 @@ export default Ember.Controller.extend(CanCheckEmails, {
.join(", "); .join(", ");
}, },
@computed("model.associated_accounts")
associatedAccountsLoaded(associatedAccounts) {
return typeof associatedAccounts !== "undefined";
},
@computed("model.associated_accounts")
associatedAccounts(associatedAccounts) {
return associatedAccounts
.map(provider => `${provider.name} (${provider.description})`)
.join(", ");
},
userFields: function() { userFields: function() {
const siteUserFields = this.site.get("user_fields"), const siteUserFields = this.site.get("user_fields"),
userFields = this.get("model.user_fields"); userFields = this.get("model.user_fields");

View file

@ -109,10 +109,10 @@
</div> </div>
<div class='display-row associations'> <div class='display-row associations'>
<div class='field'>{{i18n 'user.associated_accounts'}}</div> <div class='field'>{{i18n 'user.associated_accounts.title'}}</div>
<div class='value'> <div class='value'>
{{#if model.associated_accounts}} {{#if associatedAccountsLoaded}}
{{model.associated_accounts}} {{associatedAccounts}}
{{else}} {{else}}
{{d-button action="checkEmail" actionParam=model icon="envelope-o" label="admin.users.check_email.text" title="admin.users.check_email.title"}} {{d-button action="checkEmail" actionParam=model icon="envelope-o" label="admin.users.check_email.text" title="admin.users.check_email.title"}}
{{/if}} {{/if}}

View file

@ -193,50 +193,7 @@ export default Ember.Controller.extend(ModalFunctionality, {
}, },
externalLogin: function(loginMethod) { externalLogin: function(loginMethod) {
const name = loginMethod.get("name"); loginMethod.doLogin();
const customLogin = loginMethod.get("customLogin");
if (customLogin) {
customLogin();
} else {
let authUrl =
loginMethod.get("customUrl") || Discourse.getURL("/auth/" + name);
if (loginMethod.get("fullScreenLogin")) {
document.cookie = "fsl=true";
window.location = authUrl;
} else {
this.set("authenticate", name);
const left = this.get("lastX") - 400;
const top = this.get("lastY") - 200;
const height = loginMethod.get("frameHeight") || 400;
const width = loginMethod.get("frameWidth") || 800;
if (loginMethod.get("displayPopup")) {
authUrl = authUrl + "?display=popup";
}
const w = window.open(
authUrl,
"_blank",
"menubar=no,status=no,height=" +
height +
",width=" +
width +
",left=" +
left +
",top=" +
top
);
const self = this;
const timer = setInterval(function() {
if (!w || w.closed) {
clearInterval(timer);
self.set("authenticate", null);
}
}, 1000);
}
}
}, },
createAccount: function() { createAccount: function() {

View file

@ -5,6 +5,7 @@ import PreferencesTabController from "discourse/mixins/preferences-tab-controlle
import { setting } from "discourse/lib/computed"; import { setting } from "discourse/lib/computed";
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 { findAll } from "discourse/models/login-method";
export default Ember.Controller.extend( export default Ember.Controller.extend(
CanCheckEmails, CanCheckEmails,
@ -54,6 +55,44 @@ export default Ember.Controller.extend(
); );
}, },
@computed("model.associated_accounts")
associatedAccountsLoaded(associatedAccounts) {
return typeof associatedAccounts !== "undefined";
},
@computed("model.associated_accounts.[]")
authProviders(accounts) {
const allMethods = findAll(
this.siteSettings,
this.capabilities,
this.site.isMobileDevice
);
const result = allMethods.map(method => {
return {
method,
account: accounts.find(account => account.name === method.name) // Will be undefined if no account
};
});
return result.filter(value => {
return value.account || value.method.get("canConnect");
});
},
@computed("model.id")
disableConnectButtons(userId) {
return userId !== this.get("currentUser.id");
},
@computed()
canUpdateAssociatedAccounts() {
return (
findAll(this.siteSettings, this.capabilities, this.site.isMobileDevice)
.length > 0
);
},
actions: { actions: {
save() { save() {
this.set("saved", false); this.set("saved", false);
@ -135,6 +174,28 @@ export default Ember.Controller.extend(
showTwoFactorModal() { showTwoFactorModal() {
showModal("second-factor-intro"); showModal("second-factor-intro");
},
revokeAccount(account) {
const model = this.get("model");
this.set("revoking", true);
model
.revokeAssociatedAccount(account.name)
.then(result => {
if (result.success) {
model.get("associated_accounts").removeObject(account);
} else {
bootbox.alert(result.message);
}
})
.catch(popupAjaxError)
.finally(() => {
this.set("revoking", false);
});
},
connectAccount(method) {
method.doLogin();
} }
} }
} }

View file

@ -12,8 +12,22 @@ const LoginMethod = Ember.Object.extend({
} }
return ( return (
this.get("titleOverride") || this.get("titleOverride") || I18n.t(`login.${this.get("name")}.title`)
I18n.t("login." + this.get("name") + ".title") );
},
@computed
prettyName() {
const prettyNameSetting = this.get("prettyNameSetting");
if (!Ember.isEmpty(prettyNameSetting)) {
const result = this.siteSettings[prettyNameSetting];
if (!Ember.isEmpty(result)) {
return result;
}
}
return (
this.get("prettyNameOverride") || I18n.t(`login.${this.get("name")}.name`)
); );
}, },
@ -23,6 +37,52 @@ const LoginMethod = Ember.Object.extend({
this.get("messageOverride") || this.get("messageOverride") ||
I18n.t("login." + this.get("name") + ".message") I18n.t("login." + this.get("name") + ".message")
); );
},
doLogin() {
const name = this.get("name");
const customLogin = this.get("customLogin");
if (customLogin) {
customLogin();
} else {
let authUrl = this.get("customUrl") || Discourse.getURL("/auth/" + name);
if (this.get("fullScreenLogin")) {
document.cookie = "fsl=true";
window.location = authUrl;
} else {
this.set("authenticate", name);
const left = this.get("lastX") - 400;
const top = this.get("lastY") - 200;
const height = this.get("frameHeight") || 400;
const width = this.get("frameWidth") || 800;
if (this.get("displayPopup")) {
authUrl = authUrl + "?display=popup";
}
const w = window.open(
authUrl,
"_blank",
"menubar=no,status=no,height=" +
height +
",width=" +
width +
",left=" +
left +
",top=" +
top
);
const self = this;
const timer = setInterval(function() {
if (!w || w.closed) {
clearInterval(timer);
self.set("authenticate", null);
}
}, 1000);
}
}
} }
}); });
@ -57,6 +117,10 @@ export function findAll(siteSettings, capabilities, isMobileDevice) {
params.displayPopup = true; params.displayPopup = true;
} }
if (["facebook"].includes(name)) {
params.canConnect = true;
}
params.siteSettings = siteSettings; params.siteSettings = siteSettings;
methods.pushObject(LoginMethod.create(params)); methods.pushObject(LoginMethod.create(params));
} }

View file

@ -372,6 +372,16 @@ const User = RestModel.extend({
}); });
}, },
revokeAssociatedAccount(providerName) {
return ajax(
userPath(`${this.get("username")}/preferences/revoke-account`),
{
data: { provider_name: providerName },
type: "POST"
}
);
},
loadUserAction(id) { loadUserAction(id) {
const stream = this.get("stream"); const stream = this.get("stream");
return ajax(`/user_actions/${id}.json`, { cache: "false" }).then(result => { return ajax(`/user_actions/${id}.json`, { cache: "false" }).then(result => {

View file

@ -99,6 +99,45 @@
</div> </div>
{{/if}} {{/if}}
{{#if canUpdateAssociatedAccounts}}
<div class="control-group pref-associated-accounts">
<label class="control-label">{{i18n 'user.associated_accounts.title'}}</label>
{{#if associatedAccountsLoaded}}
<table>
{{#each authProviders as |authProvider|}}
<tr>
<td>{{authProvider.method.prettyName}}</td>
{{#if authProvider.account}}
<td>{{authProvider.account.description}}</td>
<td>
{{#if authProvider.account.can_revoke}}
{{#conditional-loading-spinner condition=revoking size='small'}}
{{d-button action="revokeAccount" actionParam=authProvider.account title="user.associated_accounts.revoke" icon="times-circle" }}
{{/conditional-loading-spinner}}
{{/if}}
</td>
{{else}}
<td colspan=2>
{{#if authProvider.method.canConnect}}
{{d-button action="connectAccount" actionParam=authProvider.method label="user.associated_accounts.connect" icon="plug" disabled=disableConnectButtons}}
{{else}}
{{i18n 'user.associated_accounts.not_connected'}}
{{/if}}
</td>
{{/if}}
</tr>
{{/each}}
</table>
{{else}}
<div class="controls">
{{d-button action="checkEmail" actionParam=model title="admin.users.check_email.title" icon="envelope-o" label="admin.users.check_email.text"}}
</div>
{{/if}}
</div>
{{/if}}
{{#unless siteSettings.sso_overrides_avatar}} {{#unless siteSettings.sso_overrides_avatar}}
<div class="control-group pref-avatar"> <div class="control-group pref-avatar">
<label class="control-label">{{i18n 'user.avatar.title'}}</label> <label class="control-label">{{i18n 'user.avatar.title'}}</label>

View file

@ -629,6 +629,12 @@
} }
} }
} }
.pref-associated-accounts table {
td {
padding: 8px;
}
}
} }
.paginated-topics-list { .paginated-topics-list {

View file

@ -8,7 +8,7 @@ class Users::OmniauthCallbacksController < ApplicationController
BUILTIN_AUTH = [ BUILTIN_AUTH = [
Auth::FacebookAuthenticator.new, Auth::FacebookAuthenticator.new,
Auth::GoogleOAuth2Authenticator.new, Auth::GoogleOAuth2Authenticator.new,
Auth::OpenIdAuthenticator.new("yahoo", "https://me.yahoo.com", trusted: true), Auth::OpenIdAuthenticator.new("yahoo", "https://me.yahoo.com", 'enable_yahoo_logins', trusted: true),
Auth::GithubAuthenticator.new, Auth::GithubAuthenticator.new,
Auth::TwitterAuthenticator.new, Auth::TwitterAuthenticator.new,
Auth::InstagramAuthenticator.new Auth::InstagramAuthenticator.new
@ -18,10 +18,6 @@ class Users::OmniauthCallbacksController < ApplicationController
layout 'no_ember' layout 'no_ember'
def self.types
@types ||= Enum.new(:facebook, :instagram, :twitter, :google, :yahoo, :github, :persona, :cas)
end
# need to be able to call this # need to be able to call this
skip_before_action :check_xhr skip_before_action :check_xhr
@ -36,9 +32,13 @@ class Users::OmniauthCallbacksController < ApplicationController
auth[:session] = session auth[:session] = session
authenticator = self.class.find_authenticator(params[:provider]) authenticator = self.class.find_authenticator(params[:provider])
provider = Discourse.auth_providers && Discourse.auth_providers.find { |p| p.name == params[:provider] } provider = DiscoursePluginRegistry.auth_providers.find { |p| p.name == params[:provider] }
@auth_result = authenticator.after_authenticate(auth) if authenticator.can_connect_existing_user? && current_user
@auth_result = authenticator.after_authenticate(auth, existing_account: current_user)
else
@auth_result = authenticator.after_authenticate(auth)
end
origin = request.env['omniauth.origin'] origin = request.env['omniauth.origin']
@ -91,23 +91,10 @@ class Users::OmniauthCallbacksController < ApplicationController
end end
def self.find_authenticator(name) def self.find_authenticator(name)
BUILTIN_AUTH.each do |authenticator| Discourse.enabled_authenticators.each do |authenticator|
if authenticator.name == name return authenticator if authenticator.name == name
raise Discourse::InvalidAccess.new(I18n.t("provider_not_enabled")) unless SiteSetting.send("enable_#{name}_logins?")
return authenticator
end
end end
raise Discourse::InvalidAccess.new(I18n.t('authenticator_not_found'))
Discourse.auth_providers.each do |provider|
next if provider.name != name
unless provider.enabled_setting.nil? || SiteSetting.send(provider.enabled_setting)
raise Discourse::InvalidAccess.new(I18n.t("provider_not_enabled"))
end
return provider.authenticator
end
raise Discourse::InvalidAccess.new(I18n.t("provider_not_found"))
end end
protected protected

View file

@ -1072,6 +1072,32 @@ class UsersController < ApplicationController
render json: success_json render json: success_json
end end
def revoke_account
user = fetch_user_from_params
guardian.ensure_can_edit!(user)
provider_name = params.require(:provider_name)
# Using Discourse.authenticators rather than Discourse.enabled_authenticators so users can
# revoke permissions even if the admin has temporarily disabled that type of login
authenticator = Discourse.authenticators.find { |authenticator| authenticator.name == provider_name }
raise Discourse::NotFound if authenticator.nil?
skip_remote = params.permit(:skip_remote)
# We're likely going to contact the remote auth provider, so hijack request
hijack do
result = authenticator.revoke(user, skip_remote: skip_remote)
if result
return render json: success_json
else
return render json: {
success: false,
message: I18n.t("associated_accounts.revoke_failed", provider_name: provider_name)
}
end
end
end
private private
def honeypot_value def honeypot_value

View file

@ -62,7 +62,7 @@ class User < ActiveRecord::Base
has_one :twitter_user_info, dependent: :destroy has_one :twitter_user_info, dependent: :destroy
has_one :github_user_info, dependent: :destroy has_one :github_user_info, dependent: :destroy
has_one :google_user_info, dependent: :destroy has_one :google_user_info, dependent: :destroy
has_one :oauth2_user_info, dependent: :destroy has_many :oauth2_user_infos, dependent: :destroy
has_one :instagram_user_info, dependent: :destroy has_one :instagram_user_info, dependent: :destroy
has_many :user_second_factors, dependent: :destroy has_many :user_second_factors, dependent: :destroy
has_one :user_stat, dependent: :destroy has_one :user_stat, dependent: :destroy
@ -952,18 +952,18 @@ class User < ActiveRecord::Base
def associated_accounts def associated_accounts
result = [] result = []
result << "Twitter(#{twitter_user_info.screen_name})" if twitter_user_info Discourse.authenticators.each do |authenticator|
result << "Facebook(#{facebook_user_info.username})" if facebook_user_info account_description = authenticator.description_for_user(self)
result << "Google(#{google_user_info.email})" if google_user_info unless account_description.empty?
result << "GitHub(#{github_user_info.screen_name})" if github_user_info result << {
result << "Instagram(#{instagram_user_info.screen_name})" if instagram_user_info name: authenticator.name,
result << "#{oauth2_user_info.provider}(#{oauth2_user_info.email})" if oauth2_user_info description: account_description,
can_revoke: authenticator.can_revoke?
user_open_ids.each do |oid| }
result << "OpenID #{oid.url[0..20]}...(#{oid.email})" end
end end
result.empty? ? I18n.t("user.no_accounts_associated") : result.join(", ") result
end end
def user_fields def user_fields

View file

@ -75,7 +75,8 @@ class UserSerializer < BasicUserSerializer
:staged, :staged,
:second_factor_enabled, :second_factor_enabled,
:second_factor_backup_enabled, :second_factor_backup_enabled,
:second_factor_remaining_backup_codes :second_factor_remaining_backup_codes,
:associated_accounts
has_one :invited_by, embed: :object, serializer: BasicUserSerializer has_one :invited_by, embed: :object, serializer: BasicUserSerializer
has_many :groups, embed: :object, serializer: BasicGroupSerializer has_many :groups, embed: :object, serializer: BasicGroupSerializer
@ -145,6 +146,10 @@ class UserSerializer < BasicUserSerializer
(scope.is_staff? && object.staged?) (scope.is_staff? && object.staged?)
end end
def include_associated_accounts?
(object.id && object.id == scope.user.try(:id))
end
def include_second_factor_enabled? def include_second_factor_enabled?
(object&.id == scope.user&.id) || scope.is_staff? (object&.id == scope.user&.id) || scope.is_staff?
end end

View file

@ -58,7 +58,7 @@ class UserAnonymizer
@user.github_user_info.try(:destroy) @user.github_user_info.try(:destroy)
@user.facebook_user_info.try(:destroy) @user.facebook_user_info.try(:destroy)
@user.single_sign_on_record.try(:destroy) @user.single_sign_on_record.try(:destroy)
@user.oauth2_user_info.try(:destroy) @user.oauth2_user_infos.try(:destroy_all)
@user.instagram_user_info.try(:destroy) @user.instagram_user_info.try(:destroy)
@user.user_open_ids.find_each { |x| x.destroy } @user.user_open_ids.find_each { |x| x.destroy }
@user.api_key.try(:destroy) @user.api_key.try(:destroy)

View file

@ -820,6 +820,12 @@ en:
one: "We'll only email you if we haven't seen you in the last minute." one: "We'll only email you if we haven't seen you in the last minute."
other: "We'll only email you if we haven't seen you in the last {{count}} minutes." other: "We'll only email you if we haven't seen you in the last {{count}} minutes."
associated_accounts:
title: "Associated Accounts"
connect: "Connect"
revoke: "Revoke"
not_connected: "(not connected)"
name: name:
title: "Name" title: "Name"
instructions: "your full name (optional)" instructions: "your full name (optional)"
@ -1007,7 +1013,6 @@ en:
topics: "Topics" topics: "Topics"
replies: "Replies" replies: "Replies"
associated_accounts: "Logins"
ip_address: ip_address:
title: "Last IP Address" title: "Last IP Address"
registration_ip_address: registration_ip_address:
@ -1198,25 +1203,28 @@ en:
preferences: "You need to be logged in to change your user preferences." preferences: "You need to be logged in to change your user preferences."
forgot: "I don't recall my account details" forgot: "I don't recall my account details"
not_approved: "Your account hasn't been approved yet. You will be notified by email when you are ready to log in." not_approved: "Your account hasn't been approved yet. You will be notified by email when you are ready to log in."
google:
title: "with Google"
message: "Authenticating with Google (make sure pop up blockers are not enabled)"
google_oauth2: google_oauth2:
name: "Google"
title: "with Google" title: "with Google"
message: "Authenticating with Google (make sure pop up blockers are not enabled)" message: "Authenticating with Google (make sure pop up blockers are not enabled)"
twitter: twitter:
name: "Twitter"
title: "with Twitter" title: "with Twitter"
message: "Authenticating with Twitter (make sure pop up blockers are not enabled)" message: "Authenticating with Twitter (make sure pop up blockers are not enabled)"
instagram: instagram:
name: "Instagram"
title: "with Instagram" title: "with Instagram"
message: "Authenticating with Instagram (make sure pop up blockers are not enabled)" message: "Authenticating with Instagram (make sure pop up blockers are not enabled)"
facebook: facebook:
name: "Facebook"
title: "with Facebook" title: "with Facebook"
message: "Authenticating with Facebook (make sure pop up blockers are not enabled)" message: "Authenticating with Facebook (make sure pop up blockers are not enabled)"
yahoo: yahoo:
name: "Yahoo"
title: "with Yahoo" title: "with Yahoo"
message: "Authenticating with Yahoo (make sure pop up blockers are not enabled)" message: "Authenticating with Yahoo (make sure pop up blockers are not enabled)"
github: github:
name: "GitHub"
title: "with GitHub" title: "with GitHub"
message: "Authenticating with GitHub (make sure pop up blockers are not enabled)" message: "Authenticating with GitHub (make sure pop up blockers are not enabled)"
invites: invites:

View file

@ -197,6 +197,7 @@ en:
not_logged_in: "You need to be logged in to do that." not_logged_in: "You need to be logged in to do that."
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."
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."
@ -701,6 +702,9 @@ en:
title: "Thanks for confirming your current email address" title: "Thanks for confirming your current email address"
description: "We're now emailing your new address for confirmation." description: "We're now emailing your new address for confirmation."
associated_accounts:
revoke_failed: "Failed to revoke your account with %{provider_name}."
activation: activation:
action: "Click here to activate your account" action: "Click here to activate your account"
already_done: "Sorry, this account confirmation link is no longer valid. Perhaps your account is already active?" already_done: "Sorry, this account confirmation link is no longer valid. Perhaps your account is already active?"
@ -1950,7 +1954,6 @@ en:
backup_code: "Log in using a backup code" backup_code: "Log in using a backup code"
user: user:
no_accounts_associated: "No accounts associated"
deactivated: "Was deactivated due to too many bounced emails to '%{email}'." deactivated: "Was deactivated due to too many bounced emails to '%{email}'."
deactivated_by_staff: "Deactivated by staff" deactivated_by_staff: "Deactivated by staff"
activated_by_staff: "Activated by staff" activated_by_staff: "Activated by staff"

View file

@ -408,6 +408,7 @@ Discourse::Application.routes.draw do
delete "#{root_path}/:username/preferences/user_image" => "users#destroy_user_image", constraints: { username: RouteFormat.username } delete "#{root_path}/:username/preferences/user_image" => "users#destroy_user_image", constraints: { username: RouteFormat.username }
put "#{root_path}/:username/preferences/avatar/pick" => "users#pick_avatar", constraints: { username: RouteFormat.username } put "#{root_path}/:username/preferences/avatar/pick" => "users#pick_avatar", constraints: { username: RouteFormat.username }
put "#{root_path}/:username/preferences/avatar/select" => "users#select_avatar", constraints: { username: RouteFormat.username } put "#{root_path}/:username/preferences/avatar/select" => "users#select_avatar", constraints: { username: RouteFormat.username }
post "#{root_path}/:username/preferences/revoke-account" => "users#revoke_account", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/staff-info" => "users#staff_info", constraints: { username: RouteFormat.username } get "#{root_path}/:username/staff-info" => "users#staff_info", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/summary" => "users#summary", constraints: { username: RouteFormat.username } get "#{root_path}/:username/summary" => "users#summary", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/invited" => "users#invited", constraints: { username: RouteFormat.username } get "#{root_path}/:username/invited" => "users#invited", constraints: { username: RouteFormat.username }

View file

@ -2,7 +2,17 @@
# an authentication system interacts with our database and middleware # an authentication system interacts with our database and middleware
class Auth::Authenticator class Auth::Authenticator
def after_authenticate(auth_options) def name
raise NotImplementedError
end
def enabled?
raise NotImplementedError
end
# run once the user has completed authentication on the third party system. Should return an instance of Auth::Result.
# If the user has requested to connect an existing account then `existing_account` will be set
def after_authenticate(auth_options, existing_account: nil)
raise NotImplementedError raise NotImplementedError
end end
@ -19,4 +29,31 @@ class Auth::Authenticator
def register_middleware(omniauth) def register_middleware(omniauth)
raise NotImplementedError raise NotImplementedError
end end
# return a string describing the connected account
# for a given user (typically email address). Used to list
# connected accounts under the user's preferences. Empty string
# indicates not connected
def description_for_user(user)
""
end
# can authorisation for this provider be revoked?
def can_revoke?
false
end
# can exising discourse users connect this provider to their accounts
def can_connect_existing_user?
false
end
# optionally implement the ability for users to revoke
# their link with this authenticator.
# should ideally contact the third party to fully revoke
# permissions. If this fails, return :remote_failed.
# skip remote if skip_remote == true
def revoke(user, skip_remote: false)
raise NotImplementedError
end
end end

View file

@ -6,7 +6,47 @@ class Auth::FacebookAuthenticator < Auth::Authenticator
"facebook" "facebook"
end end
def after_authenticate(auth_token) def enabled?
SiteSetting.enable_facebook_logins
end
def description_for_user(user)
info = FacebookUserInfo.find_by(user_id: user.id)
info&.email || info&.username || ""
end
def can_revoke?
true
end
def revoke(user, skip_remote: false)
info = FacebookUserInfo.find_by(user_id: user.id)
raise Discourse::NotFound if info.nil?
if skip_remote
info.destroy!
return true
end
response = Excon.delete(revoke_url(info.facebook_user_id))
if response.status == 200
info.destroy!
return true
end
false
end
def revoke_url(fb_user_id)
"https://graph.facebook.com/#{fb_user_id}/permissions?access_token=#{SiteSetting.facebook_app_id}|#{SiteSetting.facebook_app_secret}"
end
def can_connect_existing_user?
true
end
def after_authenticate(auth_token, existing_account: nil)
result = Auth::Result.new result = Auth::Result.new
session_info = parse_auth_token(auth_token) session_info = parse_auth_token(auth_token)
@ -20,9 +60,16 @@ class Auth::FacebookAuthenticator < Auth::Authenticator
user_info = FacebookUserInfo.find_by(facebook_user_id: facebook_hash[:facebook_user_id]) user_info = FacebookUserInfo.find_by(facebook_user_id: facebook_hash[:facebook_user_id])
result.user = user_info.try(:user) if existing_account && (user_info.nil? || existing_account.id != user_info.user_id)
user_info.destroy! if user_info
result.user = existing_account
user_info = FacebookUserInfo.create!({ user_id: result.user.id }.merge(facebook_hash))
else
result.user = user_info&.user
end
if !result.user && !email.blank? && result.user = User.find_by_email(email) if !result.user && !email.blank? && result.user = User.find_by_email(email)
FacebookUserInfo.create({ user_id: result.user.id }.merge(facebook_hash)) FacebookUserInfo.create!({ user_id: result.user.id }.merge(facebook_hash))
end end
user_info.update_columns(facebook_hash) if user_info user_info.update_columns(facebook_hash) if user_info
@ -42,7 +89,7 @@ class Auth::FacebookAuthenticator < Auth::Authenticator
def after_create_account(user, auth) def after_create_account(user, auth)
extra_data = auth[:extra_data] extra_data = auth[:extra_data]
FacebookUserInfo.create({ user_id: user.id }.merge(extra_data)) FacebookUserInfo.create!({ user_id: user.id }.merge(extra_data))
retrieve_avatar(user, extra_data) retrieve_avatar(user, extra_data)
retrieve_profile(user, extra_data) retrieve_profile(user, extra_data)

View file

@ -6,6 +6,15 @@ class Auth::GithubAuthenticator < Auth::Authenticator
"github" "github"
end end
def enabled?
SiteSetting.enable_github_logins
end
def description_for_user(user)
info = GithubUserInfo.find_by(user_id: user.id)
info&.screen_name || ""
end
class GithubEmailChecker class GithubEmailChecker
include ::HasErrors include ::HasErrors

View file

@ -4,6 +4,15 @@ class Auth::GoogleOAuth2Authenticator < Auth::Authenticator
"google_oauth2" "google_oauth2"
end end
def enabled?
SiteSetting.enable_google_oauth2_logins
end
def description_for_user(user)
info = GoogleUserInfo.find_by(user_id: user.id)
info&.email || info&.name || ""
end
def after_authenticate(auth_hash) def after_authenticate(auth_hash)
session_info = parse_hash(auth_hash) session_info = parse_hash(auth_hash)
google_hash = session_info[:google] google_hash = session_info[:google]

View file

@ -4,6 +4,15 @@ class Auth::InstagramAuthenticator < Auth::Authenticator
"instagram" "instagram"
end end
def enabled?
SiteSetting.enable_instagram_logins
end
def description_for_user(user)
info = InstagramUserInfo.find_by(user_id: user.id)
info&.screen_name || ""
end
# TODO twitter provides all sorts of extra info, like website/bio etc. # TODO twitter provides all sorts of extra info, like website/bio etc.
# it may be worth considering pulling some of it in. # it may be worth considering pulling some of it in.
def after_authenticate(auth_token) def after_authenticate(auth_token)

View file

@ -52,4 +52,8 @@ class Auth::OAuth2Authenticator < Auth::Authenticator
) )
end end
def description_for_user(user)
info = Oauth2UserInfo.find_by(user_id: user.id, provider: @name)
info&.email || info&.name || info&.uid || ""
end
end end

View file

@ -2,12 +2,22 @@ class Auth::OpenIdAuthenticator < Auth::Authenticator
attr_reader :name, :identifier attr_reader :name, :identifier
def initialize(name, identifier, opts = {}) def initialize(name, identifier, enabled_site_setting, opts = {})
@name = name @name = name
@identifier = identifier @identifier = identifier
@enabled_site_setting = enabled_site_setting
@opts = opts @opts = opts
end end
def enabled?
SiteSetting.send(@enabled_site_setting)
end
def description_for_user(user)
info = UserOpenId.find_by(user_id: user.id)
info&.email || ""
end
def after_authenticate(auth_token) def after_authenticate(auth_token)
result = Auth::Result.new result = Auth::Result.new

View file

@ -4,6 +4,15 @@ class Auth::TwitterAuthenticator < Auth::Authenticator
"twitter" "twitter"
end end
def enabled?
SiteSetting.enable_twitter_logins
end
def description_for_user(user)
info = TwitterUserInfo.find_by(user_id: user.id)
info&.email || info&.screen_name || ""
end
def after_authenticate(auth_token) def after_authenticate(auth_token)
result = Auth::Result.new result = Auth::Result.new

View file

@ -199,24 +199,15 @@ module Discourse
end end
def self.authenticators def self.authenticators
# TODO: perhaps we don't need auth providers and authenticators maybe one object is enough
# NOTE: this bypasses the site settings and gives a list of everything, we need to register every middleware # NOTE: this bypasses the site settings and gives a list of everything, we need to register every middleware
# for the cases of multisite # for the cases of multisite
# In future we may change it so we don't include them all for cases where we are not a multisite, but we would # In future we may change it so we don't include them all for cases where we are not a multisite, but we would
# require a restart after site settings change # require a restart after site settings change
Users::OmniauthCallbacksController::BUILTIN_AUTH + auth_providers.map(&:authenticator) Users::OmniauthCallbacksController::BUILTIN_AUTH + DiscoursePluginRegistry.auth_providers.map(&:authenticator)
end end
def self.auth_providers def self.enabled_authenticators
providers = [] authenticators.select { |authenticator| authenticator.enabled? }
plugins.each do |p|
next unless p.auth_providers
p.auth_providers.each do |prov|
providers << prov
end
end
providers
end end
def self.cache def self.cache

View file

@ -5,6 +5,7 @@ class DiscoursePluginRegistry
class << self class << self
attr_writer :javascripts attr_writer :javascripts
attr_writer :auth_providers
attr_writer :service_workers attr_writer :service_workers
attr_writer :admin_javascripts attr_writer :admin_javascripts
attr_writer :stylesheets attr_writer :stylesheets
@ -26,6 +27,10 @@ class DiscoursePluginRegistry
@javascripts ||= Set.new @javascripts ||= Set.new
end end
def auth_providers
@auth_providers ||= Set.new
end
def service_workers def service_workers
@service_workers ||= Set.new @service_workers ||= Set.new
end end
@ -87,6 +92,10 @@ class DiscoursePluginRegistry
end end
end end
def self.register_auth_provider(auth_provider)
self.auth_providers << auth_provider
end
def register_js(filename, options = {}) def register_js(filename, options = {})
# If we have a server side option, add that too. # If we have a server side option, add that too.
self.class.javascripts << filename self.class.javascripts << filename
@ -203,6 +212,10 @@ class DiscoursePluginRegistry
self.class.javascripts self.class.javascripts
end end
def auth_providers
self.class.auth_providers
end
def service_workers def service_workers
self.class.service_workers self.class.service_workers
end end
@ -229,6 +242,7 @@ class DiscoursePluginRegistry
def self.clear def self.clear
self.javascripts = nil self.javascripts = nil
self.auth_providers = nil
self.service_workers = nil self.service_workers = nil
self.stylesheets = nil self.stylesheets = nil
self.mobile_stylesheets = nil self.mobile_stylesheets = nil
@ -240,6 +254,7 @@ class DiscoursePluginRegistry
def self.reset! def self.reset!
javascripts.clear javascripts.clear
auth_providers.clear
service_workers.clear service_workers.clear
admin_javascripts.clear admin_javascripts.clear
stylesheets.clear stylesheets.clear

View file

@ -1,8 +1,8 @@
class Plugin::AuthProvider class Plugin::AuthProvider
def self.auth_attributes def self.auth_attributes
[:glyph, :background_color, :title, :message, :frame_width, :frame_height, :authenticator, [:glyph, :background_color, :pretty_name, :title, :message, :frame_width, :frame_height, :authenticator,
:title_setting, :enabled_setting, :full_screen_login, :custom_url] :pretty_name_setting, :title_setting, :enabled_setting, :full_screen_login, :custom_url]
end end
attr_accessor(*auth_attributes) attr_accessor(*auth_attributes)
@ -14,8 +14,10 @@ class Plugin::AuthProvider
def to_json def to_json
result = { name: name } result = { name: name }
result['customUrl'] = custom_url if custom_url result['customUrl'] = custom_url if custom_url
result['prettyNameOverride'] = pretty_name || name
result['titleOverride'] = title if title result['titleOverride'] = title if title
result['titleSetting'] = title_setting if title_setting result['titleSetting'] = title_setting if title_setting
result['prettyNameSetting'] = pretty_name_setting if pretty_name_setting
result['enabledSetting'] = enabled_setting if enabled_setting result['enabledSetting'] = enabled_setting if enabled_setting
result['messageOverride'] = message if message result['messageOverride'] = message if message
result['frameWidth'] = frame_width if frame_width result['frameWidth'] = frame_width if frame_width

View file

@ -451,6 +451,7 @@ JS
register_assets! unless assets.blank? register_assets! unless assets.blank?
register_locales! register_locales!
register_service_workers! register_service_workers!
register_auth_providers!
seed_data.each do |key, value| seed_data.each do |key, value|
DiscoursePluginRegistry.register_seed_data(key, value) DiscoursePluginRegistry.register_seed_data(key, value)
@ -488,6 +489,20 @@ JS
Plugin::AuthProvider.auth_attributes.each do |sym| Plugin::AuthProvider.auth_attributes.each do |sym|
provider.send "#{sym}=", opts.delete(sym) provider.send "#{sym}=", opts.delete(sym)
end end
after_initialize do
begin
provider.authenticator.enabled?
rescue NotImplementedError
provider.authenticator.define_singleton_method(:enabled?) do
Rails.logger.warn("Auth::Authenticator subclasses should define an `enabled?` function. Patching for now.")
return SiteSetting.send(provider.enabled_setting) if provider.enabled_setting
Rails.logger.warn("Plugin::AuthProvider has not defined an enabled_setting. Defaulting to true.")
true
end
end
end
auth_providers << provider auth_providers << provider
end end
@ -567,6 +582,12 @@ JS
end end
end end
def register_auth_providers!
auth_providers.each do |auth_provider|
DiscoursePluginRegistry.register_auth_provider(auth_provider)
end
end
def register_locales! def register_locales!
root_path = File.dirname(@path) root_path = File.dirname(@path)

View file

@ -38,6 +38,36 @@ describe Auth::FacebookAuthenticator do
expect(result.user.user_profile.location).to eq("America") expect(result.user.user_profile.location).to eq("America")
end end
it 'can connect to a different existing user account' do
authenticator = Auth::FacebookAuthenticator.new
user1 = Fabricate(:user)
user2 = Fabricate(:user)
FacebookUserInfo.create!(user_id: user1.id, facebook_user_id: 100)
hash = {
"extra" => {
"raw_info" => {
"username" => "bob"
}
},
"info" => {
"location" => "America",
"description" => "bio",
"urls" => {
"Website" => "https://awesome.com"
}
},
"uid" => "100"
}
result = authenticator.after_authenticate(hash, existing_account: user2)
expect(result.user.id).to eq(user2.id)
expect(FacebookUserInfo.exists?(user_id: user1.id)).to eq(false)
expect(FacebookUserInfo.exists?(user_id: user2.id)).to eq(true)
end
it 'can create a proper result for non existing users' do it 'can create a proper result for non existing users' do
hash = { hash = {
@ -62,4 +92,58 @@ describe Auth::FacebookAuthenticator do
end end
end end
context 'description_for_user' do
let(:user) { Fabricate(:user) }
let(:authenticator) { Auth::FacebookAuthenticator.new }
it 'returns empty string if no entry for user' do
expect(authenticator.description_for_user(user)).to eq("")
end
it 'returns correct information' do
FacebookUserInfo.create!(user_id: user.id, facebook_user_id: 12345, email: 'someuser@somedomain.tld')
expect(authenticator.description_for_user(user)).to eq('someuser@somedomain.tld')
end
end
context 'revoke' do
let(:user) { Fabricate(:user) }
let(:authenticator) { Auth::FacebookAuthenticator.new }
it 'raises exception if no entry for user' do
expect { authenticator.revoke(user) }.to raise_error(Discourse::NotFound)
end
context "with valid record" do
before do
SiteSetting.facebook_app_id = '123'
SiteSetting.facebook_app_secret = 'abcde'
FacebookUserInfo.create!(user_id: user.id, facebook_user_id: 12345, email: 'someuser@somedomain.tld')
end
it 'revokes correctly' do
stub = stub_request(:delete, authenticator.revoke_url(12345)).to_return(body: "true")
expect(authenticator.can_revoke?).to eq(true)
expect(authenticator.revoke(user)).to eq(true)
expect(stub).to have_been_requested.once
expect(authenticator.description_for_user(user)).to eq("")
end
it 'handles errors correctly' do
stub = stub_request(:delete, authenticator.revoke_url(12345)).to_return(status: 404)
expect(authenticator.revoke(user)).to eq(false)
expect(stub).to have_been_requested.once
expect(authenticator.description_for_user(user)).to eq('someuser@somedomain.tld')
expect(authenticator.revoke(user, skip_remote: true)).to eq(true)
expect(stub).to have_been_requested.once
expect(authenticator.description_for_user(user)).to eq("")
end
end
end
end end

View file

@ -9,7 +9,7 @@ load 'auth/open_id_authenticator.rb'
describe Auth::OpenIdAuthenticator do describe Auth::OpenIdAuthenticator do
it "can lookup pre-existing user if trusted" do it "can lookup pre-existing user if trusted" do
auth = Auth::OpenIdAuthenticator.new("test", "id", trusted: true) auth = Auth::OpenIdAuthenticator.new("test", "id", "enable_yahoo_logins", trusted: true)
user = Fabricate(:user) user = Fabricate(:user)
response = OpenStruct.new(identity_url: 'abc') response = OpenStruct.new(identity_url: 'abc')
@ -18,7 +18,7 @@ describe Auth::OpenIdAuthenticator do
end end
it "raises an exception when email is missing" do it "raises an exception when email is missing" do
auth = Auth::OpenIdAuthenticator.new("test", "id", trusted: true) auth = Auth::OpenIdAuthenticator.new("test", "id", "enable_yahoo_logins", trusted: true)
response = OpenStruct.new(identity_url: 'abc') response = OpenStruct.new(identity_url: 'abc')
expect { auth.after_authenticate(info: {}, extra: { response: response }) }.to raise_error(Discourse::InvalidParameters) expect { auth.after_authenticate(info: {}, extra: { response: response }) }.to raise_error(Discourse::InvalidParameters)
end end

View file

@ -29,6 +29,13 @@ describe DiscoursePluginRegistry do
end end
end end
context '#auth_providers' do
it 'defaults to an empty Set' do
registry.auth_providers = nil
expect(registry.auth_providers).to eq(Set.new)
end
end
context '#admin_javascripts' do context '#admin_javascripts' do
it 'defaults to an empty Set' do it 'defaults to an empty Set' do
registry.admin_javascripts = nil registry.admin_javascripts = nil
@ -92,6 +99,28 @@ describe DiscoursePluginRegistry do
end end
end end
context '.register_auth_provider' do
let(:registry) { DiscoursePluginRegistry }
let(:auth_provider) do
provider = Plugin::AuthProvider.new
provider.authenticator = Auth::Authenticator.new
provider
end
before do
registry.register_auth_provider(auth_provider)
end
after do
registry.reset!
end
it 'is returned by DiscoursePluginRegistry.auth_providers' do
expect(registry.auth_providers.include?(auth_provider)).to eq(true)
end
end
context '.register_service_worker' do context '.register_service_worker' do
let(:registry) { DiscoursePluginRegistry } let(:registry) { DiscoursePluginRegistry }

View file

@ -59,6 +59,54 @@ describe Discourse do
end end
end end
context 'authenticators' do
it 'returns inbuilt authenticators' do
expect(Discourse.authenticators).to match_array(Users::OmniauthCallbacksController::BUILTIN_AUTH)
end
context 'with authentication plugin installed' do
let(:plugin_auth_provider) do
authenticator_class = Class.new(Auth::Authenticator) do
def name
'pluginauth'
end
def enabled
true
end
end
provider = Plugin::AuthProvider.new
provider.authenticator = authenticator_class.new
provider
end
before do
DiscoursePluginRegistry.register_auth_provider(plugin_auth_provider)
end
after do
DiscoursePluginRegistry.reset!
end
it 'returns inbuilt and plugin authenticators' do
expect(Discourse.authenticators).to match_array(
Users::OmniauthCallbacksController::BUILTIN_AUTH + [plugin_auth_provider.authenticator])
end
end
end
context 'enabled_authenticators' do
it 'only returns enabled authenticators' do
expect(Discourse.enabled_authenticators.length).to be(0)
expect { SiteSetting.enable_twitter_logins = true }
.to change { Discourse.enabled_authenticators.length }.by(1)
expect(Discourse.enabled_authenticators.length).to be(1)
expect(Discourse.enabled_authenticators.first).to be_instance_of(Auth::TwitterAuthenticator)
end
end
context '#site_contact_user' do context '#site_contact_user' do
let!(:admin) { Fabricate(:admin) } let!(:admin) { Fabricate(:admin) }

View file

@ -125,7 +125,40 @@ describe Plugin::Instance do
end end
end end
it 'patches the enabled? function for auth_providers if not defined' do
SiteSetting.stubs(:ubuntu_login_enabled).returns(false)
plugin = Plugin::Instance.new
# No enabled_site_setting
authenticator = Auth::Authenticator.new
plugin.auth_provider(authenticator: authenticator)
plugin.notify_after_initialize
expect(authenticator.enabled?).to eq(true)
# With enabled site setting
authenticator = Auth::Authenticator.new
plugin.auth_provider(enabled_setting: 'ubuntu_login_enabled', authenticator: authenticator)
plugin.notify_after_initialize
expect(authenticator.enabled?).to eq(false)
# Defines own method
SiteSetting.stubs(:ubuntu_login_enabled).returns(true)
authenticator = Class.new(Auth::Authenticator) do
def enabled?
false
end
end.new
plugin.auth_provider(enabled_setting: 'ubuntu_login_enabled', authenticator: authenticator)
plugin.notify_after_initialize
expect(authenticator.enabled?).to eq(false)
end
context "activate!" do context "activate!" do
before do
SiteSetting.stubs(:ubuntu_login_enabled).returns(false)
end
it "can activate plugins correctly" do it "can activate plugins correctly" do
plugin = Plugin::Instance.new plugin = Plugin::Instance.new
plugin.path = "#{Rails.root}/spec/fixtures/plugins/my_plugin/plugin.rb" plugin.path = "#{Rails.root}/spec/fixtures/plugins/my_plugin/plugin.rb"
@ -135,10 +168,6 @@ describe Plugin::Instance do
File.open("#{plugin.auto_generated_path}/junk", "w") { |f| f.write("junk") } File.open("#{plugin.auto_generated_path}/junk", "w") { |f| f.write("junk") }
plugin.activate! plugin.activate!
expect(plugin.auth_providers.count).to eq(1)
auth_provider = plugin.auth_providers[0]
expect(auth_provider.authenticator.name).to eq('ubuntu')
# calls ensure_assets! make sure they are there # calls ensure_assets! make sure they are there
expect(plugin.assets.count).to eq(1) expect(plugin.assets.count).to eq(1)
plugin.assets.each do |a, opts| plugin.assets.each do |a, opts|
@ -149,6 +178,17 @@ describe Plugin::Instance do
expect(File.exists?(junk_file)).to eq(false) expect(File.exists?(junk_file)).to eq(false)
end end
it "registers auth providers correctly" do
plugin = Plugin::Instance.new
plugin.path = "#{Rails.root}/spec/fixtures/plugins/my_plugin/plugin.rb"
plugin.activate!
expect(plugin.auth_providers.count).to eq(1)
auth_provider = plugin.auth_providers[0]
expect(auth_provider.authenticator.name).to eq('ubuntu')
expect(DiscoursePluginRegistry.auth_providers.count).to eq(1)
end
it "finds all the custom assets" do it "finds all the custom assets" do
plugin = Plugin::Instance.new plugin = Plugin::Instance.new
plugin.path = "#{Rails.root}/spec/fixtures/plugins/my_plugin/plugin.rb" plugin.path = "#{Rails.root}/spec/fixtures/plugins/my_plugin/plugin.rb"

View file

@ -4,7 +4,7 @@
# authors: Frank Zappa # authors: Frank Zappa
auth_provider title: 'with Ubuntu', auth_provider title: 'with Ubuntu',
authenticator: Auth::OpenIdAuthenticator.new('ubuntu', 'https://login.ubuntu.com', trusted: true), authenticator: Auth::OpenIdAuthenticator.new('ubuntu', 'https://login.ubuntu.com', 'ubuntu_login_enabled', trusted: true),
message: 'Authenticating with Ubuntu (make sure pop up blockers are not enbaled)', message: 'Authenticating with Ubuntu (make sure pop up blockers are not enbaled)',
frame_width: 1000, # the frame size used for the pop up window, overrides default frame_width: 1000, # the frame size used for the pop up window, overrides default
frame_height: 800 frame_height: 800

View file

@ -416,17 +416,16 @@ describe User do
describe 'associated_accounts' do describe 'associated_accounts' do
it 'should correctly find social associations' do it 'should correctly find social associations' do
user = Fabricate(:user) user = Fabricate(:user)
expect(user.associated_accounts).to eq(I18n.t("user.no_accounts_associated")) expect(user.associated_accounts).to eq([])
TwitterUserInfo.create(user_id: user.id, screen_name: "sam", twitter_user_id: 1) TwitterUserInfo.create(user_id: user.id, screen_name: "sam", twitter_user_id: 1)
FacebookUserInfo.create(user_id: user.id, username: "sam", facebook_user_id: 1) FacebookUserInfo.create(user_id: user.id, username: "sam", facebook_user_id: 1)
GoogleUserInfo.create(user_id: user.id, email: "sam@sam.com", google_user_id: 1) GoogleUserInfo.create(user_id: user.id, email: "sam@sam.com", google_user_id: 1)
GithubUserInfo.create(user_id: user.id, screen_name: "sam", github_user_id: 1) GithubUserInfo.create(user_id: user.id, screen_name: "sam", github_user_id: 1)
Oauth2UserInfo.create(user_id: user.id, provider: "linkedin", email: "sam@sam.com", uid: 1)
InstagramUserInfo.create(user_id: user.id, screen_name: "sam", instagram_user_id: "examplel123123") InstagramUserInfo.create(user_id: user.id, screen_name: "sam", instagram_user_id: "examplel123123")
user.reload user.reload
expect(user.associated_accounts).to eq("Twitter(sam), Facebook(sam), Google(sam@sam.com), GitHub(sam), Instagram(sam), linkedin(sam@sam.com)") expect(user.associated_accounts.map { |a| a[:name] }).to contain_exactly('twitter', 'facebook', 'google_oauth2', 'github', 'instagram')
end end
end end

View file

@ -38,13 +38,26 @@ RSpec.describe Users::OmniauthCallbacksController do
let :provider do let :provider do
provider = Plugin::AuthProvider.new provider = Plugin::AuthProvider.new
provider.authenticator = Auth::OpenIdAuthenticator.new('ubuntu', 'https://login.ubuntu.com', trusted: true) provider.authenticator = Class.new(Auth::Authenticator) do
def name
'ubuntu'
end
def enabled?
SiteSetting.ubuntu_login_enabled
end
end.new
provider.enabled_setting = "ubuntu_login_enabled" provider.enabled_setting = "ubuntu_login_enabled"
provider provider
end end
before do before do
Discourse.stubs(:auth_providers).returns [provider] DiscoursePluginRegistry.register_auth_provider(provider)
end
after do
DiscoursePluginRegistry.reset!
end end
it "finds an authenticator when enabled" do it "finds an authenticator when enabled" do
@ -60,14 +73,6 @@ RSpec.describe Users::OmniauthCallbacksController do
expect { Users::OmniauthCallbacksController.find_authenticator("ubuntu") } expect { Users::OmniauthCallbacksController.find_authenticator("ubuntu") }
.to raise_error(Discourse::InvalidAccess) .to raise_error(Discourse::InvalidAccess)
end end
it "succeeds if an authenticator does not have a site setting" do
provider.enabled_setting = nil
SiteSetting.stubs(:ubuntu_login_enabled).returns(false)
expect(Users::OmniauthCallbacksController.find_authenticator("ubuntu"))
.to be(provider.authenticator)
end
end end
end end

View file

@ -1965,7 +1965,7 @@ describe UsersController do
json = JSON.parse(response.body) json = JSON.parse(response.body)
expect(json["email"]).to eq(user.email) expect(json["email"]).to eq(user.email)
expect(json["secondary_emails"]).to eq(user.secondary_emails) expect(json["secondary_emails"]).to eq(user.secondary_emails)
expect(json["associated_accounts"]).to be_present expect(json["associated_accounts"]).to eq([])
end end
it "works on inactive users" do it "works on inactive users" do
@ -1978,7 +1978,7 @@ describe UsersController do
json = JSON.parse(response.body) json = JSON.parse(response.body)
expect(json["email"]).to eq(inactive_user.email) expect(json["email"]).to eq(inactive_user.email)
expect(json["secondary_emails"]).to eq(inactive_user.secondary_emails) expect(json["secondary_emails"]).to eq(inactive_user.secondary_emails)
expect(json["associated_accounts"]).to be_present expect(json["associated_accounts"]).to eq([])
end end
end end
end end
@ -3068,4 +3068,46 @@ describe UsersController do
end end
end end
end end
describe '#revoke_account' do
let(:other_user) { Fabricate(:user) }
it 'errors for unauthorised users' do
post "/u/#{user.username}/preferences/revoke-account.json", params: {
provider_name: 'facebook'
}
expect(response.status).to eq(403)
sign_in(other_user)
post "/u/#{user.username}/preferences/revoke-account.json", params: {
provider_name: 'facebook'
}
expect(response.status).to eq(403)
end
context 'while logged in' do
before do
sign_in(user)
end
it 'returns an error when there is no matching account' do
post "/u/#{user.username}/preferences/revoke-account.json", params: {
provider_name: 'facebook'
}
expect(response.status).to eq(404)
end
it 'works' do
FacebookUserInfo.create!(user_id: user.id, facebook_user_id: 12345, email: 'someuser@somedomain.tld')
stub = stub_request(:delete, 'https://graph.facebook.com/12345/permissions?access_token=123%7Cabcde').to_return(body: "true")
post "/u/#{user.username}/preferences/revoke-account.json", params: {
provider_name: 'facebook'
}
expect(response.status).to eq(200)
end
end
end
end end

View file

@ -181,7 +181,7 @@ describe UserAnonymizer do
user.github_user_info = GithubUserInfo.create(user_id: user.id, screen_name: "example", github_user_id: "examplel123123") user.github_user_info = GithubUserInfo.create(user_id: user.id, screen_name: "example", github_user_id: "examplel123123")
user.facebook_user_info = FacebookUserInfo.create(user_id: user.id, facebook_user_id: "example") user.facebook_user_info = FacebookUserInfo.create(user_id: user.id, facebook_user_id: "example")
user.single_sign_on_record = SingleSignOnRecord.create(user_id: user.id, external_id: "example", last_payload: "looks good") user.single_sign_on_record = SingleSignOnRecord.create(user_id: user.id, external_id: "example", last_payload: "looks good")
user.oauth2_user_info = Oauth2UserInfo.create(user_id: user.id, uid: "example", provider: "example") user.oauth2_user_infos = [Oauth2UserInfo.create(user_id: user.id, uid: "example", provider: "example")]
user.instagram_user_info = InstagramUserInfo.create(user_id: user.id, screen_name: "example", instagram_user_id: "examplel123123") user.instagram_user_info = InstagramUserInfo.create(user_id: user.id, screen_name: "example", instagram_user_id: "examplel123123")
UserOpenId.create(user_id: user.id, email: user.email, url: "http://example.com/openid", active: true) UserOpenId.create(user_id: user.id, email: user.email, url: "http://example.com/openid", active: true)
make_anonymous make_anonymous
@ -191,7 +191,7 @@ describe UserAnonymizer do
expect(user.github_user_info).to eq(nil) expect(user.github_user_info).to eq(nil)
expect(user.facebook_user_info).to eq(nil) expect(user.facebook_user_info).to eq(nil)
expect(user.single_sign_on_record).to eq(nil) expect(user.single_sign_on_record).to eq(nil)
expect(user.oauth2_user_info).to eq(nil) expect(user.oauth2_user_infos).to be_empty
expect(user.instagram_user_info).to eq(nil) expect(user.instagram_user_info).to eq(nil)
expect(user.user_open_ids.count).to eq(0) expect(user.user_open_ids.count).to eq(0)
end end

View file

@ -1,43 +1,42 @@
import { acceptance } from "helpers/qunit-helpers"; import { acceptance } from "helpers/qunit-helpers";
acceptance("User Preferences", { acceptance("User Preferences", {
loggedIn: true, loggedIn: true,
beforeEach() { pretend(server, helper) {
const response = object => { server.post("/u/second_factors.json", () => {
return [200, { "Content-Type": "application/json" }, object]; return helper.response({
};
// prettier-ignore
server.post("/u/second_factors.json", () => { //eslint-disable-line
return response({
key: "rcyryaqage3jexfj", key: "rcyryaqage3jexfj",
qr: '<div id="test-qr">qr-code</div>' qr: '<div id="test-qr">qr-code</div>'
}); });
}); });
// prettier-ignore server.put("/u/second_factor.json", () => {
server.put("/u/second_factor.json", () => { //eslint-disable-line return helper.response({ error: "invalid token" });
return response({ error: "invalid token" });
}); });
// prettier-ignore server.put("/u/second_factors_backup.json", () => {
server.put("/u/second_factors_backup.json", () => { //eslint-disable-line return helper.response({
return response({ backup_codes: ["dsffdsd", "fdfdfdsf", "fddsds"] }); backup_codes: ["dsffdsd", "fdfdfdsf", "fddsds"]
});
});
server.post("/u/eviltrout/preferences/revoke-account", () => {
return helper.response({
success: true
});
}); });
} }
}); });
QUnit.test("update some fields", assert => { QUnit.test("update some fields", async assert => {
visit("/u/eviltrout/preferences"); await visit("/u/eviltrout/preferences");
andThen(() => { assert.ok($("body.user-preferences-page").length, "has the body class");
assert.ok($("body.user-preferences-page").length, "has the body class"); assert.equal(
assert.equal( currentURL(),
currentURL(), "/u/eviltrout/preferences/account",
"/u/eviltrout/preferences/account", "defaults to account tab"
"defaults to account tab" );
); assert.ok(exists(".user-preferences"), "it shows the preferences");
assert.ok(exists(".user-preferences"), "it shows the preferences");
});
const savePreferences = () => { const savePreferences = () => {
click(".save-user"); click(".save-user");
@ -48,25 +47,25 @@ QUnit.test("update some fields", assert => {
}; };
fillIn(".pref-name input[type=text]", "Jon Snow"); fillIn(".pref-name input[type=text]", "Jon Snow");
savePreferences(); await savePreferences();
click(".preferences-nav .nav-profile a"); click(".preferences-nav .nav-profile a");
fillIn("#edit-location", "Westeros"); fillIn("#edit-location", "Westeros");
savePreferences(); await savePreferences();
click(".preferences-nav .nav-emails a"); click(".preferences-nav .nav-emails a");
click(".pref-activity-summary input[type=checkbox]"); click(".pref-activity-summary input[type=checkbox]");
savePreferences(); await savePreferences();
click(".preferences-nav .nav-notifications a"); click(".preferences-nav .nav-notifications a");
selectKit(".control-group.notifications .combo-box.duration") selectKit(".control-group.notifications .combo-box.duration")
.expand() .expand()
.selectRowByValue(1440); .selectRowByValue(1440);
savePreferences(); await savePreferences();
click(".preferences-nav .nav-categories a"); click(".preferences-nav .nav-categories a");
fillIn(".category-controls .category-selector", "faq"); fillIn(".category-controls .category-selector", "faq");
savePreferences(); await savePreferences();
assert.ok( assert.ok(
!exists(".preferences-nav .nav-tags a"), !exists(".preferences-nav .nav-tags a"),
@ -84,83 +83,87 @@ QUnit.test("update some fields", assert => {
); );
}); });
QUnit.test("username", assert => { QUnit.test("username", async assert => {
visit("/u/eviltrout/preferences/username"); await visit("/u/eviltrout/preferences/username");
andThen(() => { assert.ok(exists("#change_username"), "it has the input element");
assert.ok(exists("#change_username"), "it has the input element");
});
}); });
QUnit.test("about me", assert => { QUnit.test("about me", async assert => {
visit("/u/eviltrout/preferences/about-me"); await visit("/u/eviltrout/preferences/about-me");
andThen(() => { assert.ok(exists(".raw-bio"), "it has the input element");
assert.ok(exists(".raw-bio"), "it has the input element");
});
}); });
QUnit.test("email", assert => { QUnit.test("email", async assert => {
visit("/u/eviltrout/preferences/email"); await visit("/u/eviltrout/preferences/email");
andThen(() => {
assert.ok(exists("#change-email"), "it has the input element");
});
fillIn("#change-email", "invalidemail"); assert.ok(exists("#change-email"), "it has the input element");
andThen(() => { await fillIn("#change-email", "invalidemail");
assert.equal(
find(".tip.bad") assert.equal(
.text() find(".tip.bad")
.trim(), .text()
I18n.t("user.email.invalid"), .trim(),
"it should display invalid email tip" I18n.t("user.email.invalid"),
); "it should display invalid email tip"
}); );
}); });
QUnit.test("second factor", assert => { QUnit.test("connected accounts", async assert => {
visit("/u/eviltrout/preferences/second-factor"); await visit("/u/eviltrout/preferences/account");
andThen(() => { assert.ok(
assert.ok(exists("#password"), "it has a password input"); exists(".pref-associated-accounts"),
}); "it has the connected accounts section"
);
assert.ok(
find(".pref-associated-accounts table tr:first td:first")
.html()
.indexOf("Facebook") > -1,
"it lists facebook"
);
fillIn("#password", "secrets"); await click(".pref-associated-accounts table tr:first td:last button");
click(".user-preferences .btn-primary");
andThen(() => { find(".pref-associated-accounts table tr:first td:last button")
assert.ok(exists("#test-qr"), "shows qr code"); .html()
assert.notOk(exists("#password"), "it hides the password input"); .indexOf("Connect") > -1;
});
fillIn("#second-factor-token", "111111");
click(".btn-primary");
andThen(() => {
assert.ok(
find(".alert-error")
.html()
.indexOf("invalid token") > -1,
"shows server validation error message"
);
});
}); });
QUnit.test("second factor backup", assert => { QUnit.test("second factor", async assert => {
visit("/u/eviltrout/preferences/second-factor-backup"); await visit("/u/eviltrout/preferences/second-factor");
andThen(() => { assert.ok(exists("#password"), "it has a password input");
assert.ok(
exists("#second-factor-token"),
"it has a authentication token input"
);
});
fillIn("#second-factor-token", "111111"); await fillIn("#password", "secrets");
click(".user-preferences .btn-primary"); await click(".user-preferences .btn-primary");
andThen(() => { assert.ok(exists("#test-qr"), "shows qr code");
assert.ok(exists(".backup-codes-area"), "shows backup codes"); assert.notOk(exists("#password"), "it hides the password input");
});
await fillIn("#second-factor-token", "111111");
await click(".btn-primary");
assert.ok(
find(".alert-error")
.html()
.indexOf("invalid token") > -1,
"shows server validation error message"
);
});
QUnit.test("second factor backup", async assert => {
await visit("/u/eviltrout/preferences/second-factor-backup");
assert.ok(
exists("#second-factor-token"),
"it has a authentication token input"
);
await fillIn("#second-factor-token", "111111");
await click(".user-preferences .btn-primary");
assert.ok(exists(".backup-codes-area"), "shows backup codes");
}); });
QUnit.test("default avatar selector", assert => { QUnit.test("default avatar selector", assert => {
@ -175,26 +178,23 @@ QUnit.test("default avatar selector", assert => {
acceptance("Avatar selector when selectable avatars is enabled", { acceptance("Avatar selector when selectable avatars is enabled", {
loggedIn: true, loggedIn: true,
settings: { selectable_avatars_enabled: true }, settings: { selectable_avatars_enabled: true },
beforeEach() { pretend(server) {
// prettier-ignore server.get("/site/selectable-avatars.json", () => {
server.get("/site/selectable-avatars.json", () => { //eslint-disable-line return [
return [200, { "Content-Type": "application/json" }, [ 200,
"https://www.discourse.org", { "Content-Type": "application/json" },
"https://meta.discourse.org", ["https://www.discourse.org", "https://meta.discourse.org"]
]]; ];
}); });
} }
}); });
QUnit.test("selectable avatars", assert => { QUnit.test("selectable avatars", async assert => {
visit("/u/eviltrout/preferences"); await visit("/u/eviltrout/preferences");
click(".pref-avatar .btn"); await click(".pref-avatar .btn");
andThen(() => {
assert.ok( assert.ok(exists(".selectable-avatars", "opens the avatar selection modal"));
exists(".selectable-avatars", "opens the avatar selection modal")
);
});
}); });
acceptance("User Preferences when badges are disabled", { acceptance("User Preferences when badges are disabled", {

View file

@ -114,6 +114,13 @@ export default {
"/letter_avatar/eviltrout/{size}/3_f9720745f5ce6dfc2b5641fca999d934.png", "/letter_avatar/eviltrout/{size}/3_f9720745f5ce6dfc2b5641fca999d934.png",
name: "Robin Ward", name: "Robin Ward",
email: "robin.ward@example.com", email: "robin.ward@example.com",
associated_accounts: [
{
name: "facebook",
description: "robin.ward@example.com",
can_revoke: true
}
],
last_posted_at: "2015-05-07T15:23:35.074Z", last_posted_at: "2015-05-07T15:23:35.074Z",
last_seen_at: "2015-05-13T14:34:23.188Z", last_seen_at: "2015-05-13T14:34:23.188Z",
bio_raw: bio_raw: