Fixes #2866 - G Suite accounts will only allow access to apps using OAuth. Password-based access will no longer be supported.
This commit is contained in:
parent
f14607e265
commit
5f06c8c6b4
33 changed files with 1694 additions and 34 deletions
1
Gemfile
1
Gemfile
|
@ -82,6 +82,7 @@ gem 'omniauth-twitter'
|
|||
gem 'omniauth-weibo-oauth2'
|
||||
|
||||
# channels
|
||||
gem 'gmail_xoauth'
|
||||
gem 'koala'
|
||||
gem 'telegramAPI'
|
||||
gem 'twitter', git: 'https://github.com/sferik/twitter.git'
|
||||
|
|
|
@ -214,6 +214,8 @@ GEM
|
|||
retriable (~> 2.1)
|
||||
globalid (0.4.2)
|
||||
activesupport (>= 4.2.0)
|
||||
gmail_xoauth (0.4.2)
|
||||
oauth (>= 0.3.6)
|
||||
guard (2.15.0)
|
||||
formatador (>= 0.2.4)
|
||||
listen (>= 2.7, < 4.0)
|
||||
|
@ -600,6 +602,7 @@ DEPENDENCIES
|
|||
factory_bot_rails
|
||||
faker
|
||||
github_changelog_generator
|
||||
gmail_xoauth
|
||||
guard
|
||||
guard-livereload
|
||||
guard-symlink
|
||||
|
|
|
@ -663,6 +663,14 @@ class App.ControllerModal extends App.Controller
|
|||
@clearAlerts()
|
||||
@onSubmit(e)
|
||||
|
||||
startLoading: =>
|
||||
@$('.modal-body').addClass('hide')
|
||||
@$('.modal-loader').removeClass('hide')
|
||||
|
||||
stopLoading: =>
|
||||
@$('.modal-body').removeClass('hide')
|
||||
@$('.modal-loader').addClass('hide')
|
||||
|
||||
class App.SessionMessage extends App.ControllerModal
|
||||
showTrySupport: true
|
||||
|
||||
|
|
|
@ -381,8 +381,9 @@ class App.ControllerTabs extends App.Controller
|
|||
events:
|
||||
'click .nav-tabs [data-toggle="tab"]': 'tabRemember'
|
||||
|
||||
constructor: ->
|
||||
super
|
||||
constructor: (params) ->
|
||||
@originParams = params # remember params for sub-controller
|
||||
super(params)
|
||||
|
||||
# check authentication
|
||||
if @requiredPermission
|
||||
|
@ -420,7 +421,7 @@ class App.ControllerTabs extends App.Controller
|
|||
params.target = tab.target
|
||||
params.el = @$("##{tab.target}")
|
||||
@controllerList ||= []
|
||||
@controllerList.push new tab.controller(params)
|
||||
@controllerList.push new tab.controller(_.extend(@originParams, params))
|
||||
|
||||
# check if tabs need to be show / cant' use .tab(), because tabs are note shown (only one tab exists)
|
||||
if @tabs.length <= 1
|
||||
|
@ -450,7 +451,7 @@ class App.ControllerNavSidbar extends App.Controller
|
|||
@bind('ui:rerender',
|
||||
=>
|
||||
@render(true)
|
||||
@updateNavigation(true)
|
||||
@updateNavigation(true, params)
|
||||
)
|
||||
|
||||
show: (params = {}) =>
|
||||
|
@ -460,7 +461,7 @@ class App.ControllerNavSidbar extends App.Controller
|
|||
for key, value of params
|
||||
if key isnt 'el' && key isnt 'shown' && key isnt 'match'
|
||||
@[key] = value
|
||||
@updateNavigation()
|
||||
@updateNavigation(false, params)
|
||||
if @activeController && _.isFunction(@activeController.show)
|
||||
@activeController.show(params)
|
||||
|
||||
|
@ -482,7 +483,7 @@ class App.ControllerNavSidbar extends App.Controller
|
|||
selectedItem: selectedItem
|
||||
)
|
||||
|
||||
updateNavigation: (force) =>
|
||||
updateNavigation: (force, params) =>
|
||||
groups = @groupsSorted()
|
||||
selectedItem = @selectedItem(groups)
|
||||
return if !selectedItem
|
||||
|
@ -491,7 +492,7 @@ class App.ControllerNavSidbar extends App.Controller
|
|||
@$('.sidebar li').removeClass('active')
|
||||
@$(".sidebar li a[href=\"#{selectedItem.target}\"]").parent().addClass('active')
|
||||
|
||||
@executeController(selectedItem)
|
||||
@executeController(selectedItem, params)
|
||||
|
||||
groupsSorted: =>
|
||||
|
||||
|
@ -552,16 +553,14 @@ class App.ControllerNavSidbar extends App.Controller
|
|||
|
||||
selectedItem
|
||||
|
||||
executeController: (selectedItem) =>
|
||||
executeController: (selectedItem, params) =>
|
||||
|
||||
if @activeController
|
||||
@activeController.el.remove()
|
||||
@activeController = undefined
|
||||
|
||||
@$('.main').append('<div>')
|
||||
@activeController = new selectedItem.controller(
|
||||
el: @$('.main div')
|
||||
)
|
||||
@activeController = new selectedItem.controller(_.extend(params, el: @$('.main div')))
|
||||
|
||||
setPosition: (position) =>
|
||||
return if @shown
|
||||
|
|
|
@ -187,6 +187,7 @@ class App.ChannelEmailAccountOverview extends App.Controller
|
|||
'click .js-emailAddressEdit': 'emailAddressEdit'
|
||||
'click .js-emailAddressDelete': 'emailAddressDelete',
|
||||
'click .js-editNotificationOutbound': 'editNotificationOutbound'
|
||||
'click .js-migrateGoogleMail': 'migrateGoogleMail'
|
||||
|
||||
constructor: ->
|
||||
super
|
||||
|
@ -379,6 +380,12 @@ class App.ChannelEmailAccountOverview extends App.Controller
|
|||
channelDriver: @channelDriver
|
||||
)
|
||||
|
||||
migrateGoogleMail: (e) =>
|
||||
e.preventDefault()
|
||||
id = $(e.target).closest('.action').data('id')
|
||||
@navigate "#channels/google/#{id}"
|
||||
|
||||
|
||||
class App.ChannelEmailEdit extends App.ControllerModal
|
||||
buttonClose: true
|
||||
buttonCancel: true
|
||||
|
|
425
app/assets/javascripts/app/controllers/_channel/google.coffee
Normal file
425
app/assets/javascripts/app/controllers/_channel/google.coffee
Normal file
|
@ -0,0 +1,425 @@
|
|||
class App.ChannelGoogle extends App.ControllerTabs
|
||||
requiredPermission: 'admin.channel_email'
|
||||
header: 'Google'
|
||||
constructor: ->
|
||||
super
|
||||
|
||||
@title 'Google', true
|
||||
|
||||
@tabs = [
|
||||
{
|
||||
name: 'Accounts',
|
||||
target: 'c-account',
|
||||
controller: ChannelGoogleAccountOverview,
|
||||
},
|
||||
{
|
||||
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 ChannelGoogleAccountOverview extends App.ControllerSubContent
|
||||
requiredPermission: 'admin.channel_google'
|
||||
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/google'
|
||||
|
||||
@startLoading()
|
||||
@ajax(
|
||||
id: 'google_index'
|
||||
type: 'GET'
|
||||
url: "#{@apiPath}/channels_google"
|
||||
processData: true
|
||||
success: (data, status, xhr) =>
|
||||
@stopLoading()
|
||||
App.Collection.loadAssets(data.assets)
|
||||
@callbackUrl = data.callback_url
|
||||
@render(data)
|
||||
)
|
||||
|
||||
render: (data) =>
|
||||
|
||||
# if no google app is registered, show intro
|
||||
external_credential = App.ExternalCredential.findByAttribute('name', 'google')
|
||||
if !external_credential
|
||||
@html App.view('google/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
|
||||
|
||||
# on a channel migration we need to auto redirect
|
||||
# the user to the "Add Account" functionality after
|
||||
# the filled up the external credentials
|
||||
if @channel_id
|
||||
item = App.Channel.find(@channel_id)
|
||||
if item && item.area != 'Google::Account'
|
||||
@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('google/list')(
|
||||
channels: channels
|
||||
external_credential: external_credential
|
||||
not_used_email_addresses: not_used_email_addresses
|
||||
)
|
||||
|
||||
# on a channel creation we will auto open the edit
|
||||
# dialog after the redirect back to zammad to optional
|
||||
# change the inbound configuration, but not for
|
||||
# migrated channel because we guess that the inbound configuration
|
||||
# is already correct for them.
|
||||
if @channel_id
|
||||
item = App.Channel.find(@channel_id)
|
||||
if item && item.area == 'Google::Account' && 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/google/link_account"
|
||||
|
||||
delete: (e) =>
|
||||
e.preventDefault()
|
||||
id = $(e.target).closest('.action').data('id')
|
||||
new App.ControllerConfirm(
|
||||
message: 'Sure?'
|
||||
callback: =>
|
||||
@ajax(
|
||||
id: 'google_delete'
|
||||
type: 'DELETE'
|
||||
url: "#{@apiPath}/channels_google"
|
||||
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: 'google_disable'
|
||||
type: 'POST'
|
||||
url: "#{@apiPath}/channels_google_disable"
|
||||
data: JSON.stringify(id: id)
|
||||
processData: true
|
||||
success: =>
|
||||
@load()
|
||||
)
|
||||
|
||||
enable: (e) =>
|
||||
e.preventDefault()
|
||||
id = $(e.target).closest('.action').data('id')
|
||||
@ajax(
|
||||
id: 'google_enable'
|
||||
type: 'POST'
|
||||
url: "#{@apiPath}/channels_google_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: 'google_rollback_migration'
|
||||
type: 'POST'
|
||||
url: "#{@apiPath}/channels_google_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, placeholder: 'optional' },
|
||||
{ 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_google_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_google_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 Google App'
|
||||
shown: true
|
||||
button: 'Connect'
|
||||
buttonCancel: true
|
||||
small: true
|
||||
|
||||
content: ->
|
||||
@external_credential = App.ExternalCredential.findByAttribute('name', 'google')
|
||||
content = $(App.view('google/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: 'google_app_verify'
|
||||
type: 'POST'
|
||||
url: "#{@apiPath}/external_credentials/google/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: 'google', 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('google', { prio: 5000, name: 'Google', parent: '#channels', target: '#channels/google', controller: App.ChannelGoogle, permission: ['admin.channel_google'] }, 'NavBarAdmin')
|
|
@ -3,12 +3,22 @@ class App.EmailAddress extends App.Model
|
|||
@extend Spine.Model.Ajax
|
||||
@url: @apiPath + '/email_addresses'
|
||||
|
||||
@filterChannel: (options, type) ->
|
||||
@filterChannel: (options, type, params) ->
|
||||
return options if type isnt 'collection'
|
||||
|
||||
localChannel = undefined
|
||||
if params && params.channel_id
|
||||
if App.Channel.exists(params.channel_id)
|
||||
localChannel = App.Channel.find(params.channel_id)
|
||||
|
||||
_.filter(
|
||||
options
|
||||
(channel) ->
|
||||
return channel if channel && channel.area is 'Email::Account'
|
||||
return if !channel
|
||||
if localChannel
|
||||
return channel if channel.area is localChannel.area
|
||||
else
|
||||
return channel if channel.area is 'Google::Account' || channel.area is 'Email::Account'
|
||||
)
|
||||
|
||||
@configure_attributes = [
|
||||
|
|
|
@ -42,6 +42,21 @@
|
|||
<% else: %>
|
||||
<% for channel in @account_channels: %>
|
||||
<div class="action <% if channel.active isnt true: %>is-inactive<% end %>" data-id="<%- channel.id %>">
|
||||
<!-- Google has postponed the authentication removal. No need to show the warning for now.
|
||||
<% if channel.active is true && channel.options.inbound && channel.options.inbound.options && channel.options.inbound.options.host == 'imap.gmail.com' && channel.options.outbound && channel.options.outbound.options && channel.options.outbound.options.host == 'smtp.gmail.com': %>
|
||||
<div class="action-alert alert alert--danger alert--square horizontal centered" role="alert">
|
||||
<% date_migration_string = '2020-06-15' %>
|
||||
<% 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.', 'G Suite', 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.', 'G Suite', 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">
|
||||
<div class="horizontal">
|
||||
|
@ -140,7 +155,7 @@
|
|||
</div>
|
||||
<div class="action-controls">
|
||||
<% if channel.active is true: %>
|
||||
<div class="btn btn--secondary js-channelDisable"><%- @T('Disable') %></div>
|
||||
<div class="btn js-channelDisable"><%- @T('Disable') %></div>
|
||||
<% else: %>
|
||||
<div class="btn btn--secondary js-channelEnable"><%- @T('Enable') %></div>
|
||||
<% end %>
|
||||
|
|
29
app/assets/javascripts/app/views/google/app_config.jst.eco
Normal file
29
app/assets/javascripts/app/views/google/app_config.jst.eco
Normal 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.', 'Google App', 'https://admin-docs.zammad.org/en/latest/channels/google.html') %>
|
||||
</p>
|
||||
<fieldset>
|
||||
<h2><%- @T('Enter your %s App Keys', 'Google') %></h2>
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="client_id">Google 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">Google 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/google/index.jst.eco
Normal file
12
app/assets/javascripts/app/views/google/index.jst.eco
Normal file
|
@ -0,0 +1,12 @@
|
|||
<div class="page-header">
|
||||
<div class="page-header-title">
|
||||
<h1><%- @T('Gmail') %> <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.', 'Google Accounts', 'Google') %></p>
|
||||
<div class="btn btn--success js-configApp"><%- @T('Connect Google App') %></div>
|
||||
</div>
|
||||
</div>
|
116
app/assets/javascripts/app/views/google/list.jst.eco
Normal file
116
app/assets/javascripts/app/views/google/list.jst.eco
Normal file
|
@ -0,0 +1,116 @@
|
|||
<div class="page-header">
|
||||
<div class="page-header-title">
|
||||
<h1><%- @T('Gmail') %> <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">
|
||||
<% if channel.options.backup_imap_classic: %>
|
||||
<div class="btn js-rollbackMigration"><%- @T('Rollback migration') %></div>
|
||||
<% end %>
|
||||
<% if channel.active is true: %>
|
||||
<div class="btn js-disable"><%- @T('Disable') %></div>
|
||||
<% else: %>
|
||||
<div class="btn btn--secondary js-enable"><%- @T('Enable') %></div>
|
||||
<% end %>
|
||||
<div class="btn btn--danger btn--secondary js-delete"><%- @T('Delete') %></div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
|
@ -81,6 +81,9 @@
|
|||
<div class="alert alert--success horizontal" role="alert">Connection established! <code class="align-right">.alert--success</code></div>
|
||||
<div class="alert alert--warning horizontal" role="alert">Delete Cookies? <code class="align-right">.alert--warning</code></div>
|
||||
<div class="alert alert--danger horizontal" role="alert">System Shutting Down! <code class="align-right">.alert--danger</code></div>
|
||||
<h3>Alert with button</h3>
|
||||
<div class="alert alert--success horizontal centered" role="alert">Add all 5 rules to your rule-set. <div class="flex-spacer"></div><div class="double-spacer"></div><div class="btn btn--action">Add Rules</div></div>
|
||||
<div class="alert alert--danger horizontal centered" role="alert">Please migrate this account. <div class="flex-spacer"></div><div class="double-spacer"></div><div class="btn btn--action">Migrate now!</div></div>
|
||||
|
||||
<hr>
|
||||
|
||||
|
|
|
@ -17,6 +17,9 @@
|
|||
<div class="modal-body">
|
||||
<%- @content %>
|
||||
</div>
|
||||
<div class="modal-loader js-loading hide">
|
||||
<div class="loading icon"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<% if @buttonCancel: %>
|
||||
<div class="modal-leftFooter">
|
||||
|
|
|
@ -6688,12 +6688,18 @@ footer {
|
|||
border-radius: 3px;
|
||||
color: white;
|
||||
border: none;
|
||||
background: hsla(0,0%,0%,.3);
|
||||
|
||||
.icon {
|
||||
@include bidi-style(margin-right, 10px, margin-left, 0);
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: hsla(0,0%,0%,.2);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&--info {
|
||||
background: hsl(203,65%,55%);
|
||||
}
|
||||
|
@ -6705,6 +6711,10 @@ footer {
|
|||
&--warning {
|
||||
color: hsl(45,98%,17%);
|
||||
background: hsl(45,98%,63%);
|
||||
|
||||
.btn {
|
||||
background: hsla(0,0%,100%,.2);
|
||||
}
|
||||
}
|
||||
|
||||
&--danger {
|
||||
|
@ -7243,6 +7253,11 @@ footer {
|
|||
|
||||
.modal {
|
||||
@extend .zIndex-10;
|
||||
|
||||
&-loader {
|
||||
text-align: center;
|
||||
padding: 70px;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
|
@ -9346,7 +9361,12 @@ output {
|
|||
}
|
||||
}
|
||||
|
||||
.action-flow {
|
||||
&-alert {
|
||||
width: calc(100% + 20px);
|
||||
margin: -10px -10px 10px;
|
||||
}
|
||||
|
||||
&-flow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
|
@ -9359,7 +9379,7 @@ output {
|
|||
}
|
||||
}
|
||||
|
||||
.action-separator {
|
||||
&-separator {
|
||||
width: 1px;
|
||||
background: hsl(0,0%,97%);
|
||||
margin: 0 10px;
|
||||
|
@ -9378,9 +9398,9 @@ output {
|
|||
}
|
||||
}
|
||||
|
||||
.action-block,
|
||||
.action-controls,
|
||||
.action-row {
|
||||
&-block,
|
||||
&-controls,
|
||||
&-row {
|
||||
padding: 10px;
|
||||
|
||||
h2:first-child,
|
||||
|
@ -9389,22 +9409,26 @@ output {
|
|||
}
|
||||
}
|
||||
|
||||
.action-block {
|
||||
&-block {
|
||||
&.action-block--flex {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.action-row {
|
||||
&-row {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: hsl(0,0%,60%);
|
||||
margin-top: 0;
|
||||
|
||||
+ .btn--text {
|
||||
margin-bottom: -4px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-label {
|
||||
&-label {
|
||||
background: hsl(197,20%,93%);
|
||||
border: 1px solid hsl(197,20%,88%);
|
||||
align-self: flex-start;
|
||||
|
@ -9415,7 +9439,7 @@ output {
|
|||
cursor: default;
|
||||
}
|
||||
|
||||
.action-flow-icon {
|
||||
&-flow-icon {
|
||||
width: 15px;
|
||||
height: 24px;
|
||||
margin-top: 16px; /* compensate for h3 height */
|
||||
|
@ -9424,7 +9448,7 @@ output {
|
|||
fill: hsl(198,17%,89%);
|
||||
}
|
||||
|
||||
.action-controls {
|
||||
&-controls {
|
||||
display: flex;
|
||||
@include bidi-style(margin-left, auto, margin-right, 0);
|
||||
align-self: flex-end;
|
||||
|
|
|
@ -19,12 +19,11 @@ class ChannelsEmailController < ApplicationController
|
|||
end
|
||||
next
|
||||
end
|
||||
assets = channel.assets(assets)
|
||||
if channel.area == 'Email::Account'
|
||||
account_channel_ids.push channel.id
|
||||
assets = channel.assets(assets)
|
||||
elsif channel.area == 'Email::Notification' && channel.active
|
||||
notification_channel_ids.push channel.id
|
||||
assets = channel.assets(assets)
|
||||
end
|
||||
end
|
||||
EmailAddress.all.each do |email_address|
|
||||
|
|
100
app/controllers/channels_google_controller.rb
Normal file
100
app/controllers/channels_google_controller.rb
Normal file
|
@ -0,0 +1,100 @@
|
|||
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
|
||||
|
||||
class ChannelsGoogleController < ApplicationController
|
||||
prepend_before_action -> { authentication_check && authorize! }
|
||||
|
||||
def index
|
||||
system_online_service = Setting.get('system_online_service')
|
||||
|
||||
assets = {}
|
||||
external_credential_ids = []
|
||||
ExternalCredential.where(name: 'google').each do |external_credential|
|
||||
assets = external_credential.assets(assets)
|
||||
external_credential_ids.push external_credential.id
|
||||
end
|
||||
|
||||
channel_ids = []
|
||||
Channel.where(area: 'Google::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('google'),
|
||||
}
|
||||
end
|
||||
|
||||
def enable
|
||||
channel = Channel.find_by(id: params[:id], area: 'Google::Account')
|
||||
channel.active = true
|
||||
channel.save!
|
||||
render json: {}
|
||||
end
|
||||
|
||||
def disable
|
||||
channel = Channel.find_by(id: params[:id], area: 'Google::Account')
|
||||
channel.active = false
|
||||
channel.save!
|
||||
render json: {}
|
||||
end
|
||||
|
||||
def destroy
|
||||
channel = Channel.find_by(id: params[:id], area: 'Google::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: 'Google::Account')
|
||||
channel.group_id = params[:group_id]
|
||||
channel.save!
|
||||
render json: {}
|
||||
end
|
||||
|
||||
def inbound
|
||||
channel = Channel.find_by(id: params[:id], area: 'Google::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: 'Google::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
|
14
app/jobs/imap_authentication_migration_cleanup_job.rb
Normal file
14
app/jobs/imap_authentication_migration_cleanup_job.rb
Normal file
|
@ -0,0 +1,14 @@
|
|||
class ImapAuthenticationMigrationCleanupJob < ApplicationJob
|
||||
include HasActiveJobLock
|
||||
|
||||
def perform
|
||||
Channel.where(area: 'Google::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
|
||||
|
||||
channel.options.delete(:backup_imap_classic)
|
||||
channel.save!
|
||||
end
|
||||
end
|
||||
end
|
|
@ -12,6 +12,8 @@ class Channel < ApplicationModel
|
|||
after_update :email_address_check
|
||||
after_destroy :email_address_check
|
||||
|
||||
after_initialize :refresh_xoaut2!
|
||||
|
||||
# rubocop:disable Style/ClassVars
|
||||
@@channel_stream = {}
|
||||
@@channel_stream_started_till_at = {}
|
||||
|
@ -40,7 +42,6 @@ fetch one account
|
|||
=end
|
||||
|
||||
def fetch(force = false)
|
||||
|
||||
adapter = options[:adapter]
|
||||
adapter_options = options
|
||||
if options[:inbound] && options[:inbound][:adapter]
|
||||
|
@ -336,6 +337,21 @@ get instance of channel driver
|
|||
self.class.driver_class(options[:adapter])
|
||||
end
|
||||
|
||||
def refresh_xoaut2!
|
||||
return if options.dig(:auth, :type) != 'XOAUTH2'
|
||||
|
||||
result = ExternalCredential.refresh_token(options[:auth][:provider], options[:auth])
|
||||
|
||||
options[:auth] = result
|
||||
options[:inbound][:options][:password] = result[:access_token]
|
||||
options[:outbound][:options][:password] = result[:access_token]
|
||||
|
||||
save!
|
||||
rescue StandardError => e
|
||||
logger.error e
|
||||
raise "Failed to refresh XOAUTH2 access_token of provider '#{options[:auth][:provider]}'! #{e.inspect}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def email_address_check
|
||||
|
|
|
@ -57,6 +57,17 @@ example
|
|||
password: 'xxx',
|
||||
keep_on_server: true,
|
||||
}
|
||||
|
||||
OR
|
||||
|
||||
params = {
|
||||
host: 'imap.gmail.com',
|
||||
user: 'xxx@gmail.com',
|
||||
password: 'xxx',
|
||||
keep_on_server: true,
|
||||
auth_type: 'XOAUTH2'
|
||||
}
|
||||
|
||||
channel = Channel.last
|
||||
instance = Channel::Driver::Imap.new
|
||||
result = instance.fetch(params, channel, 'verify')
|
||||
|
@ -95,7 +106,7 @@ example
|
|||
folder = options[:folder]
|
||||
end
|
||||
|
||||
Rails.logger.info "fetching imap (#{options[:host]}/#{options[:user]} port=#{port},ssl=#{ssl},starttls=#{starttls},folder=#{folder},keep_on_server=#{keep_on_server})"
|
||||
Rails.logger.info "fetching imap (#{options[:host]}/#{options[:user]} port=#{port},ssl=#{ssl},starttls=#{starttls},folder=#{folder},keep_on_server=#{keep_on_server},auth_type=#{options.fetch(:auth_type, 'LOGIN')})"
|
||||
|
||||
# on check, reduce open_timeout to have faster probing
|
||||
check_type_timeout = 45
|
||||
|
@ -111,7 +122,11 @@ example
|
|||
end
|
||||
|
||||
timeout(check_type_timeout) do
|
||||
@imap.login(options[:user], options[:password].dup&.force_encoding('ascii-8bit'))
|
||||
if options[:auth_type].present?
|
||||
@imap.authenticate(options[:auth_type], options[:user], options[:password])
|
||||
else
|
||||
@imap.login(options[:user], options[:password].dup&.force_encoding('ascii-8bit'))
|
||||
end
|
||||
end
|
||||
|
||||
timeout(check_type_timeout) do
|
||||
|
@ -481,7 +496,6 @@ returns
|
|||
=end
|
||||
|
||||
def channel_has_changed?(channel)
|
||||
Rails.logger.info "CC #{channel.id} CHECK."
|
||||
current_channel = Channel.find_by(id: channel.id)
|
||||
if !current_channel
|
||||
Rails.logger.info "Channel with id #{channel.id} is deleted in the meantime. Stop fetching."
|
||||
|
|
|
@ -13,7 +13,7 @@ class Channel::Driver::Smtp
|
|||
openssl_verify_mode: 'none', # optional
|
||||
user: 'someuser',
|
||||
password: 'somepass'
|
||||
authentication: nil, # nil, autodetection - to use certain schema use 'plain', 'login' or 'cram_md5'
|
||||
authentication: nil, # nil, autodetection - to use certain schema use 'plain', 'login', 'xoauth2' or 'cram_md5'
|
||||
},
|
||||
mail_attributes,
|
||||
notification
|
||||
|
|
|
@ -30,7 +30,7 @@ check and if channel not exists reset configured channels for email addresses
|
|||
EmailAddress.all.each do |email_address|
|
||||
|
||||
# set to active if channel exists
|
||||
if email_address.channel_id && Channel.find_by(id: email_address.channel_id)
|
||||
if email_address.channel_id && Channel.exists?(email_address.channel_id)
|
||||
if !email_address.active
|
||||
email_address.save!
|
||||
end
|
||||
|
|
|
@ -27,6 +27,11 @@ class ExternalCredential < ApplicationModel
|
|||
"#{Setting.get('http_type')}://#{Setting.get('fqdn')}/#channels/#{provider}/#{channel_id}"
|
||||
end
|
||||
|
||||
def self.refresh_token(provider, params)
|
||||
backend = ExternalCredential.load_backend(provider)
|
||||
backend.refresh_token(params)
|
||||
end
|
||||
|
||||
def self.load_backend(provider)
|
||||
adapter = "ExternalCredential::#{provider.camelcase}"
|
||||
require_dependency adapter.to_filename.to_s
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
class Controllers::ChannelsGoogleControllerPolicy < Controllers::ApplicationControllerPolicy
|
||||
default_permit!('admin.channel_google')
|
||||
end
|
|
@ -6,7 +6,7 @@ class Controllers::ExternalCredentialsControllerPolicy < Controllers::Applicatio
|
|||
|
||||
def provider_name
|
||||
@provider_name ||= begin
|
||||
if record.params[:id].present?
|
||||
if record.params[:id].present? && ExternalCredential.exists?(record.params[:id])
|
||||
ExternalCredential.find(record.params[:id]).name
|
||||
else
|
||||
record.params[:provider] || record.params[:name]
|
||||
|
|
12
config/routes/google.rb
Normal file
12
config/routes/google.rb
Normal file
|
@ -0,0 +1,12 @@
|
|||
Zammad::Application.routes.draw do
|
||||
api_path = Rails.configuration.api_path
|
||||
|
||||
match api_path + '/channels_google', to: 'channels_google#index', via: :get
|
||||
match api_path + '/channels_google_disable', to: 'channels_google#disable', via: :post
|
||||
match api_path + '/channels_google_enable', to: 'channels_google#enable', via: :post
|
||||
match api_path + '/channels_google', to: 'channels_google#destroy', via: :delete
|
||||
match api_path + '/channels_google_group/:id', to: 'channels_google#group', via: :post
|
||||
match api_path + '/channels_google_inbound/:id', to: 'channels_google#inbound', via: :post
|
||||
match api_path + '/channels_google_rollback_migration', to: 'channels_google#rollback_migration', via: :post
|
||||
|
||||
end
|
|
@ -0,0 +1,17 @@
|
|||
class ImapAuthenticationMigrationCleanupJobScheduler < ActiveRecord::Migration[5.2]
|
||||
def change
|
||||
|
||||
# return if it's a new setup
|
||||
return if !Setting.find_by(name: 'system_init_done')
|
||||
|
||||
Scheduler.create_or_update(
|
||||
name: 'Delete obsolete classic IMAP backup.',
|
||||
method: 'ImapAuthenticationMigrationCleanupJob.perform_now',
|
||||
period: 1.day,
|
||||
prio: 2,
|
||||
active: true,
|
||||
updated_by_id: 1,
|
||||
created_by_id: 1,
|
||||
)
|
||||
end
|
||||
end
|
|
@ -181,6 +181,15 @@ Scheduler.create_if_not_exists(
|
|||
updated_by_id: 1,
|
||||
created_by_id: 1,
|
||||
)
|
||||
Scheduler.create_or_update(
|
||||
name: 'Delete obsolete classic IMAP backup.',
|
||||
method: 'ImapAuthenticationMigrationCleanupJob.perform_now',
|
||||
period: 1.day,
|
||||
prio: 2,
|
||||
active: true,
|
||||
updated_by_id: 1,
|
||||
created_by_id: 1,
|
||||
)
|
||||
Scheduler.create_if_not_exists(
|
||||
name: 'Import Jobs',
|
||||
method: 'ImportJob.start_registered',
|
||||
|
|
|
@ -172,7 +172,7 @@ get result of inbound probe
|
|||
|
||||
result = EmailHelper::Probe.inbound(
|
||||
adapter: 'imap',
|
||||
settings: {
|
||||
options: {
|
||||
host: 'imap.gmail.com',
|
||||
port: 993,
|
||||
ssl: true,
|
||||
|
|
268
lib/external_credential/google.rb
Normal file
268
lib/external_credential/google.rb
Normal file
|
@ -0,0 +1,268 @@
|
|||
class ExternalCredential::Google
|
||||
|
||||
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: 'google')
|
||||
raise Exceptions::UnprocessableEntity, 'No Google 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: 'google')
|
||||
raise Exceptions::UnprocessableEntity, 'No google 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 email from id_token!' if user_data[:email].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) != 'imap.gmail.com'
|
||||
next if channel.options.dig(:outbound, :options, :user) != user_data[:email]
|
||||
next if channel.options.dig(:outbound, :options, :host) != 'smtp.gmail.com'
|
||||
|
||||
migrate_channel = channel
|
||||
|
||||
break
|
||||
end
|
||||
|
||||
channel_options = {
|
||||
inbound: {
|
||||
adapter: 'imap',
|
||||
options: {
|
||||
auth_type: 'XOAUTH2',
|
||||
host: 'imap.gmail.com',
|
||||
ssl: true,
|
||||
user: user_data[:email],
|
||||
},
|
||||
},
|
||||
outbound: {
|
||||
adapter: 'smtp',
|
||||
options: {
|
||||
host: 'smtp.gmail.com',
|
||||
domain: 'gmail.com',
|
||||
port: 465,
|
||||
ssl: true,
|
||||
user: user_data[:email],
|
||||
authentication: 'xoauth2',
|
||||
},
|
||||
},
|
||||
auth: response.merge(
|
||||
provider: 'google',
|
||||
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: 'Google::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[:email],
|
||||
})
|
||||
|
||||
email_addresses.each do |email|
|
||||
next if !EmailAddress.exists?(email: email[:email])
|
||||
|
||||
raise Exceptions::UnprocessableEntity, "Duplicate email address or email alias #{email[:email]} found!"
|
||||
end
|
||||
|
||||
# create channel
|
||||
channel = Channel.create!(
|
||||
area: 'Google::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 = 'openid email profile https://mail.google.com/')
|
||||
|
||||
params = {
|
||||
'client_id' => client_id,
|
||||
'redirect_uri' => ExternalCredential.callback_url('google'),
|
||||
'scope' => scope,
|
||||
'response_type' => 'code',
|
||||
'access_type' => 'offline',
|
||||
'prompt' => 'consent',
|
||||
}
|
||||
|
||||
uri = URI::HTTPS.build(
|
||||
host: 'accounts.google.com',
|
||||
path: '/o/oauth2/auth',
|
||||
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('google'),
|
||||
}
|
||||
|
||||
uri = URI::HTTPS.build(
|
||||
host: 'accounts.google.com',
|
||||
path: '/o/oauth2/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] >= 50.minutes.ago
|
||||
|
||||
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: 'accounts.google.com',
|
||||
path: '/o/oauth2/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.googleapis.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
|
||||
|
||||
aliases
|
||||
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
|
|
@ -1,5 +1,9 @@
|
|||
FactoryBot.define do
|
||||
factory :channel do
|
||||
# ensure the `refresh_xoaut2!` `after_initialize` callback gets executed
|
||||
# https://stackoverflow.com/questions/5916162/problem-with-factory-girl-association-and-after-initialize#comment51639005_28057070
|
||||
initialize_with { new(attributes) }
|
||||
|
||||
area { 'Email::Dummy' }
|
||||
group { ::Group.find(1) }
|
||||
active { true }
|
||||
|
|
30
spec/jobs/imap_authentication_migration_cleanup_job_spec.rb
Normal file
30
spec/jobs/imap_authentication_migration_cleanup_job_spec.rb
Normal file
|
@ -0,0 +1,30 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe ImapAuthenticationMigrationCleanupJob, type: :job do
|
||||
|
||||
let(:migrated_at) { 8.days.ago }
|
||||
|
||||
let(:channel) do
|
||||
channel = build(:channel, area: 'Google::Account')
|
||||
channel.options[:backup_imap_classic] = {
|
||||
backuphere: 1,
|
||||
migrated_at: migrated_at
|
||||
}
|
||||
channel.save!
|
||||
|
||||
channel
|
||||
end
|
||||
|
||||
it 'deletes obsolete classic IMAP backup' do
|
||||
expect { described_class.perform_now }.to change { channel.reload.options }
|
||||
end
|
||||
|
||||
context 'recently migrated' do
|
||||
|
||||
let(:migrated_at) { Time.zone.now }
|
||||
|
||||
it 'keeps classic IMAP backup untouched' do
|
||||
expect { described_class.perform_now }.not_to change { channel.reload.options }
|
||||
end
|
||||
end
|
||||
end
|
449
spec/lib/external_credential/google_spec.rb
Normal file
449
spec/lib/external_credential/google_spec.rb
Normal file
|
@ -0,0 +1,449 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe ExternalCredential::Google do
|
||||
|
||||
let(:token_url) { 'https://accounts.google.com/o/oauth2/token' }
|
||||
let(:alias_url) { 'https://www.googleapis.com/gmail/v1/users/me/settings/sendAs' }
|
||||
let(:authorize_url) { "https://accounts.google.com/o/oauth2/auth?access_type=offline&client_id=#{client_id}&prompt=consent&redirect_uri=http%3A%2F%2Fzammad.example.com%2Fapi%2Fv1%2Fexternal_credentials%2Fgoogle%2Fcallback&response_type=code&scope=openid+email+profile+https%3A%2F%2Fmail.google.com%2F" }
|
||||
|
||||
let(:id_token) { 'eyJhbGciOiJSUzI1NiIsImtpZCI6Inh4eHh4eDkwYmNkNzZhZWIyMDAyNmY2Yjc3MGNhYzIyMTc4MyIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhY2NvdW50cy5nb29nbGUuY29tIiwiYXpwIjoiMTMzNy1jdGYuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiIxMzM3LWN0Zi5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbSIsInN1YiI6IjAwMDg5MjkxMzM3NDkxMDAwMDAyIiwiaGQiOiJleGFtcGxlLmNvbSIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJhdF9oYXNoIjoibjAwd19fNVdwQ1RGNUcwMDBjbU56QSIsImlhdCI6MTU4NzczMjg5MywiZXhwIjoxNTg3NzM2NDkzfQ==' }
|
||||
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) { 'email profile openid https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://mail.google.com/' }
|
||||
let(:scope_stub) { 'https://mail.google.com/ https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile openid' }
|
||||
|
||||
let(:client_id) { '123' }
|
||||
let(:client_secret) { '345' }
|
||||
let(:authorization_code) { '567' }
|
||||
|
||||
let(:primary_email) { 'test@example.com' }
|
||||
let(:provider) { 'google' }
|
||||
let(:token_ttl) { 3599 }
|
||||
|
||||
let!(:alias_response_payload) do
|
||||
{
|
||||
'sendAs' => [
|
||||
{
|
||||
'sendAsEmail' => primary_email,
|
||||
'displayName' => '',
|
||||
'replyToAddress' => '',
|
||||
'signature' => '',
|
||||
'isPrimary' => true,
|
||||
'isDefault' => true
|
||||
},
|
||||
{
|
||||
'sendAsEmail' => 'alias1@example.com',
|
||||
'displayName' => 'alias1',
|
||||
'replyToAddress' => '',
|
||||
'signature' => '',
|
||||
'verificationStatus' => 'accepted',
|
||||
},
|
||||
{
|
||||
'sendAsEmail' => 'alias2@example.com',
|
||||
'displayName' => 'alias2',
|
||||
'replyToAddress' => '',
|
||||
'signature' => '',
|
||||
'verificationStatus' => 'accepted',
|
||||
},
|
||||
{
|
||||
'sendAsEmail' => 'alias3@example.com',
|
||||
'displayName' => 'alias3',
|
||||
'replyToAddress' => '',
|
||||
'signature' => '',
|
||||
'verificationStatus' => 'accepted',
|
||||
},
|
||||
]
|
||||
}
|
||||
end
|
||||
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: {})
|
||||
stub_request(:get, alias_url).to_return(status: 200, body: alias_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' => 'imap.gmail.com',
|
||||
'ssl' => true,
|
||||
'user' => primary_email,
|
||||
)
|
||||
),
|
||||
'outbound' => a_hash_including(
|
||||
'options' => a_hash_including(
|
||||
'authentication' => 'xoauth2',
|
||||
'host' => 'smtp.gmail.com',
|
||||
'domain' => 'gmail.com',
|
||||
'port' => 465,
|
||||
'ssl' => true,
|
||||
'user' => primary_email,
|
||||
)
|
||||
),
|
||||
'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: {})
|
||||
stub_request(:get, alias_url).to_return(status: 200, body: alias_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
|
||||
google = create(:external_credential, name: provider, credentials: { client_id: client_id, client_secret: client_secret } )
|
||||
request = described_class.request_account_to_link(google.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 Google 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 '.user_aliases' do
|
||||
|
||||
let(:response_status) { 200 }
|
||||
let(:response_payload) { alias_response_payload }
|
||||
|
||||
let(:token) do
|
||||
{
|
||||
access_token: access_token,
|
||||
token_type: 'Bearer'
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:get, alias_url).to_return(status: response_status, body: response_payload&.to_json, headers: {})
|
||||
end
|
||||
|
||||
it 'returns the google user email aliases' do
|
||||
result = described_class.user_aliases(token)
|
||||
expect(result).to eq([
|
||||
{ realname: 'alias1', email: 'alias1@example.com' },
|
||||
{ realname: 'alias2', email: 'alias2@example.com' },
|
||||
{ realname: 'alias3', email: 'alias3@example.com' }
|
||||
])
|
||||
end
|
||||
|
||||
context 'API errors' do
|
||||
|
||||
context '401 Unauthorized' do
|
||||
let(:response_status) { 401 }
|
||||
let(:response_payload) do
|
||||
{
|
||||
"error": {
|
||||
"code": 401,
|
||||
"message": 'Invalid Credentials',
|
||||
"errors": [
|
||||
{
|
||||
"locationType": 'header',
|
||||
"domain": 'global',
|
||||
"message": 'Invalid Credentials',
|
||||
"reason": 'authError',
|
||||
"location": 'Authorization'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'raises an exception' do
|
||||
expect do
|
||||
described_class.user_aliases(token)
|
||||
end.to raise_error(RuntimeError, 'Request failed! ERROR: Invalid Credentials')
|
||||
end
|
||||
end
|
||||
|
||||
context '500 Internal Server Error' do
|
||||
let(:response_status) { 500 }
|
||||
let(:response_payload) { nil }
|
||||
|
||||
it 'raises an exception' do
|
||||
expect do
|
||||
described_class.user_aliases(token)
|
||||
end.to raise_error(RuntimeError, 'Request failed! (code: 500)')
|
||||
end
|
||||
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(primary_email)
|
||||
end
|
||||
end
|
||||
end
|
65
spec/requests/integration/gmail_spec.rb
Normal file
65
spec/requests/integration/gmail_spec.rb
Normal file
|
@ -0,0 +1,65 @@
|
|||
require 'rails_helper'
|
||||
RSpec.describe 'Gmail XOAUTH2' do # rubocop:disable RSpec/DescribeClass
|
||||
let!(:channel) do
|
||||
create(:channel,
|
||||
area: 'Google::Account',
|
||||
options: {
|
||||
'inbound' => {
|
||||
'adapter' => 'imap',
|
||||
'options' => {
|
||||
'auth_type' => 'XOAUTH2',
|
||||
'host' => 'imap.gmail.com',
|
||||
'ssl' => true,
|
||||
'user' => ENV['GMAIL_USER'],
|
||||
'folder' => '',
|
||||
'keep_on_server' => false,
|
||||
}
|
||||
},
|
||||
'outbound' => {
|
||||
'adapter' => 'smtp',
|
||||
'options' => {
|
||||
'host' => 'smtp.gmail.com',
|
||||
'domain' => 'gmail.com',
|
||||
'port' => 465,
|
||||
'ssl' => true,
|
||||
'user' => ENV['GMAIL_USER'],
|
||||
'authentication' => 'xoauth2',
|
||||
}
|
||||
},
|
||||
'auth' => {
|
||||
'type' => 'XOAUTH2',
|
||||
'provider' => 'google',
|
||||
'access_token' => 'xxx',
|
||||
'expires_in' => 3599,
|
||||
'refresh_token' => ENV['GMAIL_REFRESH_TOKEN'],
|
||||
'scope' => 'https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://mail.google.com/ openid',
|
||||
'token_type' => 'Bearer',
|
||||
'id_token' => 'xxx',
|
||||
'created_at' => 30.days.ago,
|
||||
'client_id' => ENV['GMAIL_CLIENT_ID'],
|
||||
'client_secret' => ENV['GMAIL_CLIENT_SECRET'],
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
before do
|
||||
required_envs = %w[GMAIL_REFRESH_TOKEN GMAIL_CLIENT_ID GMAIL_CLIENT_SECRET GMAIL_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['GMAIL_USER'], "test gmail oauth unittest #{Random.new_seed}")
|
||||
expect(result[:result]).to eq('ok')
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in a new issue