Fixes #3215 - Outlook/Office365/Exchange Online MFA for IMAP & SMTP.
This commit is contained in:
parent
85bb77c551
commit
db3f553140
17 changed files with 1408 additions and 3 deletions
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
|
416
app/assets/javascripts/app/controllers/_channel/office365.coffee
Normal file
416
app/assets/javascripts/app/controllers/_channel/office365.coffee
Normal file
|
@ -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')
|
|
@ -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 = [
|
||||
|
|
|
@ -56,6 +56,19 @@
|
|||
<% end %>
|
||||
</div>
|
||||
<% 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': %>
|
||||
<div class="action-alert alert alert--danger alert--square horizontal centered" role="alert">
|
||||
<% date_migration_string = '2020-10-13' %>
|
||||
<% date_migration_string_local = App.i18n.translateDate(date_migration_string, 0) %>
|
||||
<% date_migration = new Date("#{date_migration_string}T00:00:00") %>
|
||||
<% date_now = new Date() %>
|
||||
<% if date_now > date_migration: %>
|
||||
<%- @T('%s will only allow access via OAuth. Password-based access is no longer supported since %s.', 'Office 365', date_migration_string_local) %> <div class="flex-spacer"></div><div class="double-spacer"></div><button class="btn js-migrateGoogleMail" type="button"><%- @T('Migrate now!') %></button>
|
||||
<% else: %>
|
||||
<%- @T('%s will only allow access via OAuth. Password-based access will no longer be supported on %s.', 'Office 365', date_migration_string_local) %> <div class="flex-spacer"></div><div class="double-spacer"></div><button class="btn js-migrateGoogleMail" type="button"><%- @T('Migrate now!') %></button>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
-->
|
||||
<div class="action-flow" style="width: 100%;">
|
||||
<div class="action-block action-block--flex">
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
<div class="alert alert--danger hidden" role="alert"></div>
|
||||
<p>
|
||||
<%- @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') %>
|
||||
</p>
|
||||
<fieldset>
|
||||
<h2><%- @T('Enter your %s App Keys', 'Office 365') %></h2>
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="client_id">Client ID <span>*</span></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input id="client_id" type="text" name="client_id" value="<% if @external_credential && @external_credential.credentials: %><%= @external_credential.credentials.client_id %><% end %>" class="form-control" required autocomplete="off" >
|
||||
</div>
|
||||
</div>
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="client_secret">Client Secret <span>*</span></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input id="client_secret" type="text" name="client_secret" value="<% if @external_credential && @external_credential.credentials: %><%= @external_credential.credentials.client_secret %><% end %>" class="form-control" required autocomplete="off" >
|
||||
</div>
|
||||
</div>
|
||||
<h2><%- @T('Your callback URL') %></h2>
|
||||
<div class="input form-group">
|
||||
<div class="controls">
|
||||
<input class="form-control js-select" readonly value="<%= @callbackUrl %>">
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
12
app/assets/javascripts/app/views/office365/index.jst.eco
Normal file
12
app/assets/javascripts/app/views/office365/index.jst.eco
Normal file
|
@ -0,0 +1,12 @@
|
|||
<div class="page-header">
|
||||
<div class="page-header-title">
|
||||
<h1><%- @T('Office 365') %> <small><%- @T('Accounts') %></small></h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-content">
|
||||
<div class="page-description">
|
||||
<p><%- @T('You can connect %s with Zammad. You need to connect your Zammad with %s first.', 'Office 365 Accounts', 'Office 365') %></p>
|
||||
<div class="btn btn--success js-configApp"><%- @T('Connect Office 365 App') %></div>
|
||||
</div>
|
||||
</div>
|
116
app/assets/javascripts/app/views/office365/list.jst.eco
Normal file
116
app/assets/javascripts/app/views/office365/list.jst.eco
Normal file
|
@ -0,0 +1,116 @@
|
|||
<div class="page-header">
|
||||
<div class="page-header-title">
|
||||
<h1><%- @T('Office 365') %> <small><%- @T('Accounts') %></small></h1>
|
||||
</div>
|
||||
|
||||
<div class="page-header-meta">
|
||||
<a class="btn js-configApp"><%- @T('Configure App') %></a>
|
||||
<a class="btn btn--success js-new"><%- @T('Add Account') %></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if !_.isEmpty(@not_used_email_addresses): %>
|
||||
<div class="action">
|
||||
<div class="action-flow">
|
||||
<div class="action-block">
|
||||
<%- @T('Notice') %>: <%- @T('Unassigned email addresses, assign them to a channel or delete them.') %></h3>
|
||||
<ul class="list">
|
||||
<% for email_address in @not_used_email_addresses: %>
|
||||
<li class="list-item" data-id="<%= email_address.id %>">
|
||||
<div class="list-item-name">
|
||||
<a href="#" class="js-emailAddressEdit"><%= email_address.realname %> <<%= email_address.email %>></a>
|
||||
</div>
|
||||
<div class="list-item-delete js-emailAddressDelete">
|
||||
<%- @Icon('diagonal-cross') %>
|
||||
</div>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="page-content">
|
||||
<% for channel in @channels: %>
|
||||
<div class="action <% if channel.active isnt true: %>is-inactive<% end %>" data-id="<%= channel.id %>">
|
||||
<div class="action-flow" style="width: 100%;">
|
||||
<div class="action-block action-block--flex">
|
||||
<div class="horizontal">
|
||||
<h3><%- @Icon('status', channel.status_in + " inline") %> <%- @T('Inbound') %></h3>
|
||||
<% if channel.preferences.editable isnt false: %>
|
||||
<div class="js-editInbound btn btn--text space-left"><%- @T('Edit') %></div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if !_.isEmpty(channel.last_log_in): %>
|
||||
<div class="alert alert--danger">
|
||||
<%= channel.last_log_in %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<hr>
|
||||
|
||||
<h3><%- @T('Destination Group') %></h3>
|
||||
<a href="#" class="js-channelGroupChange <% if channel.group.active is false: %>alert alert--danger<% end %>">
|
||||
<% 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 %>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="action-block action-block--flex">
|
||||
<div class="horizontal">
|
||||
<h3><%- @Icon('status', channel.status_out + " inline") %> <%- @T('Outbound') %></h3>
|
||||
</div>
|
||||
|
||||
<% if !_.isEmpty(channel.last_log_out): %>
|
||||
<div class="alert alert--danger">
|
||||
<%= channel.last_log_out %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<hr>
|
||||
|
||||
<h3><%- @T('Email Address') %></h3>
|
||||
<ul class="list">
|
||||
<% if !_.isEmpty(channel.email_addresses): %>
|
||||
<% for email_address in channel.email_addresses: %>
|
||||
<li class="list-item" data-id="<%= email_address.id %>">
|
||||
<div class="list-item-name"><%= email_address.email %></div>
|
||||
<div class="btn btn--text js-emailAddressEdit space-left space-right"><%- @T('Edit') %></div>
|
||||
<% if channel.email_addresses.length > 1: %>
|
||||
<div class="list-item-delete js-emailAddressDelete">
|
||||
<%- @Icon('diagonal-cross') %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% else: %>
|
||||
<li class="list-item"><%- @T('none') %>
|
||||
<% end %>
|
||||
</ul>
|
||||
<a class="text-muted js-emailAddressNew" href="#">+ <%- @T('Add') %></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-controls">
|
||||
<div class="btn btn--danger btn--secondary js-delete"><%- @T('Delete') %></div>
|
||||
<% if channel.options.backup_imap_classic: %>
|
||||
<div class="btn btn--secondary js-rollbackMigration"><%- @T('Rollback migration') %></div>
|
||||
<% end %>
|
||||
<% if channel.active is true: %>
|
||||
<div class="btn btn--secondary js-disable"><%- @T('Disable') %></div>
|
||||
<% else: %>
|
||||
<div class="btn btn--secondary js-enable"><%- @T('Enable') %></div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
100
app/controllers/channels_office365_controller.rb
Normal file
100
app/controllers/channels_office365_controller.rb
Normal file
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
class Controllers::ChannelsOffice365ControllerPolicy < Controllers::ApplicationControllerPolicy
|
||||
default_permit!('admin.channel_office365')
|
||||
end
|
12
config/routes/office365.rb
Normal file
12
config/routes/office365.rb
Normal file
|
@ -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
|
267
lib/external_credential/office365.rb
Normal file
267
lib/external_credential/office365.rb
Normal file
|
@ -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
|
343
spec/lib/external_credential/office365_spec.rb
Normal file
343
spec/lib/external_credential/office365_spec.rb
Normal file
|
@ -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
|
77
spec/requests/integration/office365_spec.rb
Normal file
77
spec/requests/integration/office365_spec.rb
Normal file
|
@ -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
|
|
@ -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|
|
||||
|
|
Loading…
Reference in a new issue