diff --git a/app/controllers/api/v1/accounts/google/authorizations_controller.rb b/app/controllers/api/v1/accounts/google/authorizations_controller.rb new file mode 100644 index 000000000..1140a214b --- /dev/null +++ b/app/controllers/api/v1/accounts/google/authorizations_controller.rb @@ -0,0 +1,32 @@ +class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::BaseController + include GoogleConcern + before_action :check_authorization + + def create + email = params[:authorization][:email] + redirect_url = google_client.auth_code.authorize_url( + { + redirect_uri: "#{base_url}/google/callback", + scope: 'email profile https://mail.google.com/', + response_type: 'code', + prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it + access_type: 'offline', # the default is 'online' + client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil) + } + ) + + if redirect_url + cache_key = "google::#{email.downcase}" + ::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes) + render json: { success: true, url: redirect_url } + else + render json: { success: false }, status: :unprocessable_entity + end + end + + private + + def check_authorization + raise Pundit::NotAuthorizedError unless Current.account_user.administrator? + end +end diff --git a/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb index bee47b213..df563094a 100644 --- a/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb @@ -12,8 +12,8 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts } ) if redirect_url - email = email.downcase - ::Redis::Alfred.setex(email, Current.account.id, 5.minutes) + cache_key = "microsoft::#{email.downcase}" + ::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes) render json: { success: true, url: redirect_url } else render json: { success: false }, status: :unprocessable_entity diff --git a/app/controllers/concerns/google_concern.rb b/app/controllers/concerns/google_concern.rb new file mode 100644 index 000000000..474b14aec --- /dev/null +++ b/app/controllers/concerns/google_concern.rb @@ -0,0 +1,20 @@ +module GoogleConcern + extend ActiveSupport::Concern + + def google_client + app_id = GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil) + app_secret = GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_SECRET', nil) + + ::OAuth2::Client.new(app_id, app_secret, { + site: 'https://oauth2.googleapis.com', + authorize_url: 'https://accounts.google.com/o/oauth2/auth', + token_url: 'https://accounts.google.com/o/oauth2/token' + }) + end + + private + + def base_url + ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + end +end diff --git a/app/controllers/concerns/microsoft_concern.rb b/app/controllers/concerns/microsoft_concern.rb index 0f37bd03f..507b9f8a3 100644 --- a/app/controllers/concerns/microsoft_concern.rb +++ b/app/controllers/concerns/microsoft_concern.rb @@ -15,10 +15,6 @@ module MicrosoftConcern private - def parsed_body - @parsed_body ||= Rack::Utils.parse_nested_query(@response.raw_response.body) - end - def base_url ENV.fetch('FRONTEND_URL', 'http://localhost:3000') end diff --git a/app/controllers/google/callbacks_controller.rb b/app/controllers/google/callbacks_controller.rb new file mode 100644 index 000000000..391e1de0f --- /dev/null +++ b/app/controllers/google/callbacks_controller.rb @@ -0,0 +1,18 @@ +class Google::CallbacksController < OauthCallbackController + include GoogleConcern + + private + + def provider_name + 'google' + end + + def imap_address + 'imap.gmail.com' + end + + def oauth_client + # from GoogleConcern + google_client + end +end diff --git a/app/controllers/microsoft/callbacks_controller.rb b/app/controllers/microsoft/callbacks_controller.rb index aa1c22d91..2f07505fc 100644 --- a/app/controllers/microsoft/callbacks_controller.rb +++ b/app/controllers/microsoft/callbacks_controller.rb @@ -1,90 +1,17 @@ -class Microsoft::CallbacksController < ApplicationController +class Microsoft::CallbacksController < OauthCallbackController include MicrosoftConcern - def show - @response = microsoft_client.auth_code.get_token( - oauth_code, - redirect_uri: "#{base_url}/microsoft/callback" - ) - - inbox, already_exists = find_or_create_inbox - ::Redis::Alfred.delete(users_data['email'].downcase) - - if already_exists - redirect_to app_microsoft_inbox_settings_url(account_id: account.id, inbox_id: inbox.id) - else - redirect_to app_microsoft_inbox_agents_url(account_id: account.id, inbox_id: inbox.id) - end - rescue StandardError => e - ChatwootExceptionTracker.new(e).capture_exception - redirect_to '/' - end - private - def oauth_code - params[:code] + def oauth_client + microsoft_client end - def users_data - decoded_token = JWT.decode parsed_body[:id_token], nil, false - decoded_token[0] + def provider_name + 'microsoft' end - def parsed_body - @parsed_body ||= @response.response.parsed - end - - def account_id - ::Redis::Alfred.get(users_data['email'].downcase) - end - - def account - @account ||= Account.find(account_id) - end - - def find_or_create_inbox - channel_email = Channel::Email.find_by(email: users_data['email'], account: account) - # we need this value to know where to redirect on sucessful processing of the callback - channel_exists = channel_email.present? - - channel_email ||= create_microsoft_channel_with_inbox - update_microsoft_channel(channel_email) - - # reauthorize channel, this code path only triggers when microsoft auth is successful - # reauthorized will also update cache keys for the associated inbox - channel_email.reauthorized! - - [channel_email.inbox, channel_exists] - end - - # Fallback name, for when name field is missing from users_data - def fallback_name - users_data['email'].split('@').first.parameterize.titleize - end - - def create_microsoft_channel_with_inbox - ActiveRecord::Base.transaction do - channel_email = Channel::Email.create!(email: users_data['email'], account: account) - account.inboxes.create!( - account: account, - channel: channel_email, - name: users_data['name'] || fallback_name - ) - channel_email - end - end - - def update_microsoft_channel(channel_email) - channel_email.update!({ - imap_login: users_data['email'], imap_address: 'outlook.office365.com', - imap_port: '993', imap_enabled: true, - provider: 'microsoft', - provider_config: { - access_token: parsed_body['access_token'], - refresh_token: parsed_body['refresh_token'], - expires_on: (Time.current.utc + 1.hour).to_s - } - }) + def imap_address + 'outlook.office365.com' end end diff --git a/app/controllers/oauth_callback_controller.rb b/app/controllers/oauth_callback_controller.rb new file mode 100644 index 000000000..309160f1f --- /dev/null +++ b/app/controllers/oauth_callback_controller.rb @@ -0,0 +1,108 @@ +class OauthCallbackController < ApplicationController + def show + @response = oauth_client.auth_code.get_token( + oauth_code, + redirect_uri: "#{base_url}/#{provider_name}/callback" + ) + + handle_response + ::Redis::Alfred.delete(cache_key) + rescue StandardError => e + ChatwootExceptionTracker.new(e).capture_exception + redirect_to '/' + end + + private + + def handle_response + inbox, already_exists = find_or_create_inbox + + if already_exists + redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id) + else + redirect_to app_email_inbox_agents_url(account_id: account.id, inbox_id: inbox.id) + end + end + + def find_or_create_inbox + channel_email = Channel::Email.find_by(email: users_data['email'], account: account) + # we need this value to know where to redirect on sucessful processing of the callback + channel_exists = channel_email.present? + + channel_email ||= create_channel_with_inbox + update_channel(channel_email) + + # reauthorize channel, this code path only triggers when microsoft auth is successful + # reauthorized will also update cache keys for the associated inbox + channel_email.reauthorized! + + [channel_email.inbox, channel_exists] + end + + def update_channel(channel_email) + channel_email.update!({ + imap_login: users_data['email'], imap_address: imap_address, + imap_port: '993', imap_enabled: true, + provider: provider_name, + provider_config: { + access_token: parsed_body['access_token'], + refresh_token: parsed_body['refresh_token'], + expires_on: (Time.current.utc + 1.hour).to_s + } + }) + end + + def provider_name + raise NotImplementedError + end + + def oauth_client + raise NotImplementedError + end + + def cache_key + "#{provider_name}::#{users_data['email'].downcase}" + end + + def create_channel_with_inbox + ActiveRecord::Base.transaction do + channel_email = Channel::Email.create!(email: users_data['email'], account: account) + account.inboxes.create!( + account: account, + channel: channel_email, + name: users_data['name'] || fallback_name + ) + channel_email + end + end + + def users_data + decoded_token = JWT.decode parsed_body[:id_token], nil, false + decoded_token[0] + end + + def account_id + ::Redis::Alfred.get(cache_key) + end + + def account + @account ||= Account.find(account_id) + end + + # Fallback name, for when name field is missing from users_data + def fallback_name + users_data['email'].split('@').first.parameterize.titleize + end + + def oauth_code + params[:code] + end + + def base_url + ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + end + + def parsed_body + @parsed_body ||= @response.response.parsed + end +end diff --git a/app/javascript/dashboard/api/channel/googleClient.js b/app/javascript/dashboard/api/channel/googleClient.js new file mode 100644 index 000000000..8b59919e4 --- /dev/null +++ b/app/javascript/dashboard/api/channel/googleClient.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class MicrosoftClient extends ApiClient { + constructor() { + super('google', { accountScoped: true }); + } + + generateAuthorization(payload) { + return axios.post(`${this.url}/authorization`, payload); + } +} + +export default new MicrosoftClient(); diff --git a/app/javascript/dashboard/components/ChannelSelector.vue b/app/javascript/dashboard/components/ChannelSelector.vue index dd9513fb6..6cff70bd3 100644 --- a/app/javascript/dashboard/components/ChannelSelector.vue +++ b/app/javascript/dashboard/components/ChannelSelector.vue @@ -1,6 +1,6 @@