FEATURE: API to create user's associated account (#15737)
Discourse users and associated accounts are created or updated when a user logins or connects the account using their account preferences. This new API can be used to create associated accounts and users too, if necessary.
This commit is contained in:
parent
a7db0ce985
commit
39ab14531a
|
@ -159,6 +159,17 @@ class UsersController < ApplicationController
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if params[:external_ids]&.is_a?(ActionController::Parameters) && current_user&.admin? && is_api?
|
||||||
|
attributes[:user_associated_accounts] = []
|
||||||
|
|
||||||
|
params[:external_ids].each do |provider_name, provider_uid|
|
||||||
|
authenticator = Discourse.enabled_authenticators.find { |a| a.name == provider_name }
|
||||||
|
raise Discourse::InvalidParameters.new(:external_ids) if !authenticator&.is_managed?
|
||||||
|
|
||||||
|
attributes[:user_associated_accounts] << { provider_name: provider_name, provider_uid: provider_uid }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
json_result(user, serializer: UserSerializer, additional_errors: [:user_profile, :user_option]) do |u|
|
json_result(user, serializer: UserSerializer, additional_errors: [:user_profile, :user_option]) do |u|
|
||||||
updater = UserUpdater.new(current_user, user)
|
updater = UserUpdater.new(current_user, user)
|
||||||
updater.update(attributes.permit!)
|
updater.update(attributes.permit!)
|
||||||
|
@ -632,6 +643,7 @@ class UsersController < ApplicationController
|
||||||
params.require(:username)
|
params.require(:username)
|
||||||
params.require(:invite_code) if SiteSetting.require_invite_code
|
params.require(:invite_code) if SiteSetting.require_invite_code
|
||||||
params.permit(:user_fields)
|
params.permit(:user_fields)
|
||||||
|
params.permit(:external_ids)
|
||||||
|
|
||||||
unless SiteSetting.allow_new_registrations
|
unless SiteSetting.allow_new_registrations
|
||||||
return fail_with("login.new_registrations_disabled")
|
return fail_with("login.new_registrations_disabled")
|
||||||
|
@ -691,6 +703,18 @@ class UsersController < ApplicationController
|
||||||
user.custom_fields = fields
|
user.custom_fields = fields
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Handle associated accounts
|
||||||
|
associations = []
|
||||||
|
if params[:external_ids]&.is_a?(ActionController::Parameters) && current_user&.admin? && is_api?
|
||||||
|
params[:external_ids].each do |provider_name, provider_uid|
|
||||||
|
authenticator = Discourse.enabled_authenticators.find { |a| a.name == provider_name }
|
||||||
|
raise Discourse::InvalidParameters.new(:external_ids) if !authenticator&.is_managed?
|
||||||
|
|
||||||
|
association = UserAssociatedAccount.find_or_initialize_by(provider_name: provider_name, provider_uid: provider_uid)
|
||||||
|
associations << association
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
authentication = UserAuthenticator.new(user, session)
|
authentication = UserAuthenticator.new(user, session)
|
||||||
|
|
||||||
if !authentication.has_authenticator? && !SiteSetting.enable_local_logins && !(current_user&.admin? && is_api?)
|
if !authentication.has_authenticator? && !SiteSetting.enable_local_logins && !(current_user&.admin? && is_api?)
|
||||||
|
@ -709,11 +733,12 @@ class UsersController < ApplicationController
|
||||||
|
|
||||||
# just assign a password if we have an authenticator and no password
|
# just assign a password if we have an authenticator and no password
|
||||||
# this is the case for Twitter
|
# this is the case for Twitter
|
||||||
user.password = SecureRandom.hex if user.password.blank? && authentication.has_authenticator?
|
user.password = SecureRandom.hex if user.password.blank? && (authentication.has_authenticator? || associations.present?)
|
||||||
|
|
||||||
if user.save
|
if user.save
|
||||||
authentication.finish
|
authentication.finish
|
||||||
activation.finish
|
activation.finish
|
||||||
|
associations.each { |a| a.update!(user: user) }
|
||||||
user.update_timezone_if_missing(params[:timezone])
|
user.update_timezone_if_missing(params[:timezone])
|
||||||
|
|
||||||
secure_session[HONEYPOT_KEY] = nil
|
secure_session[HONEYPOT_KEY] = nil
|
||||||
|
|
|
@ -35,7 +35,8 @@ class AdminDetailedUserSerializer < AdminUserSerializer
|
||||||
:second_factor_enabled,
|
:second_factor_enabled,
|
||||||
:can_disable_second_factor,
|
:can_disable_second_factor,
|
||||||
:can_delete_sso_record,
|
:can_delete_sso_record,
|
||||||
:api_key_count
|
:api_key_count,
|
||||||
|
:external_ids
|
||||||
|
|
||||||
has_one :approved_by, serializer: BasicUserSerializer, embed: :objects
|
has_one :approved_by, serializer: BasicUserSerializer, embed: :objects
|
||||||
has_one :suspended_by, serializer: BasicUserSerializer, embed: :objects
|
has_one :suspended_by, serializer: BasicUserSerializer, embed: :objects
|
||||||
|
@ -145,6 +146,16 @@ class AdminDetailedUserSerializer < AdminUserSerializer
|
||||||
object.api_keys.active.count
|
object.api_keys.active.count
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def external_ids
|
||||||
|
external_ids = {}
|
||||||
|
|
||||||
|
object.user_associated_accounts.map do |user_associated_account|
|
||||||
|
external_ids[user_associated_account.provider_name] = user_associated_account.provider_uid
|
||||||
|
end
|
||||||
|
|
||||||
|
external_ids
|
||||||
|
end
|
||||||
|
|
||||||
def can_delete_sso_record
|
def can_delete_sso_record
|
||||||
scope.can_delete_sso_record?(object)
|
scope.can_delete_sso_record?(object)
|
||||||
end
|
end
|
||||||
|
|
|
@ -197,6 +197,10 @@ class UserUpdater
|
||||||
update_allowed_pm_users(attributes[:allowed_pm_usernames])
|
update_allowed_pm_users(attributes[:allowed_pm_usernames])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if attributes.key?(:user_associated_accounts)
|
||||||
|
updated_associated_accounts(attributes[:user_associated_accounts])
|
||||||
|
end
|
||||||
|
|
||||||
name_changed = user.name_changed?
|
name_changed = user.name_changed?
|
||||||
if (saved = (!save_options || user.user_option.save) && (user_notification_schedule.nil? || user_notification_schedule.save) && user_profile.save && user.save) &&
|
if (saved = (!save_options || user.user_option.save) && (user_notification_schedule.nil? || user_notification_schedule.save) && user_profile.save && user.save) &&
|
||||||
(name_changed && old_user_name.casecmp(attributes.fetch(:name)) != 0)
|
(name_changed && old_user_name.casecmp(attributes.fetch(:name)) != 0)
|
||||||
|
@ -265,6 +269,17 @@ class UserUpdater
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def updated_associated_accounts(associations)
|
||||||
|
associations.each do |association|
|
||||||
|
user_associated_account = UserAssociatedAccount.find_or_initialize_by(user_id: user.id, provider_name: association[:provider_name])
|
||||||
|
if association[:provider_uid].present?
|
||||||
|
user_associated_account.update!(provider_uid: association[:provider_uid])
|
||||||
|
else
|
||||||
|
user_associated_account.destroy!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
attr_reader :user, :guardian
|
attr_reader :user, :guardian
|
||||||
|
|
|
@ -141,7 +141,7 @@ describe Discourse do
|
||||||
'pluginauth'
|
'pluginauth'
|
||||||
end
|
end
|
||||||
|
|
||||||
def enabled
|
def enabled?
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -48,6 +48,15 @@ RSpec.describe Admin::UsersController do
|
||||||
get "/admin/users/#{user.id}.json"
|
get "/admin/users/#{user.id}.json"
|
||||||
expect(response.status).to eq(200)
|
expect(response.status).to eq(200)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'includes associated accounts' do
|
||||||
|
user.user_associated_accounts.create!(provider_name: 'pluginauth', provider_uid: 'pluginauth_uid')
|
||||||
|
|
||||||
|
get "/admin/users/#{user.id}.json"
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expect(response.parsed_body['external_ids'].size).to eq(1)
|
||||||
|
expect(response.parsed_body['external_ids']['pluginauth']).to eq('pluginauth_uid')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'a non-existing user' do
|
context 'a non-existing user' do
|
||||||
|
|
|
@ -486,6 +486,9 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"external_ids": {
|
||||||
|
"type": "object"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
@ -547,6 +550,7 @@
|
||||||
"approved_by",
|
"approved_by",
|
||||||
"suspended_by",
|
"suspended_by",
|
||||||
"silenced_by",
|
"silenced_by",
|
||||||
"groups"
|
"groups",
|
||||||
|
"external_ids"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,6 +21,9 @@
|
||||||
},
|
},
|
||||||
"user_fields[1]": {
|
"user_fields[1]": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"external_ids": {
|
||||||
|
"type": "object"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"external_ids": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"success",
|
||||||
|
"user"
|
||||||
|
]
|
||||||
|
}
|
|
@ -71,6 +71,32 @@ describe 'users' do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
put 'Update a user' do
|
||||||
|
tags 'Users'
|
||||||
|
operationId 'updateUser'
|
||||||
|
consumes 'application/json'
|
||||||
|
|
||||||
|
parameter name: 'Api-Key', in: :header, type: :string, required: true
|
||||||
|
parameter name: 'Api-Username', in: :header, type: :string, required: true
|
||||||
|
expected_request_schema = load_spec_schema('user_update_request')
|
||||||
|
parameter name: :username, in: :path, type: :string, required: true
|
||||||
|
parameter name: :params, in: :body, schema: expected_request_schema
|
||||||
|
|
||||||
|
produces 'application/json'
|
||||||
|
response '200', 'user updated' do
|
||||||
|
expected_response_schema = load_spec_schema('user_update_response')
|
||||||
|
schema expected_response_schema
|
||||||
|
|
||||||
|
let(:username) { Fabricate(:user).username }
|
||||||
|
let(:params) { { 'name' => 'user' } }
|
||||||
|
|
||||||
|
it_behaves_like "a JSON endpoint", 200 do
|
||||||
|
let(:expected_response_schema) { expected_response_schema }
|
||||||
|
let(:expected_request_schema) { expected_request_schema }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
path '/u/by-external/{external_id}.json' do
|
path '/u/by-external/{external_id}.json' do
|
||||||
|
|
|
@ -648,6 +648,69 @@ describe UsersController do
|
||||||
expect(response.status).to eq(200)
|
expect(response.status).to eq(200)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'with external_ids' do
|
||||||
|
fab!(:api_key, refind: false) { Fabricate(:api_key, user: admin) }
|
||||||
|
|
||||||
|
let(:plugin_auth_provider) do
|
||||||
|
authenticator_class = Class.new(Auth::ManagedAuthenticator) do
|
||||||
|
def name
|
||||||
|
'pluginauth'
|
||||||
|
end
|
||||||
|
|
||||||
|
def enabled?
|
||||||
|
true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
provider = Auth::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 'creates User record' do
|
||||||
|
params = {
|
||||||
|
username: 'foobar',
|
||||||
|
email: 'test@example.com',
|
||||||
|
external_ids: { 'pluginauth' => 'pluginauth_uid' },
|
||||||
|
}
|
||||||
|
|
||||||
|
expect { post "/u.json", params: params, headers: { HTTP_API_KEY: api_key.key } }
|
||||||
|
.to change { UserAssociatedAccount.count }.by(1)
|
||||||
|
.and change { User.count }.by(1)
|
||||||
|
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
|
||||||
|
user = User.last
|
||||||
|
user_associated_account = UserAssociatedAccount.last
|
||||||
|
|
||||||
|
expect(user.username).to eq('foobar')
|
||||||
|
expect(user.email).to eq('test@example.com')
|
||||||
|
expect(user.user_associated_account_ids).to contain_exactly(user_associated_account.id)
|
||||||
|
expect(user_associated_account.provider_name).to eq('pluginauth')
|
||||||
|
expect(user_associated_account.provider_uid).to eq('pluginauth_uid')
|
||||||
|
expect(user_associated_account.user_id).to eq(user.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns error if external ID provider does not exist' do
|
||||||
|
params = {
|
||||||
|
username: 'foobar',
|
||||||
|
email: 'test@example.com',
|
||||||
|
external_ids: { 'pluginauth2' => 'pluginauth_uid' },
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/u.json", params: params, headers: { HTTP_API_KEY: api_key.key }
|
||||||
|
expect(response.status).to eq(400)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when creating a non active user (unconfirmed email)' do
|
context 'when creating a non active user (unconfirmed email)' do
|
||||||
|
@ -2231,6 +2294,74 @@ describe UsersController do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'with external_ids' do
|
||||||
|
fab!(:api_key, refind: false) { Fabricate(:api_key, user: admin) }
|
||||||
|
|
||||||
|
let(:plugin_auth_provider) do
|
||||||
|
authenticator_class = Class.new(Auth::ManagedAuthenticator) do
|
||||||
|
def name
|
||||||
|
'pluginauth'
|
||||||
|
end
|
||||||
|
|
||||||
|
def enabled?
|
||||||
|
true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
provider = Auth::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 'can create UserAssociatedAccount records' do
|
||||||
|
params = {
|
||||||
|
external_ids: { 'pluginauth' => 'pluginauth_uid' },
|
||||||
|
}
|
||||||
|
|
||||||
|
expect { put "/u/#{user.username}.json", params: params, headers: { HTTP_API_KEY: api_key.key } }
|
||||||
|
.to change { UserAssociatedAccount.count }.by(1)
|
||||||
|
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
|
||||||
|
user_associated_account = UserAssociatedAccount.last
|
||||||
|
expect(user.reload.user_associated_account_ids).to contain_exactly(user_associated_account.id)
|
||||||
|
expect(user_associated_account.provider_name).to eq('pluginauth')
|
||||||
|
expect(user_associated_account.provider_uid).to eq('pluginauth_uid')
|
||||||
|
expect(user_associated_account.user_id).to eq(user.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'can destroy UserAssociatedAccount records' do
|
||||||
|
user.user_associated_accounts.create!(provider_name: 'pluginauth', provider_uid: 'pluginauth_uid')
|
||||||
|
|
||||||
|
params = {
|
||||||
|
external_ids: { 'pluginauth' => nil },
|
||||||
|
}
|
||||||
|
|
||||||
|
expect { put "/u/#{user.username}.json", params: params, headers: { HTTP_API_KEY: api_key.key } }
|
||||||
|
.to change { UserAssociatedAccount.count }.by(-1)
|
||||||
|
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expect(user.reload.user_associated_account_ids).to be_blank
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns error if external ID provider does not exist' do
|
||||||
|
params = {
|
||||||
|
external_ids: { 'pluginauth2' => 'pluginauth_uid' },
|
||||||
|
}
|
||||||
|
|
||||||
|
put "/u/#{user.username}.json", params: params, headers: { HTTP_API_KEY: api_key.key }
|
||||||
|
expect(response.status).to eq(400)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#badge_title' do
|
describe '#badge_title' do
|
||||||
|
|
Loading…
Reference in New Issue