From a5b1e2b65044a16073de7c66e5cd5e1c2c718994 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Wed, 11 Mar 2020 00:02:15 +0530 Subject: [PATCH] Feature: Access tokens for API access (#604) Co-authored-by: Pranav Raj Sreepuram --- .../messages/outgoing/normal_builder.rb | 2 +- app/controllers/api/base_controller.rb | 9 +- .../widget/inboxes_controller.rb | 2 +- app/controllers/application_controller.rb | 20 +- .../concerns/access_token_auth_helper.rb | 24 + .../dashboard/api/channel/webChannel.js | 2 +- app/javascript/dashboard/components/Code.vue | 3 +- .../dashboard/i18n/locale/en/settings.json | 4 + .../dashboard/settings/profile/Index.vue | 11 + app/models/access_token.rb | 21 + app/models/account.rb | 1 + app/models/agent_bot.rb | 4 +- app/models/agent_bot_inbox.rb | 9 + app/models/concerns/access_tokenable.rb | 11 + app/models/conversation.rb | 2 + app/models/user.rb | 7 +- .../widget/inboxes/create.json.jbuilder | 0 .../widget/inboxes/update.json.jbuilder | 0 app/views/devise/token.json.jbuilder | 1 + config/routes.rb | 4 +- .../20200309170810_create_access_tokens.rb | 23 + ...132_add_account_id_to_agent_bot_inboxes.rb | 10 + db/schema.rb | 503 +++++++++--------- package.json | 2 +- spec/controllers/api/base_controller_spec.rb | 60 +++ .../accounts_controller_spec.rb | 0 .../conversations/messages_controller_spec.rb | 23 +- .../accounts/conversations_controller_spec.rb | 11 + .../widget/inboxes_controller_spec.rb | 18 +- 29 files changed, 517 insertions(+), 270 deletions(-) rename app/controllers/api/v1/{ => accounts}/widget/inboxes_controller.rb (93%) create mode 100644 app/controllers/concerns/access_token_auth_helper.rb create mode 100644 app/models/access_token.rb create mode 100644 app/models/concerns/access_tokenable.rb rename app/views/api/v1/{ => accounts}/widget/inboxes/create.json.jbuilder (100%) rename app/views/api/v1/{ => accounts}/widget/inboxes/update.json.jbuilder (100%) create mode 100644 db/migrate/20200309170810_create_access_tokens.rb create mode 100644 db/migrate/20200309213132_add_account_id_to_agent_bot_inboxes.rb create mode 100644 spec/controllers/api/base_controller_spec.rb rename spec/controllers/api/v1/{ => accounts}/accounts_controller_spec.rb (100%) rename spec/controllers/api/v1/{ => accounts}/widget/inboxes_controller_spec.rb (73%) diff --git a/app/builders/messages/outgoing/normal_builder.rb b/app/builders/messages/outgoing/normal_builder.rb index 5969a97c5..5d64608a3 100644 --- a/app/builders/messages/outgoing/normal_builder.rb +++ b/app/builders/messages/outgoing/normal_builder.rb @@ -22,7 +22,7 @@ class Messages::Outgoing::NormalBuilder message_type: :outgoing, content: @content, private: @private, - user_id: @user.id, + user_id: @user&.id, source_id: @fb_id } end diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 097a40dab..d512eeabf 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -1,9 +1,16 @@ class Api::BaseController < ApplicationController + include AccessTokenAuthHelper respond_to :json - before_action :authenticate_user! + before_action :authenticate_access_token!, if: :authenticate_by_access_token? + before_action :validate_bot_access_token!, if: :authenticate_by_access_token? + before_action :authenticate_user!, unless: :authenticate_by_access_token? private + def authenticate_by_access_token? + request.headers[:api_access_token].present? + end + def set_conversation @conversation ||= current_account.conversations.find_by(display_id: params[:conversation_id]) end diff --git a/app/controllers/api/v1/widget/inboxes_controller.rb b/app/controllers/api/v1/accounts/widget/inboxes_controller.rb similarity index 93% rename from app/controllers/api/v1/widget/inboxes_controller.rb rename to app/controllers/api/v1/accounts/widget/inboxes_controller.rb index ce739fef0..f6305e4eb 100644 --- a/app/controllers/api/v1/widget/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/widget/inboxes_controller.rb @@ -1,4 +1,4 @@ -class Api::V1::Widget::InboxesController < Api::BaseController +class Api::V1::Accounts::Widget::InboxesController < Api::BaseController before_action :authorize_request before_action :set_web_widget_channel, only: [:update] before_action :set_inbox, only: [:update] diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index f38c45c63..5bac8991e 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -14,7 +14,25 @@ class ApplicationController < ActionController::Base private def current_account - @_ ||= current_user.account + @_ ||= find_current_account + end + + def find_current_account + account = Account.find(params[:account_id]) + if current_user + account_accessible_for_user?(account) + elsif @resource&.is_a?(AgentBot) + account_accessible_for_bot?(account) + end + account + end + + def account_accessible_for_user?(account) + render_unauthorized('You are not authorized to access this account') unless account.account_users.find_by(user_id: current_user.id) + end + + def account_accessible_for_bot?(account) + render_unauthorized('You are not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id) end def handle_with_exception diff --git a/app/controllers/concerns/access_token_auth_helper.rb b/app/controllers/concerns/access_token_auth_helper.rb new file mode 100644 index 000000000..9ebcf3864 --- /dev/null +++ b/app/controllers/concerns/access_token_auth_helper.rb @@ -0,0 +1,24 @@ +module AccessTokenAuthHelper + BOT_ACCESSIBLE_ENDPOINTS = { + 'api/v1/accounts/conversations' => ['toggle_status'], + 'api/v1/accounts/conversations/messages' => ['create'] + }.freeze + + def authenticate_access_token! + access_token = AccessToken.find_by(token: request.headers[:api_access_token]) + render_unauthorized('Invalid Access Token') && return unless access_token + token_owner = access_token.owner + @resource = token_owner + end + + def validate_bot_access_token! + return if current_user.is_a?(User) + return if agent_bot_accessible? + + render_unauthorized('Access to this endpoint is not authorized for bots') + end + + def agent_bot_accessible? + BOT_ACCESSIBLE_ENDPOINTS.fetch(params[:controller], []).include?(params[:action]) + end +end diff --git a/app/javascript/dashboard/api/channel/webChannel.js b/app/javascript/dashboard/api/channel/webChannel.js index 7fc5fb2db..5354787c3 100644 --- a/app/javascript/dashboard/api/channel/webChannel.js +++ b/app/javascript/dashboard/api/channel/webChannel.js @@ -2,7 +2,7 @@ import ApiClient from '../ApiClient'; class WebChannel extends ApiClient { constructor() { - super('widget/inboxes'); + super('widget/inboxes', { accountScoped: true }); } } diff --git a/app/javascript/dashboard/components/Code.vue b/app/javascript/dashboard/components/Code.vue index 17134ebdc..95b09e82c 100644 --- a/app/javascript/dashboard/components/Code.vue +++ b/app/javascript/dashboard/components/Code.vue @@ -26,7 +26,8 @@ export default { }, }, methods: { - onCopy() { + onCopy(e) { + e.preventDefault(); copy(this.script); bus.$emit('newToastMessage', this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL')); }, diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 683490afe..32b982476 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -18,6 +18,10 @@ "TITLE": "Password", "NOTE": "Updating your password would reset your logins in multiple devices." }, + "ACCESS_TOKEN": { + "TITLE": "Access Token", + "NOTE": "This token can be used if you are building an API based integration" + }, "EMAIL_NOTIFICATIONS_SECTION" : { "TITLE": "Email Notifications", "NOTE": "Update your email notification preferences here", diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue index ecdbace19..c1ffd9b05 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue @@ -83,6 +83,17 @@ +
+
+

+ {{ $t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.TITLE') }} +

+

{{ $t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE') }}

+
+
+ +
+
/messages' do - let(:conversation) { create(:conversation, account: account) } + let!(:inbox) { create(:inbox, account: account) } + let!(:conversation) { create(:conversation, inbox: inbox, account: account) } context 'when it is an unauthenticated user' do it 'returns unauthorized' do @@ -30,6 +31,24 @@ RSpec.describe 'Conversation Messages API', type: :request do expect(conversation.messages.first.content).to eq(params[:message]) end end + + context 'when it is an authenticated agent bot' do + let!(:agent_bot) { create(:agent_bot) } + + it 'creates a new outgoing message' do + create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot) + params = { message: 'test-message' } + + post api_v1_account_conversation_messages_url(account_id: account.id, conversation_id: conversation.display_id), + params: params, + headers: { api_access_token: agent_bot.access_token.token }, + as: :json + + expect(response).to have_http_status(:success) + expect(conversation.messages.count).to eq(1) + expect(conversation.messages.first.content).to eq(params[:message]) + end + end end describe 'GET /api/v1/accounts/{account.id}/conversations/:id/messages' do diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb index cc01362fb..58c1f60b6 100644 --- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb @@ -80,6 +80,17 @@ RSpec.describe 'Conversations API', type: :request do expect(response).to have_http_status(:success) expect(conversation.reload.status).to eq('resolved') end + + it 'toggles the conversation status to open from bot' do + conversation.update!(status: 'bot') + + post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(conversation.reload.status).to eq('open') + end end end diff --git a/spec/controllers/api/v1/widget/inboxes_controller_spec.rb b/spec/controllers/api/v1/accounts/widget/inboxes_controller_spec.rb similarity index 73% rename from spec/controllers/api/v1/widget/inboxes_controller_spec.rb rename to spec/controllers/api/v1/accounts/widget/inboxes_controller_spec.rb index d01f56533..97fd582c8 100644 --- a/spec/controllers/api/v1/widget/inboxes_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/widget/inboxes_controller_spec.rb @@ -1,17 +1,17 @@ require 'rails_helper' -RSpec.describe '/api/v1/widget/inboxes', type: :request do +RSpec.describe '/api/v1/accounts/{account.id}/widget/inboxes', type: :request do let(:account) { create(:account) } let(:inbox) { create(:inbox, account: account) } let(:admin) { create(:user, account: account, role: :administrator) } let(:agent) { create(:user, account: account, role: :agent) } - describe 'POST /api/v1/widget/inboxes' do + describe 'POST /api/v1/accounts/{account.id}/widget/inboxes' do let(:params) { { website: { website_name: 'test', website_url: 'test.com', widget_color: '#eaeaea' } } } context 'when unauthenticated user' do it 'returns unauthorized' do - post '/api/v1/widget/inboxes', params: params + post "/api/v1/accounts/#{account.id}/widget/inboxes", params: params expect(response).to have_http_status(:unauthorized) end end @@ -19,7 +19,7 @@ RSpec.describe '/api/v1/widget/inboxes', type: :request do context 'when user is logged in' do context 'with user as administrator' do it 'creates inbox and returns website_token' do - post '/api/v1/widget/inboxes', params: params, headers: admin.create_new_auth_token + post "/api/v1/accounts/#{account.id}/widget/inboxes", params: params, headers: admin.create_new_auth_token expect(response).to have_http_status(:success) json_response = JSON.parse(response.body) @@ -31,7 +31,7 @@ RSpec.describe '/api/v1/widget/inboxes', type: :request do context 'with user as agent' do it 'returns unauthorized' do - post '/api/v1/widget/inboxes', + post "/api/v1/accounts/#{account.id}/widget/inboxes", params: params, headers: agent.create_new_auth_token @@ -41,12 +41,12 @@ RSpec.describe '/api/v1/widget/inboxes', type: :request do end end - describe 'PATCH /api/v1/widget/inboxes/:id' do + describe 'PATCH /api/v1/accounts/{account.id}/widget/inboxes/:id' do let(:update_params) { { website: { widget_color: '#eaeaea' } } } context 'when unauthenticated user' do it 'returns unauthorized' do - patch "/api/v1/widget/inboxes/#{inbox.channel_id}", params: update_params + patch "/api/v1/accounts/#{account.id}/widget/inboxes/#{inbox.channel_id}", params: update_params expect(response).to have_http_status(:unauthorized) end end @@ -54,7 +54,7 @@ RSpec.describe '/api/v1/widget/inboxes', type: :request do context 'when user is logged in' do context 'with user as administrator' do it 'updates website channel' do - patch "/api/v1/widget/inboxes/#{inbox.channel_id}", + patch "/api/v1/accounts/#{account.id}/widget/inboxes/#{inbox.channel_id}", params: update_params, headers: admin.create_new_auth_token @@ -67,7 +67,7 @@ RSpec.describe '/api/v1/widget/inboxes', type: :request do context 'with user as agent' do it 'returns unauthorized' do - patch "/api/v1/widget/inboxes/#{inbox.channel_id}", + patch "/api/v1/accounts/#{account.id}/widget/inboxes/#{inbox.channel_id}", params: update_params, headers: agent.create_new_auth_token