From 01a9e7f148eee5e41c8f085cf03b2c7a9335e72e Mon Sep 17 00:00:00 2001 From: Angus McLeod Date: Thu, 30 May 2019 15:04:34 +1000 Subject: [PATCH] complete OAuth authorization && Start API Admin UI --- .../controllers/admin-wizards-api.js.es6 | 78 +++++++++ .../custom-wizard-admin-route-map.js.es6 | 3 + .../discourse/models/custom-wizard-api.js.es6 | 50 ++++++ .../discourse/routes/admin-wizards-api.js.es6 | 15 ++ .../routes/admin-wizards-apis.js.es6 | 11 ++ .../templates/admin-wizard-submissions.hbs | 16 -- .../discourse/templates/admin-wizards-api.hbs | 124 ++++++++++++++ .../templates/admin-wizards-apis.hbs | 20 +++ .../discourse/templates/admin-wizards.hbs | 1 + config/locales/client.en.yml | 19 +++ controllers/admin_api.rb | 55 +++++++ controllers/authorization.rb | 15 -- jobs/refresh_api_access_token.rb | 2 +- lib/authorization.rb | 153 ++++++++---------- plugin.rb | 12 +- serializers/api_list_item_serializer.rb | 3 + serializers/api_serializer.rb | 17 ++ 17 files changed, 472 insertions(+), 122 deletions(-) create mode 100644 assets/javascripts/discourse/controllers/admin-wizards-api.js.es6 create mode 100644 assets/javascripts/discourse/models/custom-wizard-api.js.es6 create mode 100644 assets/javascripts/discourse/routes/admin-wizards-api.js.es6 create mode 100644 assets/javascripts/discourse/routes/admin-wizards-apis.js.es6 delete mode 100644 assets/javascripts/discourse/templates/admin-wizard-submissions.hbs create mode 100644 assets/javascripts/discourse/templates/admin-wizards-api.hbs create mode 100644 assets/javascripts/discourse/templates/admin-wizards-apis.hbs create mode 100644 controllers/admin_api.rb delete mode 100644 controllers/authorization.rb create mode 100644 serializers/api_list_item_serializer.rb create mode 100644 serializers/api_serializer.rb diff --git a/assets/javascripts/discourse/controllers/admin-wizards-api.js.es6 b/assets/javascripts/discourse/controllers/admin-wizards-api.js.es6 new file mode 100644 index 00000000..270dac4f --- /dev/null +++ b/assets/javascripts/discourse/controllers/admin-wizards-api.js.es6 @@ -0,0 +1,78 @@ +import { ajax } from 'discourse/lib/ajax'; +import { popupAjaxError } from 'discourse/lib/ajax-error'; +import CustomWizardApi from '../models/custom-wizard-api'; + +export default Ember.Controller.extend({ + loadingSubscriptions: false, + notAuthorized: Ember.computed.not('api.authorized'), + authorizationTypes: ['oauth', 'basic'], + isOauth: Ember.computed.equal('api.authType', 'oauth'), + + actions: { + addParam() { + this.get('api.authParams').pushObject({}); + }, + + removeParam(param) { + this.get('api.authParams').removeObject(param); + }, + + authorize() { + const api = this.get('api'); + const { authType, authUrl, authParams } = api; + let query = '?'; + + if (authType === 'oauth') { + query += `client_id=${api.get('clientId')}&redirect_uri=${encodeURIComponent(api.get('redirectUri'))}&response_type=code`; + + if (authParams) { + authParams.forEach(p => { + query += `&${p.key}=${encodeURIComponent(p.value)}`; + }); + } + } else { + // basic auth + } + + window.location.href = authUrl + query; + }, + + save() { + const api = this.get('api'); + const service = api.get('service'); + + let data = {}; + + data['auth_type'] = api.get('authType'); + data['auth_url'] = api.get('authUrl'); + + if (data.auth_type === 'oauth') { + data['client_id'] = api.get('clientId'); + data['client_secret'] = api.get('clientSecret'); + + let params = api.get('authParams'); + + if (params) { + data['auth_params'] = JSON.stringify(params); + } + + data['token_url'] = api.get('tokenUrl'); + } else { + data['username'] = api.get('username'); + data['password'] = api.get('password'); + } + + this.set('savingApi', true); + + ajax(`/admin/wizards/apis/${service}/save`, { + type: 'PUT', + data + }).catch(popupAjaxError) + .then(result => { + if (result.success) { + this.set('api', CustomWizardApi.create(result.api)); + } + }).finally(() => this.set('savingApi', false)); + } + } +}); diff --git a/assets/javascripts/discourse/custom-wizard-admin-route-map.js.es6 b/assets/javascripts/discourse/custom-wizard-admin-route-map.js.es6 index 46d0e411..dfe53a82 100644 --- a/assets/javascripts/discourse/custom-wizard-admin-route-map.js.es6 +++ b/assets/javascripts/discourse/custom-wizard-admin-route-map.js.es6 @@ -8,6 +8,9 @@ export default { this.route('adminWizardsSubmissions', { path: '/submissions', resetNamespace: true }, function() { this.route('adminWizardSubmissions', { path: '/:wizard_id', resetNamespace: true }); }); + this.route('adminWizardsApis', { path: '/apis', resetNamespace: true }, function() { + this.route('adminWizardsApi', { path: '/:service', resetNamespace: true }); + }); }); } }; diff --git a/assets/javascripts/discourse/models/custom-wizard-api.js.es6 b/assets/javascripts/discourse/models/custom-wizard-api.js.es6 new file mode 100644 index 00000000..0bdec66a --- /dev/null +++ b/assets/javascripts/discourse/models/custom-wizard-api.js.es6 @@ -0,0 +1,50 @@ +import { ajax } from 'discourse/lib/ajax'; +import { default as computed } from 'ember-addons/ember-computed-decorators'; + +const CustomWizardApi = Discourse.Model.extend({ + @computed('service') + redirectUri(service) { + const baseUrl = location.protocol+'//'+location.hostname+(location.port ? ':'+location.port: ''); + return baseUrl + `/admin/wizards/apis/${service}/redirect`; + } +}); + +CustomWizardApi.reopenClass({ + create(params) { + const api = this._super.apply(this); + api.setProperties({ + service: params.service, + authType: params.auth_type, + authUrl: params.auth_url, + tokenUrl: params.token_url, + clientId: params.client_id, + clientSecret: params.client_secret, + authParams: Ember.A(params.auth_params), + authorized: params.authorized, + accessToken: params.access_token, + refreshToken: params.refresh_token, + code: params.code, + tokenExpiresAt: params.token_expires_at, + tokenRefreshAt: params.token_refresh_at + }); + return api; + }, + + find(service) { + return ajax(`/admin/wizards/apis/${service}`, { + type: 'GET' + }).then(result => { + return result; + }); + }, + + list() { + return ajax("/admin/wizards/apis", { + type: 'GET' + }).then(result => { + return result; + }); + } +}); + +export default CustomWizardApi; diff --git a/assets/javascripts/discourse/routes/admin-wizards-api.js.es6 b/assets/javascripts/discourse/routes/admin-wizards-api.js.es6 new file mode 100644 index 00000000..72f4ad2a --- /dev/null +++ b/assets/javascripts/discourse/routes/admin-wizards-api.js.es6 @@ -0,0 +1,15 @@ +import CustomWizardApi from '../models/custom-wizard-api'; + +export default Discourse.Route.extend({ + model(params) { + if (params.service === 'new') { + return {}; + } else { + return CustomWizardApi.find(params.service); + } + }, + + setupController(controller, model){ + controller.set("api", CustomWizardApi.create(model)); + } +}); diff --git a/assets/javascripts/discourse/routes/admin-wizards-apis.js.es6 b/assets/javascripts/discourse/routes/admin-wizards-apis.js.es6 new file mode 100644 index 00000000..b7e89e10 --- /dev/null +++ b/assets/javascripts/discourse/routes/admin-wizards-apis.js.es6 @@ -0,0 +1,11 @@ +import CustomWizardApi from '../models/custom-wizard-api'; + +export default Discourse.Route.extend({ + model() { + return CustomWizardApi.list(); + }, + + setupController(controller, model){ + controller.set("model", model); + } +}); diff --git a/assets/javascripts/discourse/templates/admin-wizard-submissions.hbs b/assets/javascripts/discourse/templates/admin-wizard-submissions.hbs deleted file mode 100644 index ec8a55ff..00000000 --- a/assets/javascripts/discourse/templates/admin-wizard-submissions.hbs +++ /dev/null @@ -1,16 +0,0 @@ -
- - - {{#each fields as |f|}} - - {{/each}} - - {{#each submissions as |s|}} - - {{#each-in s as |k v|}} - - {{/each-in}} - - {{/each}} -
{{f}}
{{v}}
-
diff --git a/assets/javascripts/discourse/templates/admin-wizards-api.hbs b/assets/javascripts/discourse/templates/admin-wizards-api.hbs new file mode 100644 index 00000000..9ab94f03 --- /dev/null +++ b/assets/javascripts/discourse/templates/admin-wizards-api.hbs @@ -0,0 +1,124 @@ +
+ +
+
+ +
+ {{input value=api.service}} +
+
+ {{i18n 'admin.wizard.api.redirect_uri'}} + {{api.redirectUri}} +
+
+ +
+ +
+ {{combo-box value=api.authType content=authorizationTypes none='admin.wizard.api.auth_type_none'}} +
+
+ +
+ +
+ {{input value=api.authUrl}} +
+
+ +
+ +
+ {{input value=api.tokenUrl}} +
+
+ + {{#if isOauth}} +
+ +
+ {{input value=api.clientId}} +
+
+ +
+ +
+ {{input value=api.clientSecret}} +
+
+ +
+ +
+ {{#each api.authParams as |param|}} +
+ {{input value=param.key placeholder=(i18n 'admin.wizard.api.param_key')}} + {{input value=param.value placeholder=(i18n 'admin.wizard.api.param_value')}} + {{d-button action='removeParam' actionParam=param icon='times'}} +
+ {{/each}} + {{d-button label='admin.wizard.api.param_new' icon='plus' action='addParam'}} +
+
+ {{/if}} +
+ +
+ {{d-button label="admin.wizard.api.save" action="save"}} + {{#if savingApi}} + {{loading-spinner size="small"}} + {{/if}} +
+ +
+ {{d-button label="admin.wizard.api.authorize" action="authorize"}} +
+ +
+ {{#if api.authorized}} + + {{i18n "admin.wizard.api.authorized"}} + {{else}} + + {{i18n "admin.wizard.api.not_authorized"}} + {{/if}} +
+ +
+
+ +
+ {{api.accessToken}} +
+
+ +
+ +
+ {{api.tokenExpiresAt}} +
+
+ +
+ +
+ {{api.tokenRefreshAt}} +
+
+ +
+ +
+ {{api.refreshToken}} +
+
+ +
+ +
+ {{api.code}} +
+
+
+
diff --git a/assets/javascripts/discourse/templates/admin-wizards-apis.hbs b/assets/javascripts/discourse/templates/admin-wizards-apis.hbs new file mode 100644 index 00000000..78a5c954 --- /dev/null +++ b/assets/javascripts/discourse/templates/admin-wizards-apis.hbs @@ -0,0 +1,20 @@ +
+
+
    + {{#each model as |api|}} +
  • + {{#link-to "adminWizardsApi" api.service}}{{api.service}}{{/link-to}} +
  • + {{/each}} +
+
+ {{#link-to 'adminWizardsApi' 'new' class="btn"}} + {{d-icon "plus"}} {{i18n 'admin.wizard.api.new'}} + {{/link-to}} +
+
+ +
+ {{outlet}} +
+
diff --git a/assets/javascripts/discourse/templates/admin-wizards.hbs b/assets/javascripts/discourse/templates/admin-wizards.hbs index f855b33d..3c26e24a 100644 --- a/assets/javascripts/discourse/templates/admin-wizards.hbs +++ b/assets/javascripts/discourse/templates/admin-wizards.hbs @@ -1,6 +1,7 @@ {{#admin-nav}} {{nav-item route='adminWizardsCustom' label='admin.wizard.custom_label'}} {{nav-item route='adminWizardsSubmissions' label='admin.wizard.submissions_label'}} + {{nav-item route='adminWizardsApis' label='admin.wizard.api.nav_label'}} {{/admin-nav}}
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 653be688..33bfbc47 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -126,6 +126,25 @@ en: label: "Custom Category" wizard_field: "Wizard Field" user_field: "User Field" + api: + nav_label: 'APIs' + new: 'New Api' + service: 'Api Service' + redirect_uri: "Redirect Uri" + auth_type: 'Authorization Type' + auth_type_none: 'Select a type' + token_url: "Token Url" + auth_url: 'Authorization Url' + client_id: 'Client Id' + client_secret: 'Client Secret' + save: "Save" + authorize: 'Authorize' + authorized: 'Authorized' + not_authorized: "Not Authorized" + params: 'Params' + param_new: 'New Param' + param_key: 'Param Key' + param_value: 'Param Value' wizard_js: location: diff --git a/controllers/admin_api.rb b/controllers/admin_api.rb new file mode 100644 index 00000000..04f5a95f --- /dev/null +++ b/controllers/admin_api.rb @@ -0,0 +1,55 @@ +class CustomWizard::AdminApiController < ::ApplicationController + before_action :ensure_logged_in + before_action :ensure_admin + skip_before_action :check_xhr, only: [:redirect] + + def index + end + + def list + serializer = ActiveModel::ArraySerializer.new( + CustomWizard::Authorization.list, + each_serializer: CustomWizard::ApiListItemSerializer + ) + + render json: MultiJson.dump(serializer) + end + + def find + params.require(:service) + render_serialized(CustomWizard::Authorization.get(params[:service]), CustomWizard::ApiSerializer, root: false) + end + + def save + params.require(:service) + + data = params.permit( + :service, + :auth_type, + :auth_url, + :token_url, + :client_id, + :client_secret, + :username, + :password, + :auth_params + ).to_h + + data[:auth_params] = JSON.parse(data[:auth_params]) if data[:auth_params] + + result = CustomWizard::Authorization.set(data[:service], data.except!(:service)) + + render json: success_json.merge(api: CustomWizard::ApiSerializer.new(result, root: false)) + end + + def redirect + params.require(:service) + params.require(:code) + + CustomWizard::Authorization.set(params[:service], code: params[:code]) + + CustomWizard::Authorization.get_token(params[:service]) + + return redirect_to path('/admin/wizards/apis/' + params[:service]) + end +end diff --git a/controllers/authorization.rb b/controllers/authorization.rb deleted file mode 100644 index c016476d..00000000 --- a/controllers/authorization.rb +++ /dev/null @@ -1,15 +0,0 @@ -class CustomWizard::AuthorizationController < ::ApplicationController - skip_before_action :check_xhr, - :preload_json, - :redirect_to_login_if_required, - :verify_authenticity_token - - def callback - - params.require(:service) - params.require(:code) - - CustomWizard::Authorization.set_code(service, params[:code]) - CustomWizard::Authorization.get_access_token(service) - end -end diff --git a/jobs/refresh_api_access_token.rb b/jobs/refresh_api_access_token.rb index 6e517743..1b3221e8 100644 --- a/jobs/refresh_api_access_token.rb +++ b/jobs/refresh_api_access_token.rb @@ -1,7 +1,7 @@ module Jobs class RefreshApiAccessToken < Jobs::Base def execute(args) - CustomWizard::Authorization.refresh_access_token(args[:service]) + CustomWizard::Authorization.refresh_token(args[:service]) end end end diff --git a/lib/authorization.rb b/lib/authorization.rb index eac5a77a..a9b27142 100644 --- a/lib/authorization.rb +++ b/lib/authorization.rb @@ -1,81 +1,59 @@ require 'excon' class CustomWizard::Authorization + include ActiveModel::SerializerSupport - BASIC_AUTH = 'basic_authentication' - OAUTH2_AUTH = 'OAuth2_authentication' + NGROK_URL = '' - def self.authentication_protocol(service) - PluginStore.get(service, 'authentication_protocol') || {} + attr_accessor :authorized, + :service, + :auth_type, + :auth_url, + :token_url, + :client_id, + :client_secret, + :auth_params, + :access_token, + :refresh_token, + :token_expires_at, + :token_refresh_at, + :code, + :username, + :password + + def initialize(service, params) + @service = service + data = params.is_a?(String) ? ::JSON.parse(params) : params + + data.each do |k, v| + self.send "#{k}=", v if self.respond_to?(k) + end end - def self.set_authentication_protocol(service, protocol) - raise Discourse::InvalidParameters.new(:protocol) unless [BASIC_AUTH, OAUTH2_AUTH].include? protocol - PluginStore.set(service, 'authentication_protocol', protocol) + def authorized + @authorized ||= @access_token && @token_expires_at.to_datetime > Time.now end - def self.access_token(service) - PluginStore.get(service, 'access_token') || {} + def self.set(service, data) + model = self.get(service) || {} + + data.each do |k, v| + model.send "#{k}=", v if model.respond_to?(k) + end + + PluginStore.set("custom_wizard_#{service}", 'authorization', model.as_json) + + self.get(service) end - def self.set_access_token(service, data) - PluginStore.set(service, 'access_token', data) + def self.get(service) + data = PluginStore.get("custom_wizard_#{service}", 'authorization') + self.new(service, data) end - def self.refresh_token (service) - PluginStore.get(service, 'refresh_token') - end - - def self.set_refresh_token(service, token) - PluginStore.set(service, 'refresh_token', token) - end - - def self.code(service) - PluginStore.get(service,'code') - end - - def self.set_code(service, code) - PluginStore.set(service, 'code', code) - end - - def self.username(service) - PluginStore.get(service,'username') - end - - def self.set_username(service, username) - PluginStore.set(service, 'username', username) - end - - def self.password(service) - PluginStore.get(service,'password') - end - - def self.set_password(service, password) - PluginStore.set(service, 'password', password) - end - - def self.client_id(service) - PluginStore.get(service,'client_id') - end - - def self.set_client_id(service, client_id) - PluginStore.set(service, 'client_id', client_id) - end - - def self.client_secret(service) - PluginStore.get(service,'client_secret') - end - - def self.set_client_secret(service, client_secret) - PluginStore.set(service, 'client_secret', client_secret) - end - - def self.url(service) - PluginStore.get(service,'url') - end - - def self.set_url(service, url) - PluginStore.set(service, 'url', url) + def self.list + PluginStoreRow.where("plugin_name LIKE 'custom_wizard_%' AND key = 'authorization'") + .map { |record| self.new(record['plugin_name'].split('_').last, record['value']) } end def self.get_header_authorization_string(service) @@ -98,17 +76,19 @@ class CustomWizard::Authorization end end - def self.get_access_token(service) + def self.get_token(service) + authorization = CustomWizard::Authorization.get(service) + body = { - client_id: CustomWizard::Authorization.client_id(service), - client_secret: CustomWizard::Authorization.client_secret(service), - code: CustomWizard::Authorization.code(service), + client_id: authorization.client_id, + client_secret: authorization.client_secret, + code: authorization.code, grant_type: 'authorization_code', - redirect_uri: (Rails.env.development? ? CustomWizard::NGROK_URL : Discourse.base_url) + '/custom_wizard/authorization/callback' + redirect_uri: Discourse.base_url + "/admin/wizards/apis/#{service}/redirect" } result = Excon.post( - CustomWizard::Authorization.url(service), + authorization.token_url, :headers => { "Content-Type" => "application/x-www-form-urlencoded" }, @@ -118,16 +98,18 @@ class CustomWizard::Authorization self.handle_token_result(service, result) end - def self.refresh_access_token(service) + def self.refresh_token(service) + authorization = CustomWizard::Authorization.get(service) + body = { grant_type: 'refresh_token', - refresh_token: CustomWizard::Authorization.refresh_token(service) + refresh_token: authorization.refresh_token } - authorization_string = CustomWizard::Authorization.client_id(service) + ':' + CustomWizard::Authorization.client_secret(service) + authorization_string = authorization.client_id + ':' + authorization.client_secret result = Excon.post( - CustomWizard::Authorization.url(service), + authorization.token_url, :headers => { "Content-Type" => "application/x-www-form-urlencoded", "Authorization" => "Basic #{Base64.strict_encode64(authorization_string)}" @@ -140,9 +122,11 @@ class CustomWizard::Authorization def self.handle_token_result(service, result) data = JSON.parse(result.body) + return false if (data['error']) - token = data['access_token'] + access_token = data['access_token'] + refresh_token = data['refresh_token'] expires_at = Time.now + data['expires_in'].seconds refresh_at = expires_at.to_time - 2.hours @@ -152,18 +136,11 @@ class CustomWizard::Authorization Jobs.enqueue_at(refresh_at, :refresh_api_access_token, opts) - CustomWizard::Authorization.set_access_token( - service: service, - token: token, - expires_at: expires_at, - refresh_at: refresh_at + CustomWizard::Authorization.set(service, + access_token: access_token, + refresh_token: refresh_token, + token_expires_at: expires_at, + token_refresh_at: refresh_at ) - - CustomWizard::Authorization.set_refresh_token(service, data['refresh_token']) - end - - def self.authorized(service) - CustomWizard::Authorization.access_token[service, :token] && - CustomWizard::Authorization.access_token[service, :expires_at].to_datetime > Time.now end end diff --git a/plugin.rb b/plugin.rb index 067cb0b4..45725879 100644 --- a/plugin.rb +++ b/plugin.rb @@ -49,12 +49,12 @@ after_initialize do get ':wizard_id/steps' => 'wizard#index' get ':wizard_id/steps/:step_id' => 'wizard#index' put ':wizard_id/steps/:step_id' => 'steps#update' - get 'authorization/callback' => "authorization#callback" end require_dependency 'admin_constraint' Discourse::Application.routes.append do mount ::CustomWizard::Engine, at: 'w' + post 'wizard/authorization/callback' => "custom_wizard/authorization#callback" scope module: 'custom_wizard', constraints: AdminConstraint.new do get 'admin/wizards' => 'admin#index' @@ -67,11 +67,17 @@ after_initialize do delete 'admin/wizards/custom/remove' => 'admin#remove' get 'admin/wizards/submissions' => 'admin#index' get 'admin/wizards/submissions/:wizard_id' => 'admin#submissions' + get 'admin/wizards/apis' => 'admin_api#list' + get 'admin/wizards/apis/new' => 'admin_api#index' + get 'admin/wizards/apis/:service' => 'admin_api#find' + put 'admin/wizards/apis/:service/save' => 'admin_api#save' + get 'admin/wizards/apis/:service/redirect' => 'admin_api#redirect' end end load File.expand_path('../jobs/clear_after_time_wizard.rb', __FILE__) load File.expand_path('../jobs/set_after_time_wizard.rb', __FILE__) + load File.expand_path('../jobs/refresh_api_access_token.rb', __FILE__) load File.expand_path('../lib/builder.rb', __FILE__) load File.expand_path('../lib/field.rb', __FILE__) load File.expand_path('../lib/step_updater.rb', __FILE__) @@ -82,7 +88,9 @@ after_initialize do load File.expand_path('../controllers/wizard.rb', __FILE__) load File.expand_path('../controllers/steps.rb', __FILE__) load File.expand_path('../controllers/admin.rb', __FILE__) - load File.expand_path('../controllers/authorization.rb', __FILE__) + load File.expand_path('../controllers/admin_api.rb', __FILE__) + load File.expand_path('../serializers/api_serializer.rb', __FILE__) + load File.expand_path('../serializers/api_list_item_serializer.rb', __FILE__) ::UsersController.class_eval do def wizard_path diff --git a/serializers/api_list_item_serializer.rb b/serializers/api_list_item_serializer.rb new file mode 100644 index 00000000..854349c8 --- /dev/null +++ b/serializers/api_list_item_serializer.rb @@ -0,0 +1,3 @@ +class CustomWizard::ApiListItemSerializer < ApplicationSerializer + attributes :service +end diff --git a/serializers/api_serializer.rb b/serializers/api_serializer.rb new file mode 100644 index 00000000..3bf86348 --- /dev/null +++ b/serializers/api_serializer.rb @@ -0,0 +1,17 @@ +class CustomWizard::ApiSerializer < ApplicationSerializer + attributes :service, + :auth_type, + :auth_url, + :token_url, + :client_id, + :client_secret, + :authorized, + :auth_params, + :access_token, + :refresh_token, + :token_expires_at, + :token_refresh_at, + :code, + :username, + :password +end