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:
Thorsten Eckel 2020-05-28 15:28:07 +02:00
parent f14607e265
commit 5f06c8c6b4
33 changed files with 1694 additions and 34 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

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.', '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>

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

View 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 %> &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">
<% 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>

View file

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

View file

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

View file

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

View file

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

View 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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

View file

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

View 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

View 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

View 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