Fixes #3215 - Outlook/Office365/Exchange Online MFA for IMAP & SMTP.

This commit is contained in:
Rolf Schmidt 2020-10-02 14:46:19 +02:00 committed by Thorsten Eckel
parent 85bb77c551
commit db3f553140
17 changed files with 1408 additions and 3 deletions

View file

@ -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'

View file

@ -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'

View file

@ -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

View 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')

View file

@ -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 = [

View file

@ -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">

View file

@ -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>

View 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>

View 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 %> &lt;<%= email_address.email %>&gt;</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>

View 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

View file

@ -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

View file

@ -0,0 +1,3 @@
class Controllers::ChannelsOffice365ControllerPolicy < Controllers::ApplicationControllerPolicy
default_permit!('admin.channel_office365')
end

View 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

View 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

View 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

View 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

View file

@ -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|