From db3f553140ef506f8d31815ec08651187a3a6e4f Mon Sep 17 00:00:00 2001 From: Rolf Schmidt Date: Fri, 2 Oct 2020 14:46:19 +0200 Subject: [PATCH] Fixes #3215 - Outlook/Office365/Exchange Online MFA for IMAP & SMTP. --- .rubocop/todo.rspec.yml | 5 + .rubocop/todo.yml | 7 + .../app/controllers/_channel/email.coffee | 5 + .../app/controllers/_channel/office365.coffee | 416 ++++++++++++++++++ .../app/models/email_address.coffee | 2 +- .../channel/email_account_overview.jst.eco | 13 + .../app/views/office365/app_config.jst.eco | 29 ++ .../app/views/office365/index.jst.eco | 12 + .../app/views/office365/list.jst.eco | 116 +++++ .../channels_office365_controller.rb | 100 +++++ ...ap_authentication_migration_cleanup_job.rb | 2 +- .../channels_office365_controller_policy.rb | 3 + config/routes/office365.rb | 12 + lib/external_credential/office365.rb | 267 +++++++++++ .../lib/external_credential/office365_spec.rb | 343 +++++++++++++++ spec/requests/integration/office365_spec.rb | 77 ++++ spec/support/vcr.rb | 2 +- 17 files changed, 1408 insertions(+), 3 deletions(-) create mode 100644 app/assets/javascripts/app/controllers/_channel/office365.coffee create mode 100644 app/assets/javascripts/app/views/office365/app_config.jst.eco create mode 100644 app/assets/javascripts/app/views/office365/index.jst.eco create mode 100644 app/assets/javascripts/app/views/office365/list.jst.eco create mode 100644 app/controllers/channels_office365_controller.rb create mode 100644 app/policies/controllers/channels_office365_controller_policy.rb create mode 100644 config/routes/office365.rb create mode 100644 lib/external_credential/office365.rb create mode 100644 spec/lib/external_credential/office365_spec.rb create mode 100644 spec/requests/integration/office365_spec.rb diff --git a/.rubocop/todo.rspec.yml b/.rubocop/todo.rspec.yml index 237eb4ca5..d4081517d 100644 --- a/.rubocop/todo.rspec.yml +++ b/.rubocop/todo.rspec.yml @@ -42,6 +42,7 @@ RSpec/ContextWording: - 'spec/lib/auth_spec.rb' - 'spec/lib/core_ext/string_spec.rb' - 'spec/lib/external_credential/google_spec.rb' + - 'spec/lib/external_credential/office365_spec.rb' - 'spec/lib/html_sanitizer_spec.rb' - 'spec/lib/import/exchange/folder_spec.rb' - 'spec/lib/import/helper_spec.rb' @@ -144,6 +145,7 @@ RSpec/ContextWording: - 'spec/requests/external_credentials_spec.rb' - 'spec/requests/integration/check_mk_spec.rb' - 'spec/requests/integration/gmail_spec.rb' + - 'spec/requests/integration/office365_spec.rb' - 'spec/requests/integration/object_manager_attributes_spec.rb' - 'spec/requests/integration/smime_spec.rb' - 'spec/requests/knowledge_base/attachments_spec.rb' @@ -176,6 +178,7 @@ RSpec/ExampleLength: - 'spec/lib/auto_wizard_spec.rb' - 'spec/lib/core_ext/string_spec.rb' - 'spec/lib/external_credential/google_spec.rb' + - 'spec/lib/external_credential/office365_spec.rb' - 'spec/lib/external_sync_spec.rb' - 'spec/lib/import/import_job_backend_examples.rb' - 'spec/lib/import/ldap_spec.rb' @@ -356,6 +359,7 @@ RSpec/LetSetup: Exclude: - 'spec/jobs/ticket_online_notification_seen_job_spec.rb' - 'spec/lib/external_credential/google_spec.rb' + - 'spec/lib/external_credential/office365_spec.rb' - 'spec/lib/secure_mailing/smime_spec.rb' - 'spec/lib/sessions/backend/ticket_overview_list_spec.rb' - 'spec/models/channel/driver/twitter_spec.rb' @@ -615,6 +619,7 @@ RSpec/NestedGroups: - 'spec/lib/core_ext/string_spec.rb' - 'spec/lib/email_address_validation_spec.rb' - 'spec/lib/external_credential/google_spec.rb' + - 'spec/lib/external_credential/office365_spec.rb' - 'spec/lib/html_sanitizer_spec.rb' - 'spec/lib/import/exchange/folder_spec.rb' - 'spec/lib/notification_factory/mailer_spec.rb' diff --git a/.rubocop/todo.yml b/.rubocop/todo.yml index 59659c0fb..8815b42a8 100644 --- a/.rubocop/todo.yml +++ b/.rubocop/todo.yml @@ -26,6 +26,7 @@ Metrics/AbcSize: - 'app/controllers/attachments_controller.rb' - 'app/controllers/channels_email_controller.rb' - 'app/controllers/channels_google_controller.rb' + - 'app/controllers/channels_office365_controller.rb' - 'app/controllers/channels_sms_controller.rb' - 'app/controllers/channels_telegram_controller.rb' - 'app/controllers/channels_twitter_controller.rb' @@ -273,6 +274,7 @@ Metrics/AbcSize: - 'lib/excel_sheet/ticket.rb' - 'lib/external_credential/facebook.rb' - 'lib/external_credential/google.rb' + - 'lib/external_credential/office365.rb' - 'lib/external_credential/twitter.rb' - 'lib/facebook.rb' - 'lib/fill_db.rb' @@ -456,6 +458,7 @@ Metrics/CyclomaticComplexity: - 'app/controllers/application_controller/renders_models.rb' - 'app/controllers/channels_email_controller.rb' - 'app/controllers/channels_google_controller.rb' + - 'app/controllers/channels_office365_controller.rb' - 'app/controllers/channels_twitter_controller.rb' - 'app/controllers/concerns/checks_user_attributes_by_current_user_permission.rb' - 'app/controllers/concerns/creates_ticket_articles.rb' @@ -613,6 +616,7 @@ Metrics/CyclomaticComplexity: - 'lib/excel_sheet.rb' - 'lib/external_credential/facebook.rb' - 'lib/external_credential/google.rb' + - 'lib/external_credential/office365.rb' - 'lib/external_credential/twitter.rb' - 'lib/facebook.rb' - 'lib/fill_db.rb' @@ -693,6 +697,7 @@ Metrics/PerceivedComplexity: - 'app/controllers/application_controller/logs_http_access.rb' - 'app/controllers/channels_email_controller.rb' - 'app/controllers/channels_google_controller.rb' + - 'app/controllers/channels_office365_controller.rb' - 'app/controllers/concerns/creates_ticket_articles.rb' - 'app/controllers/first_steps_controller.rb' - 'app/controllers/form_controller.rb' @@ -837,6 +842,7 @@ Metrics/PerceivedComplexity: - 'lib/excel_sheet.rb' - 'lib/external_credential/facebook.rb' - 'lib/external_credential/google.rb' + - 'lib/external_credential/office365.rb' - 'lib/external_credential/twitter.rb' - 'lib/facebook.rb' - 'lib/fill_db.rb' @@ -934,6 +940,7 @@ Style/OptionalBooleanParameter: - 'lib/core_ext/string.rb' - 'lib/external_credential/facebook.rb' - 'lib/external_credential/google.rb' + - 'lib/external_credential/office365.rb' - 'lib/external_credential/twitter.rb' - 'lib/html_sanitizer.rb' - 'lib/models.rb' diff --git a/app/assets/javascripts/app/controllers/_channel/email.coffee b/app/assets/javascripts/app/controllers/_channel/email.coffee index 74809e7e3..2c55b286c 100644 --- a/app/assets/javascripts/app/controllers/_channel/email.coffee +++ b/app/assets/javascripts/app/controllers/_channel/email.coffee @@ -188,6 +188,7 @@ class App.ChannelEmailAccountOverview extends App.Controller 'click .js-emailAddressDelete': 'emailAddressDelete', 'click .js-editNotificationOutbound': 'editNotificationOutbound' 'click .js-migrateGoogleMail': 'migrateGoogleMail' + 'click .js-migrateOffice365Mail': 'migrateOffice365Mail' constructor: -> super @@ -385,6 +386,10 @@ class App.ChannelEmailAccountOverview extends App.Controller id = $(e.target).closest('.action').data('id') @navigate "#channels/google/#{id}" + migrateOffice365Mail: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + @navigate "#channels/office365/#{id}" class App.ChannelEmailEdit extends App.ControllerModal buttonClose: true diff --git a/app/assets/javascripts/app/controllers/_channel/office365.coffee b/app/assets/javascripts/app/controllers/_channel/office365.coffee new file mode 100644 index 000000000..7fbb4b29a --- /dev/null +++ b/app/assets/javascripts/app/controllers/_channel/office365.coffee @@ -0,0 +1,416 @@ +class App.ChannelOffice365 extends App.ControllerTabs + requiredPermission: 'admin.channel_office365' + header: 'Office 365' + constructor: -> + super + + @title 'Office 365', true + + @tabs = [ + { + name: 'Accounts', + target: 'c-account', + controller: ChannelOffice365AccountOverview, + }, + { + name: 'Filter', + target: 'c-filter', + controller: App.ChannelEmailFilter, + }, + { + name: 'Signatures', + target: 'c-signature', + controller: App.ChannelEmailSignature, + }, + { + name: 'Settings', + target: 'c-setting', + controller: App.SettingsArea, + params: { area: 'Email::Base' }, + }, + ] + + @render() + +class ChannelOffice365AccountOverview extends App.ControllerSubContent + requiredPermission: 'admin.channel_office365' + events: + 'click .js-new': 'new' + 'click .js-delete': 'delete' + 'click .js-configApp': 'configApp' + 'click .js-disable': 'disable' + 'click .js-enable': 'enable' + 'click .js-channelGroupChange': 'groupChange' + 'click .js-editInbound': 'editInbound' + 'click .js-rollbackMigration': 'rollbackMigration' + 'click .js-emailAddressNew': 'emailAddressNew' + 'click .js-emailAddressEdit': 'emailAddressEdit' + 'click .js-emailAddressDelete': 'emailAddressDelete', + + constructor: -> + super + + #@interval(@load, 60000) + @load() + + load: (reset_channel_id = false) => + if reset_channel_id + @channel_id = undefined + @navigate '#channels/office365' + + @startLoading() + @ajax( + id: 'office365_index' + type: 'GET' + url: "#{@apiPath}/channels_office365" + processData: true + success: (data, status, xhr) => + @stopLoading() + App.Collection.loadAssets(data.assets) + @callbackUrl = data.callback_url + @render(data) + ) + + render: (data) => + + # if no office365 app is registered, show intro + external_credential = App.ExternalCredential.findByAttribute('name', 'office365') + if !external_credential + @html App.view('office365/index')() + if @channel_id + @configApp() + return + + channels = [] + for channel_id in data.channel_ids + channel = App.Channel.find(channel_id) + if channel.group_id + channel.group = App.Group.find(channel.group_id) + else + channel.group = '-' + + email_addresses = App.EmailAddress.search(filter: { channel_id: channel.id }) + channel.email_addresses = email_addresses + + channels.push channel + + # auto redirect to gmail account linking if we have no account + if @channel_id && channels.length < 1 + @new() + return + + # get all unlinked email addresses + not_used_email_addresses = [] + for email_address_id in data.not_used_email_address_ids + not_used_email_addresses.push App.EmailAddress.find(email_address_id) + + @html App.view('office365/list')( + channels: channels + external_credential: external_credential + not_used_email_addresses: not_used_email_addresses + ) + + if @channel_id + item = App.Channel.find(@channel_id) + if item && item.options && item.options.backup_imap_classic is undefined + @editInbound(undefined, @channel_id, true) + @channel_id = undefined + + show: (params) => + for key, value of params + if key isnt 'el' && key isnt 'shown' && key isnt 'match' + @[key] = value + + configApp: => + new AppConfig( + container: @el.parents('.content') + callbackUrl: @callbackUrl + load: @load + ) + + new: (e) -> + window.location.href = "#{@apiPath}/external_credentials/office365/link_account" + + delete: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + new App.ControllerConfirm( + message: 'Sure?' + callback: => + @ajax( + id: 'office365_delete' + type: 'DELETE' + url: "#{@apiPath}/channels_office365" + data: JSON.stringify(id: id) + processData: true + success: => + @load() + ) + container: @el.closest('.content') + ) + + disable: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + @ajax( + id: 'office365_disable' + type: 'POST' + url: "#{@apiPath}/channels_office365_disable" + data: JSON.stringify(id: id) + processData: true + success: => + @load() + ) + + enable: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + @ajax( + id: 'office365_enable' + type: 'POST' + url: "#{@apiPath}/channels_office365_enable" + data: JSON.stringify(id: id) + processData: true + success: => + @load() + ) + + editInbound: (e, channel_id, set_active) => + if !channel_id + e.preventDefault() + channel_id = $(e.target).closest('.action').data('id') + item = App.Channel.find(channel_id) + new App.ChannelInboundEdit( + container: @el.closest('.content') + item: item + callback: @load + set_active: set_active, + ) + + rollbackMigration: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + @ajax( + id: 'office365_rollback_migration' + type: 'POST' + url: "#{@apiPath}/channels_office365_rollback_migration" + data: JSON.stringify(id: id) + processData: true + success: => + @load() + @notify + type: 'success' + msg: 'Rollback of channel migration succeeded!' + error: (data) => + @notify + type: 'error' + msg: 'Failed to rollback migration of the channel!' + ) + + groupChange: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + item = App.Channel.find(id) + new App.ChannelGroupEdit( + container: @el.closest('.content') + item: item + callback: @load + ) + + emailAddressNew: (e) => + e.preventDefault() + channel_id = $(e.target).closest('.action').data('id') + new App.ControllerGenericNew( + pageData: + object: 'Email Address' + genericObject: 'EmailAddress' + container: @el.closest('.content') + item: + channel_id: channel_id + callback: @load + ) + + emailAddressEdit: (e) => + e.preventDefault() + id = $(e.target).closest('li').data('id') + new App.ControllerGenericEdit( + pageData: + object: 'Email Address' + genericObject: 'EmailAddress' + container: @el.closest('.content') + id: id + callback: @load + ) + + emailAddressDelete: (e) => + e.preventDefault() + id = $(e.target).closest('li').data('id') + item = App.EmailAddress.find(id) + new App.ControllerGenericDestroyConfirm( + item: item + container: @el.closest('.content') + callback: @load + ) + +class App.ChannelInboundEdit extends App.ControllerModal + buttonClose: true + buttonCancel: true + buttonSubmit: true + head: 'Channel' + + content: => + configureAttributesBase = [ + { name: 'options::folder', display: 'Folder', tag: 'input', type: 'text', limit: 120, null: true, autocapitalize: false }, + { name: 'options::keep_on_server', display: 'Keep messages on server', tag: 'boolean', null: true, options: { true: 'yes', false: 'no' }, translate: true, default: false }, + ] + @form = new App.ControllerForm( + model: + configure_attributes: configureAttributesBase + className: '' + params: @item.options.inbound + ) + @form.form + + onSubmit: (e) => + @startLoading() + + # get params + params = @formParam(e.target) + + # validate form + errors = @form.validate(params) + + # show errors in form + if errors + @log 'error', errors + @formValidate(form: e.target, errors: errors) + return false + + # disable form + @formDisable(e) + + if @set_active + params['active'] = true + + # update + @ajax( + id: 'channel_email_inbound' + type: 'POST' + url: "#{@apiPath}/channels_office365_inbound/#{@item.id}" + data: JSON.stringify(params) + processData: true + success: (data, status, xhr) => + @callback(true) + @close() + error: (xhr) => + @stopLoading() + @formEnable(e) + details = xhr.responseJSON || {} + @notify + type: 'error' + msg: App.i18n.translateContent(details.error_human || details.error || 'Unable to save changes.') + timeout: 6000 + ) + +class App.ChannelGroupEdit extends App.ControllerModal + buttonClose: true + buttonCancel: true + buttonSubmit: true + head: 'Channel' + + content: => + configureAttributesBase = [ + { name: 'group_id', display: 'Destination Group', tag: 'select', null: false, relation: 'Group', nulloption: true, filter: { active: true } }, + ] + @form = new App.ControllerForm( + model: + configure_attributes: configureAttributesBase + className: '' + params: @item + ) + @form.form + + onSubmit: (e) => + + # get params + params = @formParam(e.target) + + # validate form + errors = @form.validate(params) + + # show errors in form + if errors + @log 'error', errors + @formValidate(form: e.target, errors: errors) + return false + + # disable form + @formDisable(e) + + # update + @ajax( + id: 'channel_email_group' + type: 'POST' + url: "#{@apiPath}/channels_office365_group/#{@item.id}" + data: JSON.stringify(params) + processData: true + success: (data, status, xhr) => + @callback() + @close() + error: (xhr) => + data = JSON.parse(xhr.responseText) + @formEnable(e) + @el.find('.alert').removeClass('hidden').text(data.error || 'Unable to save changes.') + ) + +class AppConfig extends App.ControllerModal + head: 'Connect Office 365 App' + shown: true + button: 'Connect' + buttonCancel: true + small: true + + content: -> + @external_credential = App.ExternalCredential.findByAttribute('name', 'office365') + content = $(App.view('office365/app_config')( + external_credential: @external_credential + callbackUrl: @callbackUrl + )) + content.find('.js-select').on('click', (e) => + @selectAll(e) + ) + content + + onClosed: => + return if !@isChanged + @isChanged = false + @load() + + onSubmit: (e) => + @formDisable(e) + + # verify app credentials + @ajax( + id: 'office365_app_verify' + type: 'POST' + url: "#{@apiPath}/external_credentials/office365/app_verify" + data: JSON.stringify(@formParams()) + processData: true + success: (data, status, xhr) => + if data.attributes + if !@external_credential + @external_credential = new App.ExternalCredential + @external_credential.load(name: 'office365', credentials: data.attributes) + @external_credential.save( + done: => + @isChanged = true + @close() + fail: => + @el.find('.alert').removeClass('hidden').text('Unable to create entry.') + ) + return + @formEnable(e) + @el.find('.alert').removeClass('hidden').text(data.error || 'Unable to verify App.') + ) + +App.Config.set('office365', { prio: 5000, name: 'Office 365', parent: '#channels', target: '#channels/office365', controller: App.ChannelOffice365, permission: ['admin.channel_office365'] }, 'NavBarAdmin') diff --git a/app/assets/javascripts/app/models/email_address.coffee b/app/assets/javascripts/app/models/email_address.coffee index b8f4c2089..7409b98bd 100644 --- a/app/assets/javascripts/app/models/email_address.coffee +++ b/app/assets/javascripts/app/models/email_address.coffee @@ -18,7 +18,7 @@ class App.EmailAddress extends App.Model if localChannel return channel if channel.area is localChannel.area else - return channel if channel.area is 'Google::Account' || channel.area is 'Email::Account' + return channel if channel.area is 'Google::Account' || channel.area is 'Office365::Account' || channel.area is 'Email::Account' ) @configure_attributes = [ diff --git a/app/assets/javascripts/app/views/channel/email_account_overview.jst.eco b/app/assets/javascripts/app/views/channel/email_account_overview.jst.eco index 5936e5f95..c1fe4dc33 100644 --- a/app/assets/javascripts/app/views/channel/email_account_overview.jst.eco +++ b/app/assets/javascripts/app/views/channel/email_account_overview.jst.eco @@ -56,6 +56,19 @@ <% end %> <% end %> + <% if channel.active is true && channel.options.inbound && channel.options.inbound.options && channel.options.inbound.options.host == 'outlook.office365.com' && channel.options.outbound && channel.options.outbound.options && channel.options.outbound.options.host == 'smtp.office365.com': %> + + <% end %> -->
diff --git a/app/assets/javascripts/app/views/office365/app_config.jst.eco b/app/assets/javascripts/app/views/office365/app_config.jst.eco new file mode 100644 index 000000000..1c1f565ee --- /dev/null +++ b/app/assets/javascripts/app/views/office365/app_config.jst.eco @@ -0,0 +1,29 @@ + +

+ <%- @T('The tutorial on how to manage a %s is hosted on our online documentation %l.', 'Office 365 App', 'https://admin-docs.zammad.org/en/latest/channels/office365.html') %> +

+
+

<%- @T('Enter your %s App Keys', 'Office 365') %>

+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+

<%- @T('Your callback URL') %>

+
+
+ +
+
+
diff --git a/app/assets/javascripts/app/views/office365/index.jst.eco b/app/assets/javascripts/app/views/office365/index.jst.eco new file mode 100644 index 000000000..105e5292e --- /dev/null +++ b/app/assets/javascripts/app/views/office365/index.jst.eco @@ -0,0 +1,12 @@ + + +
+
+

<%- @T('You can connect %s with Zammad. You need to connect your Zammad with %s first.', 'Office 365 Accounts', 'Office 365') %>

+
<%- @T('Connect Office 365 App') %>
+
+
diff --git a/app/assets/javascripts/app/views/office365/list.jst.eco b/app/assets/javascripts/app/views/office365/list.jst.eco new file mode 100644 index 000000000..cf59cc286 --- /dev/null +++ b/app/assets/javascripts/app/views/office365/list.jst.eco @@ -0,0 +1,116 @@ + + +<% if !_.isEmpty(@not_used_email_addresses): %> +
+
+
+ <%- @T('Notice') %>: <%- @T('Unassigned email addresses, assign them to a channel or delete them.') %> + +
+
+
+<% end %> + +
+<% for channel in @channels: %> +
+
+
+
+

<%- @Icon('status', channel.status_in + " inline") %> <%- @T('Inbound') %>

+ <% if channel.preferences.editable isnt false: %> +
<%- @T('Edit') %>
+ <% end %> +
+ + <% if !_.isEmpty(channel.last_log_in): %> +
+ <%= channel.last_log_in %> +
+ <% end %> + +
+ +

<%- @T('Destination Group') %>

+ + <% groupName = '' %> + <% if channel.group.displayName: %> + <% groupName = channel.group.displayName() %> + <% else: %> + <% groupName = channel.group %> + <% end %> + <% if channel.group.active is false: %> + <%- @T('%s is inactive, please select a active one.', groupName) %> + <% else: %> + <%= groupName %> + <% end %> + +
+ +
+
+

<%- @Icon('status', channel.status_out + " inline") %> <%- @T('Outbound') %>

+
+ + <% if !_.isEmpty(channel.last_log_out): %> +
+ <%= channel.last_log_out %> +
+ <% end %> + +
+ +

<%- @T('Email Address') %>

+
    + <% if !_.isEmpty(channel.email_addresses): %> + <% for email_address in channel.email_addresses: %> +
  • +
    <%= email_address.email %>
    +
    <%- @T('Edit') %>
    + <% if channel.email_addresses.length > 1: %> +
    + <%- @Icon('diagonal-cross') %> +
    + <% end %> + <% end %> + <% else: %> +
  • <%- @T('none') %> + <% end %> +
+ + <%- @T('Add') %> +
+
+ +
+
<%- @T('Delete') %>
+ <% if channel.options.backup_imap_classic: %> +
<%- @T('Rollback migration') %>
+ <% end %> + <% if channel.active is true: %> +
<%- @T('Disable') %>
+ <% else: %> +
<%- @T('Enable') %>
+ <% end %> +
+
+<% end %> +
diff --git a/app/controllers/channels_office365_controller.rb b/app/controllers/channels_office365_controller.rb new file mode 100644 index 000000000..1e3ce45e0 --- /dev/null +++ b/app/controllers/channels_office365_controller.rb @@ -0,0 +1,100 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ + +class ChannelsOffice365Controller < ApplicationController + prepend_before_action -> { authentication_check && authorize! } + + def index + system_online_service = Setting.get('system_online_service') + + assets = {} + external_credential_ids = [] + ExternalCredential.where(name: 'office365').each do |external_credential| + assets = external_credential.assets(assets) + external_credential_ids.push external_credential.id + end + + channel_ids = [] + Channel.where(area: 'Office365::Account').order(:id).each do |channel| + assets = channel.assets(assets) + channel_ids.push channel.id + end + + not_used_email_address_ids = [] + EmailAddress.find_each do |email_address| + next if system_online_service && email_address.preferences && email_address.preferences['online_service_disable'] + + assets = email_address.assets(assets) + if !email_address.channel_id || !email_address.active || !Channel.exists?(email_address.channel_id) + not_used_email_address_ids.push email_address.id + end + end + + render json: { + assets: assets, + not_used_email_address_ids: not_used_email_address_ids, + channel_ids: channel_ids, + external_credential_ids: external_credential_ids, + callback_url: ExternalCredential.callback_url('office365'), + } + end + + def enable + channel = Channel.find_by(id: params[:id], area: 'Office365::Account') + channel.active = true + channel.save! + render json: {} + end + + def disable + channel = Channel.find_by(id: params[:id], area: 'Office365::Account') + channel.active = false + channel.save! + render json: {} + end + + def destroy + channel = Channel.find_by(id: params[:id], area: 'Office365::Account') + email = EmailAddress.find_by(channel_id: channel.id) + email.destroy! + channel.destroy! + render json: {} + end + + def group + channel = Channel.find_by(id: params[:id], area: 'Office365::Account') + channel.group_id = params[:group_id] + channel.save! + render json: {} + end + + def inbound + channel = Channel.find_by(id: params[:id], area: 'Office365::Account') + %w[folder keep_on_server].each do |key| + channel.options[:inbound][:options][key] = params[:options][key] + end + + result = EmailHelper::Probe.inbound(channel.options[:inbound]) + raise Exceptions::UnprocessableEntity, ( result[:message_human] || result[:message] ) if result[:result] == 'invalid' + + channel.status_in = 'ok' + channel.status_out = 'ok' + channel.last_log_in = nil + channel.last_log_out = nil + if params.key?(:active) + channel.active = params[:active] + end + + channel.save! + + render json: {} + end + + def rollback_migration + channel = Channel.find_by(id: params[:id], area: 'Office365::Account') + raise 'Failed to find backup on channel!' if !channel.options[:backup_imap_classic] + + channel.update!(channel.options[:backup_imap_classic][:attributes]) + render json: {} + end + +end diff --git a/app/jobs/imap_authentication_migration_cleanup_job.rb b/app/jobs/imap_authentication_migration_cleanup_job.rb index f6e7c8472..012da6395 100644 --- a/app/jobs/imap_authentication_migration_cleanup_job.rb +++ b/app/jobs/imap_authentication_migration_cleanup_job.rb @@ -2,7 +2,7 @@ class ImapAuthenticationMigrationCleanupJob < ApplicationJob include HasActiveJobLock def perform - Channel.where(area: 'Google::Account').find_each do |channel| + Channel.where(area: ['Google::Account', 'Office365::Account']).find_each do |channel| next if channel.options.blank? next if channel.options[:backup_imap_classic].blank? next if channel.options[:backup_imap_classic][:migrated_at] > 7.days.ago diff --git a/app/policies/controllers/channels_office365_controller_policy.rb b/app/policies/controllers/channels_office365_controller_policy.rb new file mode 100644 index 000000000..1f372dcb9 --- /dev/null +++ b/app/policies/controllers/channels_office365_controller_policy.rb @@ -0,0 +1,3 @@ +class Controllers::ChannelsOffice365ControllerPolicy < Controllers::ApplicationControllerPolicy + default_permit!('admin.channel_office365') +end diff --git a/config/routes/office365.rb b/config/routes/office365.rb new file mode 100644 index 000000000..eeb6895dd --- /dev/null +++ b/config/routes/office365.rb @@ -0,0 +1,12 @@ +Zammad::Application.routes.draw do + api_path = Rails.configuration.api_path + + match api_path + '/channels_office365', to: 'channels_office365#index', via: :get + match api_path + '/channels_office365_disable', to: 'channels_office365#disable', via: :post + match api_path + '/channels_office365_enable', to: 'channels_office365#enable', via: :post + match api_path + '/channels_office365', to: 'channels_office365#destroy', via: :delete + match api_path + '/channels_office365_group/:id', to: 'channels_office365#group', via: :post + match api_path + '/channels_office365_inbound/:id', to: 'channels_office365#inbound', via: :post + match api_path + '/channels_office365_rollback_migration', to: 'channels_office365#rollback_migration', via: :post + +end diff --git a/lib/external_credential/office365.rb b/lib/external_credential/office365.rb new file mode 100644 index 000000000..6b86533fc --- /dev/null +++ b/lib/external_credential/office365.rb @@ -0,0 +1,267 @@ +class ExternalCredential::Office365 + + def self.app_verify(params) + request_account_to_link(params, false) + params + end + + def self.request_account_to_link(credentials = {}, app_required = true) + external_credential = ExternalCredential.find_by(name: 'office365') + raise Exceptions::UnprocessableEntity, 'No Office365 app configured!' if !external_credential && app_required + + if external_credential + if credentials[:client_id].blank? + credentials[:client_id] = external_credential.credentials['client_id'] + end + if credentials[:client_secret].blank? + credentials[:client_secret] = external_credential.credentials['client_secret'] + end + end + + raise Exceptions::UnprocessableEntity, 'No client_id param!' if credentials[:client_id].blank? + raise Exceptions::UnprocessableEntity, 'No client_secret param!' if credentials[:client_secret].blank? + + authorize_url = generate_authorize_url(credentials[:client_id]) + + { + authorize_url: authorize_url, + } + end + + def self.link_account(_request_token, params) + external_credential = ExternalCredential.find_by(name: 'office365') + raise Exceptions::UnprocessableEntity, 'No office365 app configured!' if !external_credential + raise Exceptions::UnprocessableEntity, 'No code for session found!' if !params[:code] + + response = authorize_tokens(external_credential.credentials[:client_id], external_credential.credentials[:client_secret], params[:code]) + %w[refresh_token access_token expires_in scope token_type id_token].each do |key| + raise Exceptions::UnprocessableEntity, "No #{key} for authorization request found!" if response[key.to_sym].blank? + end + + user_data = user_info(response[:id_token]) + raise Exceptions::UnprocessableEntity, 'Unable to extract user preferred_username from id_token!' if user_data[:preferred_username].blank? + + migrate_channel = nil + Channel.where(area: 'Email::Account').find_each do |channel| + next if channel.options.dig(:inbound, :options, :user) != user_data[:email] + next if channel.options.dig(:inbound, :options, :host) != 'outlook.office365.com' + next if channel.options.dig(:outbound, :options, :user) != user_data[:email] + next if channel.options.dig(:outbound, :options, :host) != 'smtp.office365.com' + + migrate_channel = channel + + break + end + + channel_options = { + inbound: { + adapter: 'imap', + options: { + auth_type: 'XOAUTH2', + host: 'outlook.office365.com', + ssl: true, + user: user_data[:preferred_username], + }, + }, + outbound: { + adapter: 'smtp', + options: { + host: 'smtp.office365.com', + domain: 'office365.com', + port: 587, + user: user_data[:preferred_username], + authentication: 'xoauth2', + }, + }, + auth: response.merge( + provider: 'office365', + type: 'XOAUTH2', + client_id: external_credential.credentials[:client_id], + client_secret: external_credential.credentials[:client_secret], + ), + } + + if migrate_channel + channel_options[:inbound][:options][:folder] = migrate_channel.options[:inbound][:options][:folder] + channel_options[:inbound][:options][:keep_on_server] = migrate_channel.options[:inbound][:options][:keep_on_server] + + backup = { + attributes: { + area: migrate_channel.area, + options: migrate_channel.options, + last_log_in: migrate_channel.last_log_in, + last_log_out: migrate_channel.last_log_out, + status_in: migrate_channel.status_in, + status_out: migrate_channel.status_out, + }, + migrated_at: Time.zone.now, + } + + migrate_channel.update( + area: 'Office365::Account', + options: channel_options.merge(backup_imap_classic: backup), + last_log_in: nil, + last_log_out: nil, + ) + + return migrate_channel + end + + email_addresses = user_aliases(response) + email_addresses.unshift({ + realname: "#{Setting.get('product_name')} Support", + email: user_data[:preferred_username], + }) + + email_addresses.each do |email| + next if !EmailAddress.exists?(email: email[:preferred_username]) + + raise Exceptions::UnprocessableEntity, "Duplicate email address or email alias #{email[:preferred_username]} found!" + end + + # create channel + channel = Channel.create!( + area: 'Office365::Account', + group_id: Group.first.id, + options: channel_options, + active: false, + created_by_id: 1, + updated_by_id: 1, + ) + + email_addresses.each do |user_alias| + EmailAddress.create!( + channel_id: channel.id, + realname: user_alias[:realname], + email: user_alias[:email], + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + end + + channel + end + + def self.generate_authorize_url(client_id, scope = 'https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send offline_access openid profile email') + + params = { + 'client_id' => client_id, + 'redirect_uri' => ExternalCredential.callback_url('office365'), + 'scope' => scope, + 'response_type' => 'code', + 'access_type' => 'offline', + 'prompt' => 'consent', + } + + uri = URI::HTTPS.build( + host: 'login.microsoftonline.com', + path: '/common/oauth2/v2.0/authorize', + query: params.to_query + ) + + uri.to_s + end + + def self.authorize_tokens(client_id, client_secret, authorization_code) + params = { + 'client_secret' => client_secret, + 'code' => authorization_code, + 'grant_type' => 'authorization_code', + 'client_id' => client_id, + 'redirect_uri' => ExternalCredential.callback_url('office365'), + } + + uri = URI::HTTPS.build( + host: 'login.microsoftonline.com', + path: '/common/oauth2/v2.0/token', + ) + + response = Net::HTTP.post_form(uri, params) + if response.code != 200 && response.body.blank? + Rails.logger.error "Request failed! (code: #{response.code})" + raise "Request failed! (code: #{response.code})" + end + + result = JSON.parse(response.body) + if result['error'] && response.code != 200 + Rails.logger.error "Request failed! ERROR: #{result['error']} (#{result['error_description']}, params: #{params.to_json})" + raise "Request failed! ERROR: #{result['error']} (#{result['error_description']})" + end + + result[:created_at] = Time.zone.now + + result.symbolize_keys + end + + def self.refresh_token(token) + return token if token[:created_at] >= Time.zone.now - 50.minutes + + params = { + 'client_id' => token[:client_id], + 'client_secret' => token[:client_secret], + 'refresh_token' => token[:refresh_token], + 'grant_type' => 'refresh_token', + } + uri = URI::HTTPS.build( + host: 'login.microsoftonline.com', + path: '/common/oauth2/v2.0/token', + ) + + response = Net::HTTP.post_form(uri, params) + if response.code != 200 && response.body.blank? + Rails.logger.error "Request failed! (code: #{response.code})" + raise "Request failed! (code: #{response.code})" + end + + result = JSON.parse(response.body) + if result['error'] && response.code != 200 + Rails.logger.error "Request failed! ERROR: #{result['error']} (#{result['error_description']}, params: #{params.to_json})" + raise "Request failed! ERROR: #{result['error']} (#{result['error_description']})" + end + + token.merge( + created_at: Time.zone.now, + access_token: result['access_token'], + ).symbolize_keys + end + + def self.user_aliases(_token) + # uri = URI.parse('https://www.office365apis.com/gmail/v1/users/me/settings/sendAs') + # http = Net::HTTP.new(uri.host, uri.port) + # http.use_ssl = true + # response = http.get(uri.request_uri, { 'Authorization' => "#{token[:token_type]} #{token[:access_token]}" }) + # if response.code != 200 && response.body.blank? + # Rails.logger.error "Request failed! (code: #{response.code})" + # raise "Request failed! (code: #{response.code})" + # end + + # result = JSON.parse(response.body) + # if result['error'] && response.code != 200 + # Rails.logger.error "Request failed! ERROR: #{result['error']['message']}" + # raise "Request failed! ERROR: #{result['error']['message']}" + # end + + # aliases = [] + # result['sendAs'].each do |row| + # next if row['isPrimary'] + # next if !row['verificationStatus'] + # next if row['verificationStatus'] != 'accepted' + + # aliases.push({ + # realname: row['displayName'], + # email: row['sendAsEmail'], + # }) + # end + + [] + end + + def self.user_info(id_token) + split = id_token.split('.')[1] + return if split.blank? + + JSON.parse(Base64.decode64(split)).symbolize_keys + end + +end diff --git a/spec/lib/external_credential/office365_spec.rb b/spec/lib/external_credential/office365_spec.rb new file mode 100644 index 000000000..f99de3b3f --- /dev/null +++ b/spec/lib/external_credential/office365_spec.rb @@ -0,0 +1,343 @@ +require 'rails_helper' + +RSpec.describe ExternalCredential::Office365 do + + let(:token_url) { 'https://login.microsoftonline.com/common/oauth2/v2.0/token' } + let(:authorize_url) { "https://login.microsoftonline.com/common/oauth2/v2.0/authorize?access_type=offline&client_id=#{client_id}&prompt=consent&redirect_uri=http%3A%2F%2Fzammad.example.com%2Fapi%2Fv1%2Fexternal_credentials%2Foffice365%2Fcallback&response_type=code&scope=https%3A%2F%2Foutlook.office.com%2FIMAP.AccessAsUser.All+https%3A%2F%2Foutlook.office.com%2FSMTP.Send+offline_access+openid+profile+email" } + + let(:id_token) { 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImtnMkxZczJUMENUaklmajRydDZKSXluZW4zOCJ9.eyJhdWQiOiIyMTk4NTFhYS0wMDAwLTRhNDctMTExMS0zMmQwNzAyZTAxMjM0IiwiaXNzIjoiaHR0cHM6Ly9sb2dpbi5taWNyb3NvZnRvbmxpbmUuY29tLzM2YTlhYjU1LWZpZmEtMjAyMC04YTc4LTkwcnM0NTRkYmNmZDJkL3YyLjAiLCJpYXQiOjEzMDE1NTE4MzUsIm5iZiI6MTMwMTU1MTgzNSwiZXhwIjoxNjAxNTU5NzQ0LCJuYW1lIjoiRXhhbXBsZSBVc2VyIiwib2lkIjoiMTExYWIyMTQtMTJzNy00M2NnLThiMTItM2ozM2UydDBjYXUyIiwicHJlZmVycmVkX3VzZXJuYW1lIjoidGVzdEBleGFtcGxlLmNvbSIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInJoIjoiMC40MjM0LWZmZnNmZGdkaGRLZUpEU1hiejlMYXBSbUNHZGdmZ2RmZ0kwZHkwSEF1QlhaSEFNYy4iLCJzdWIiOiJYY0VlcmVyQkVnX0EzNWJlc2ZkczNMTElXNjU1NFQtUy0ycGRnZ2R1Z3c1NDNXT2xJIiwidGlkIjoiMzZhOWFiNTUtZmlmYS0yMDIwLThhNzgtOTByczQ1NGRiY2ZkMmQiLCJ1dGkiOiJEU0dGZ3Nhc2RkZmdqdGpyMzV3cWVlIiwidmVyIjoiMi4wIn0=.l0nglq4rIlkR29DFK3PQFQTjE-VeHdgLmcnXwGvT8Z-QBaQjeTAcoMrVpr0WdL6SRYiyn2YuqPnxey6N0IQdlmvTMBv0X_dng_y4CiQ8ABdZrQK0VSRWZViboJgW5iBvJYFcMmVoilHChueCzTBnS1Wp2KhirS2ymUkPHS6AB98K0tzOEYciR2eJsJ2JOdo-82oOW4w6tbbqMvzT3DzsxqPQRGe2hUbNqo6gcwJLqq4t0bNf5XiYThw1sv4IivERmqW_pfybXEseKyZGd4NnJ6WwwOgTz5tkoLwls_YeDZVcp_Fpw9XR7J0UlyPqLtoUEjVihdyrJjAbdtHFKdOjrw' } + let(:access_token) { '000.0000lvC3gAbjs8CYoKitfqM5LBS5N13374MCg6pNpZ28mxO2HuZvg0000_rsW00aACmFEto1BJeGDuu0000vmV6Esqv78iec-FbEe842ZevQtOOemQyQXjhMs62K1E6g3ehDLPRp6j4vtpSKSb6I-3MuDPfdzdqI23hM0' } + let(:refresh_token) { '1//00000VO1ES0hFCgYIARAAGAkSNwF-L9IraWQNMj5ZTqhB00006DssAYcpEyFks5OuvZ1337wrqX0D7tE5o71FIPzcWEMM5000004' } + let(:request_token) { nil } # not used but required by ExternalCredential API + + let(:scope_payload) { 'https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send offline_access openid profile email' } + let(:scope_stub) { scope_payload } + + let(:client_id) { '123' } + let(:client_secret) { '345' } + let(:authorization_code) { '567' } + + let(:email_address) { 'test@example.com' } + let(:provider) { 'office365' } + let(:token_ttl) { 3599 } + + let!(:token_response_payload) do + { + 'access_token' => access_token, + 'expires_in' => token_ttl, + 'refresh_token' => refresh_token, + 'scope' => scope_stub, + 'token_type' => 'Bearer', + 'id_token' => id_token, + 'type' => 'XOAUTH2', + } + end + + describe '.link_account' do + let!(:authorization_payload) do + { + code: authorization_code, + scope: scope_payload, + authuser: '4', + hd: 'example.com', + prompt: 'consent', + controller: 'external_credentials', + action: 'callback', + provider: provider + } + end + + before do + # we check the TTL of tokens and therefore need freeze the time + freeze_time + end + + context 'success' do + + let(:request_payload) do + { + 'client_secret' => client_secret, + 'code' => authorization_code, + 'grant_type' => 'authorization_code', + 'client_id' => client_id, + 'redirect_uri' => ExternalCredential.callback_url(provider), + } + end + + before do + stub_request(:post, token_url) + .with(body: hash_including(request_payload)) + .to_return(status: 200, body: token_response_payload.to_json, headers: {}) + + create(:external_credential, name: provider, credentials: { client_id: client_id, client_secret: client_secret } ) + end + + it 'creates a Channel instance' do + + channel = described_class.link_account(request_token, authorization_payload) + + expect(channel.options).to match( + a_hash_including( + 'inbound' => a_hash_including( + 'options' => a_hash_including( + 'auth_type' => 'XOAUTH2', + 'host' => 'outlook.office365.com', + 'ssl' => true, + 'user' => email_address, + ) + ), + 'outbound' => a_hash_including( + 'options' => a_hash_including( + 'authentication' => 'xoauth2', + 'host' => 'smtp.office365.com', + 'domain' => 'office365.com', + 'port' => 587, + 'user' => email_address, + ) + ), + 'auth' => a_hash_including( + 'access_token' => access_token, + 'expires_in' => token_ttl, + 'refresh_token' => refresh_token, + 'scope' => scope_stub, + 'token_type' => 'Bearer', + 'id_token' => id_token, + 'created_at' => Time.zone.now, + 'type' => 'XOAUTH2', + 'client_id' => client_id, + 'client_secret' => client_secret, + ), + ) + ) + end + end + + context 'API errors' do + + before do + stub_request(:post, token_url).to_return(status: response_status, body: response_payload&.to_json, headers: {}) + + create(:external_credential, name: provider, credentials: { client_id: client_id, client_secret: client_secret } ) + end + + shared_examples 'failed attempt' do + it 'raises an exception' do + expect do + described_class.link_account(request_token, authorization_payload) + end.to raise_error(RuntimeError, exception_message) + end + end + + context '404 invalid_client' do + let(:response_status) { 404 } + let(:response_payload) do + { + "error": 'invalid_client', + "error_description": 'The OAuth client was not found.' + } + end + let(:exception_message) { 'Request failed! ERROR: invalid_client (The OAuth client was not found.)' } + + include_examples 'failed attempt' + end + + context '500 Internal Server Error' do + let(:response_status) { 500 } + let(:response_payload) { nil } + let(:exception_message) { 'Request failed! (code: 500)' } + + include_examples 'failed attempt' + end + end + end + + describe '.refresh_token' do + let!(:authorization_payload) do + { + code: authorization_code, + scope: scope_payload, + authuser: '4', + hd: 'example.com', + prompt: 'consent', + controller: 'external_credentials', + action: 'callback', + provider: provider + } + end + let!(:channel) do + stub_request(:post, token_url).to_return(status: 200, body: token_response_payload.to_json, headers: {}) + + create(:external_credential, name: provider, credentials: { client_id: client_id, client_secret: client_secret } ) + channel = described_class.link_account(request_token, authorization_payload) + + # remove stubs and allow new stubbing for tested requests + WebMock.reset! + + channel + end + + before do + # we check the TTL of tokens and therefore need freeze the time + freeze_time + end + + context 'success' do + + let!(:expired_at) { channel.options['auth']['created_at'] } + + before do + stub_request(:post, token_url).to_return(status: 200, body: response_payload.to_json, headers: {}) + end + + context 'access_token still valid' do + let(:response_payload) do + { + 'access_token' => access_token, + 'expires_in' => token_ttl, + 'scope' => scope_stub, + 'token_type' => 'Bearer', + 'type' => 'XOAUTH2', + } + end + + it 'does not refresh' do + expect do + channel.refresh_xoaut2! + end.not_to change { channel.options['auth']['created_at'] } + end + end + + context 'access_token expired' do + let(:refreshed_access_token) { 'some_new_token' } + + let(:response_payload) do + { + 'access_token' => refreshed_access_token, + 'expires_in' => token_ttl, + 'scope' => scope_stub, + 'token_type' => 'Bearer', + 'type' => 'XOAUTH2', + } + end + + before do + travel 1.hour + end + + it 'refreshes token' do + expect do + channel.refresh_xoaut2! + end.to change { channel.options['auth'] }.to a_hash_including( + 'created_at' => Time.zone.now, + 'access_token' => refreshed_access_token, + ) + end + end + end + + context 'API errors' do + + before do + stub_request(:post, token_url).to_return(status: response_status, body: response_payload&.to_json, headers: {}) + + # invalidate existing token + travel 1.hour + end + + shared_examples 'failed attempt' do + it 'raises an exception' do + expect do + channel.refresh_xoaut2! + end.to raise_error(RuntimeError, exception_message) + end + end + + context '400 invalid_client' do + let(:response_status) { 400 } + let(:response_payload) do + { + "error": 'invalid_client', + "error_description": 'The OAuth client was not found.' + } + end + let(:exception_message) { /The OAuth client was not found/ } + + include_examples 'failed attempt' + end + + context '500 Internal Server Error' do + let(:response_status) { 500 } + let(:response_payload) { nil } + let(:exception_message) { /code: 500/ } + + include_examples 'failed attempt' + end + end + end + + describe '.request_account_to_link' do + it 'generates authorize_url from credentials' do + office365 = create(:external_credential, name: provider, credentials: { client_id: client_id, client_secret: client_secret } ) + request = described_class.request_account_to_link(office365.credentials) + + expect(request[:authorize_url]).to eq(authorize_url) + end + + context 'errors' do + + shared_examples 'failed attempt' do + it 'raises an exception' do + expect do + described_class.request_account_to_link(credentials, app_required) + end.to raise_error(Exceptions::UnprocessableEntity, exception_message) + end + end + + context 'missing credentials' do + let(:credentials) { nil } + let(:app_required) { true } + let(:exception_message) { 'No Office365 app configured!' } + + include_examples 'failed attempt' + end + + context 'missing client_id' do + let(:credentials) do + { + client_secret: client_secret + } + end + let(:app_required) { false } + let(:exception_message) { 'No client_id param!' } + + include_examples 'failed attempt' + end + + context 'missing client_secret' do + let(:credentials) do + { + client_id: client_id + } + end + let(:app_required) { false } + let(:exception_message) { 'No client_secret param!' } + + include_examples 'failed attempt' + end + end + end + + describe '.generate_authorize_url' do + it 'generates valid URL' do + url = described_class.generate_authorize_url(client_id) + expect(url).to eq(authorize_url) + end + end + + describe '.user_info' do + it 'extracts user information from id_token' do + info = described_class.user_info(id_token) + expect(info[:email]).to eq(email_address) + end + end +end diff --git a/spec/requests/integration/office365_spec.rb b/spec/requests/integration/office365_spec.rb new file mode 100644 index 000000000..714bb1187 --- /dev/null +++ b/spec/requests/integration/office365_spec.rb @@ -0,0 +1,77 @@ +require 'rails_helper' +RSpec.describe 'Office365 XOAUTH2' do # rubocop:disable RSpec/DescribeClass + let(:channel) do + create(:channel, + area: 'Office365::Account', + options: { + 'inbound' => { + 'adapter' => 'imap', + 'options' => { + 'auth_type' => 'XOAUTH2', + 'host' => 'outlook.office365.com', + 'ssl' => true, + 'user' => ENV['OFFICE365_USER'], + 'folder' => '', + 'keep_on_server' => false, + } + }, + 'outbound' => { + 'adapter' => 'smtp', + 'options' => { + 'host' => 'smtp.office365.com', + 'domain' => 'office365.com', + 'port' => 587, + 'user' => ENV['OFFICE365_USER'], + 'authentication' => 'xoauth2', + } + }, + 'auth' => { + 'type' => 'XOAUTH2', + 'provider' => 'office365', + 'access_token' => 'xxx', + 'expires_in' => 3599, + 'refresh_token' => ENV['OFFICE365_REFRESH_TOKEN'], + 'scope' => 'https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send offline_access openid profile email', + 'token_type' => 'Bearer', + 'id_token' => 'xxx', + 'created_at' => 30.days.ago, + 'client_id' => ENV['OFFICE365_CLIENT_ID'], + 'client_secret' => ENV['OFFICE365_CLIENT_SECRET'], + } + }) + end + + before do + required_envs = %w[OFFICE365_REFRESH_TOKEN OFFICE365_CLIENT_ID OFFICE365_CLIENT_SECRET OFFICE365_USER] + required_envs.each do |key| + skip("NOTICE: Missing environment variable #{key} for test! (Please fill up: #{required_envs.join(' && ')})") if ENV[key].blank? + end + end + + context 'inbound' do + it 'succeeds' do + result = EmailHelper::Probe.inbound(channel.options[:inbound]) + expect(result[:result]).to eq('ok') + end + end + + context 'outbound' do + it 'succeeds' do + result = EmailHelper::Probe.outbound(channel.options[:outbound], ENV['OFFICE365_USER'], "test office365 oauth unittest #{Random.new_seed}") + expect(result[:result]).to eq('ok') + end + end + + context 'when non-Office365 channels are present' do + + let!(:email_address) { create(:email_address, channel: create(:channel, area: 'Some::Other')) } + + before do + channel + end + + it "doesn't remove email address assignments" do + expect { Channel.where(area: 'Office365::Account').find_each {} }.not_to change { email_address.reload.channel_id } + end + end +end diff --git a/spec/support/vcr.rb b/spec/support/vcr.rb index b3d496990..2267e036d 100644 --- a/spec/support/vcr.rb +++ b/spec/support/vcr.rb @@ -1,4 +1,4 @@ -VCR_IGNORE_MATCHING_HOSTS = %w[zammad.com google.com elasticsearch selenium].freeze +VCR_IGNORE_MATCHING_HOSTS = %w[zammad.com google.com elasticsearch selenium login.microsoftonline.com].freeze VCR_IGNORE_MATCHING_REGEXPS = [/^192\.168\.\d+\.\d+$/].freeze VCR.configure do |config|