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:
Dan Ungureanu 2022-03-03 18:17:02 +02:00 committed by GitHub
parent a7db0ce985
commit 39ab14531a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 260 additions and 4 deletions

View File

@ -159,6 +159,17 @@ class UsersController < ApplicationController
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|
updater = UserUpdater.new(current_user, user)
updater.update(attributes.permit!)
@ -632,6 +643,7 @@ class UsersController < ApplicationController
params.require(:username)
params.require(:invite_code) if SiteSetting.require_invite_code
params.permit(:user_fields)
params.permit(:external_ids)
unless SiteSetting.allow_new_registrations
return fail_with("login.new_registrations_disabled")
@ -691,6 +703,18 @@ class UsersController < ApplicationController
user.custom_fields = fields
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)
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
# 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
authentication.finish
activation.finish
associations.each { |a| a.update!(user: user) }
user.update_timezone_if_missing(params[:timezone])
secure_session[HONEYPOT_KEY] = nil

View File

@ -35,7 +35,8 @@ class AdminDetailedUserSerializer < AdminUserSerializer
:second_factor_enabled,
:can_disable_second_factor,
:can_delete_sso_record,
:api_key_count
:api_key_count,
:external_ids
has_one :approved_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
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
scope.can_delete_sso_record?(object)
end

View File

@ -197,6 +197,10 @@ class UserUpdater
update_allowed_pm_users(attributes[:allowed_pm_usernames])
end
if attributes.key?(:user_associated_accounts)
updated_associated_accounts(attributes[:user_associated_accounts])
end
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) &&
(name_changed && old_user_name.casecmp(attributes.fetch(:name)) != 0)
@ -265,6 +269,17 @@ class UserUpdater
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
attr_reader :user, :guardian

View File

@ -141,7 +141,7 @@ describe Discourse do
'pluginauth'
end
def enabled
def enabled?
true
end
end

View File

@ -48,6 +48,15 @@ RSpec.describe Admin::UsersController do
get "/admin/users/#{user.id}.json"
expect(response.status).to eq(200)
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
context 'a non-existing user' do

View File

@ -486,6 +486,9 @@
]
}
]
},
"external_ids": {
"type": "object"
}
},
"required": [
@ -547,6 +550,7 @@
"approved_by",
"suspended_by",
"silenced_by",
"groups"
"groups",
"external_ids"
]
}

View File

@ -21,6 +21,9 @@
},
"user_fields[1]": {
"type": "boolean"
},
"external_ids": {
"type": "object"
}
},
"required": [

View File

@ -0,0 +1,17 @@
{
"additionalProperties": false,
"properties": {
"name": {
"type": "string"
},
"email": {
"type": "string"
},
"password": {
"type": "string"
},
"external_ids": {
"type": "object"
}
}
}

View File

@ -0,0 +1,15 @@
{
"additionalProperties": false,
"properties": {
"success": {
"type": "string"
},
"user": {
"type": "object"
}
},
"required": [
"success",
"user"
]
}

View File

@ -71,6 +71,32 @@ describe 'users' do
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
path '/u/by-external/{external_id}.json' do

View File

@ -648,6 +648,69 @@ describe UsersController do
expect(response.status).to eq(200)
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
context 'when creating a non active user (unconfirmed email)' do
@ -2231,6 +2294,74 @@ describe UsersController do
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
describe '#badge_title' do