2021-06-01 12:20:20 +00:00
|
|
|
# Copyright (C) 2012-2021 Zammad Foundation, http://zammad-foundation.org/
|
|
|
|
|
2020-10-27 14:05:10 +00:00
|
|
|
class ExternalCredential::Microsoft365
|
2020-10-02 12:46:19 +00:00
|
|
|
|
|
|
|
def self.app_verify(params)
|
|
|
|
request_account_to_link(params, false)
|
|
|
|
params
|
|
|
|
end
|
|
|
|
|
|
|
|
def self.request_account_to_link(credentials = {}, app_required = true)
|
2020-10-27 14:05:10 +00:00
|
|
|
external_credential = ExternalCredential.find_by(name: 'microsoft365')
|
2021-11-15 15:58:19 +00:00
|
|
|
raise Exceptions::UnprocessableEntity, __('No Microsoft365 app configured!') if !external_credential && app_required
|
2020-10-02 12:46:19 +00:00
|
|
|
|
|
|
|
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
|
2021-03-08 13:13:15 +00:00
|
|
|
# client_tenant may be empty. Set only if key is nonexistant at all
|
|
|
|
if !credentials.key? :client_tenant
|
|
|
|
credentials[:client_tenant] = external_credential.credentials['client_tenant']
|
|
|
|
end
|
2020-10-02 12:46:19 +00:00
|
|
|
end
|
|
|
|
|
2021-11-15 15:58:19 +00:00
|
|
|
raise Exceptions::UnprocessableEntity, __('No client_id param!') if credentials[:client_id].blank?
|
|
|
|
raise Exceptions::UnprocessableEntity, __('No client_secret param!') if credentials[:client_secret].blank?
|
2020-10-02 12:46:19 +00:00
|
|
|
|
2021-03-08 13:13:15 +00:00
|
|
|
authorize_url = generate_authorize_url(credentials)
|
2020-10-02 12:46:19 +00:00
|
|
|
|
|
|
|
{
|
|
|
|
authorize_url: authorize_url,
|
|
|
|
}
|
|
|
|
end
|
|
|
|
|
|
|
|
def self.link_account(_request_token, params)
|
2020-10-27 14:05:10 +00:00
|
|
|
external_credential = ExternalCredential.find_by(name: 'microsoft365')
|
2021-11-15 15:58:19 +00:00
|
|
|
raise Exceptions::UnprocessableEntity, __('No Microsoft365 app configured!') if !external_credential
|
|
|
|
raise Exceptions::UnprocessableEntity, __('No code for session found!') if !params[:code]
|
2020-10-02 12:46:19 +00:00
|
|
|
|
2021-03-08 13:13:15 +00:00
|
|
|
response = authorize_tokens(external_credential.credentials, params[:code])
|
2020-10-02 12:46:19 +00:00
|
|
|
%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])
|
2021-11-15 15:58:19 +00:00
|
|
|
raise Exceptions::UnprocessableEntity, __('Unable to extract user preferred_username from id_token!') if user_data[:preferred_username].blank?
|
2020-10-02 12:46:19 +00:00
|
|
|
|
|
|
|
channel_options = {
|
|
|
|
inbound: {
|
|
|
|
adapter: 'imap',
|
|
|
|
options: {
|
|
|
|
auth_type: 'XOAUTH2',
|
|
|
|
host: 'outlook.office365.com',
|
|
|
|
ssl: true,
|
|
|
|
user: user_data[:preferred_username],
|
|
|
|
},
|
|
|
|
},
|
|
|
|
outbound: {
|
|
|
|
adapter: 'smtp',
|
|
|
|
options: {
|
|
|
|
host: 'smtp.office365.com',
|
|
|
|
port: 587,
|
|
|
|
user: user_data[:preferred_username],
|
|
|
|
authentication: 'xoauth2',
|
|
|
|
},
|
|
|
|
},
|
|
|
|
auth: response.merge(
|
2020-10-27 14:05:10 +00:00
|
|
|
provider: 'microsoft365',
|
2020-10-02 12:46:19 +00:00
|
|
|
type: 'XOAUTH2',
|
|
|
|
client_id: external_credential.credentials[:client_id],
|
|
|
|
client_secret: external_credential.credentials[:client_secret],
|
2021-03-08 13:13:15 +00:00
|
|
|
client_tenant: external_credential.credentials[:client_tenant],
|
2020-10-02 12:46:19 +00:00
|
|
|
),
|
|
|
|
}
|
|
|
|
|
2020-11-20 13:58:57 +00:00
|
|
|
if params[:channel_id]
|
|
|
|
existing_channel = Channel.where(area: 'Microsoft365::Account').find(params[:channel_id])
|
|
|
|
|
|
|
|
existing_channel.update!(
|
|
|
|
options: channel_options,
|
|
|
|
)
|
|
|
|
|
|
|
|
existing_channel.refresh_xoauth2!
|
|
|
|
|
|
|
|
return existing_channel
|
|
|
|
end
|
|
|
|
|
|
|
|
migrate_channel = nil
|
|
|
|
Channel.where(area: 'Email::Account').find_each do |channel|
|
|
|
|
next if channel.options.dig(:inbound, :options, :user) != user_data[:email]
|
|
|
|
next if channel.options.dig(:inbound, :options, :host) != 'outlook.office365.com'
|
|
|
|
next if channel.options.dig(:outbound, :options, :user) != user_data[:email]
|
|
|
|
next if channel.options.dig(:outbound, :options, :host) != 'smtp.office365.com'
|
|
|
|
|
|
|
|
migrate_channel = channel
|
|
|
|
|
|
|
|
break
|
|
|
|
end
|
|
|
|
|
2020-10-02 12:46:19 +00:00
|
|
|
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(
|
2020-10-27 14:05:10 +00:00
|
|
|
area: 'Microsoft365::Account',
|
2020-10-02 12:46:19 +00:00
|
|
|
options: channel_options.merge(backup_imap_classic: backup),
|
|
|
|
last_log_in: nil,
|
|
|
|
last_log_out: nil,
|
|
|
|
)
|
|
|
|
|
|
|
|
return migrate_channel
|
|
|
|
end
|
|
|
|
|
2020-10-07 13:21:21 +00:00
|
|
|
email_addresses = [
|
|
|
|
{
|
|
|
|
realname: "#{Setting.get('product_name')} Support",
|
|
|
|
email: user_data[:preferred_username],
|
|
|
|
},
|
|
|
|
]
|
2020-10-02 12:46:19 +00:00
|
|
|
|
|
|
|
email_addresses.each do |email|
|
2020-10-07 13:21:21 +00:00
|
|
|
next if !EmailAddress.exists?(email: email[:email])
|
2020-10-02 12:46:19 +00:00
|
|
|
|
2020-10-07 13:21:21 +00:00
|
|
|
raise Exceptions::UnprocessableEntity, "Duplicate email address or email alias #{email[:email]} found!"
|
2020-10-02 12:46:19 +00:00
|
|
|
end
|
|
|
|
|
|
|
|
# create channel
|
|
|
|
channel = Channel.create!(
|
2020-10-27 14:05:10 +00:00
|
|
|
area: 'Microsoft365::Account',
|
2020-10-02 12:46:19 +00:00
|
|
|
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
|
|
|
|
|
2021-03-08 13:13:15 +00:00
|
|
|
def self.generate_authorize_url(credentials, scope = 'https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send offline_access openid profile email')
|
2020-10-02 12:46:19 +00:00
|
|
|
params = {
|
2021-03-08 13:13:15 +00:00
|
|
|
'client_id' => credentials[:client_id],
|
2020-10-27 14:05:10 +00:00
|
|
|
'redirect_uri' => ExternalCredential.callback_url('microsoft365'),
|
2020-10-02 12:46:19 +00:00
|
|
|
'scope' => scope,
|
|
|
|
'response_type' => 'code',
|
|
|
|
'access_type' => 'offline',
|
|
|
|
'prompt' => 'consent',
|
|
|
|
}
|
|
|
|
|
2021-03-08 13:13:15 +00:00
|
|
|
tenant = credentials[:client_tenant].presence || 'common'
|
|
|
|
|
2020-10-02 12:46:19 +00:00
|
|
|
uri = URI::HTTPS.build(
|
|
|
|
host: 'login.microsoftonline.com',
|
2021-03-08 13:13:15 +00:00
|
|
|
path: "/#{tenant}/oauth2/v2.0/authorize",
|
2020-10-02 12:46:19 +00:00
|
|
|
query: params.to_query
|
|
|
|
)
|
|
|
|
|
|
|
|
uri.to_s
|
|
|
|
end
|
|
|
|
|
2021-03-08 13:13:15 +00:00
|
|
|
def self.authorize_tokens(credentials, authorization_code)
|
|
|
|
uri = authorize_tokens_uri(credentials[:client_tenant])
|
|
|
|
params = authorize_tokens_params(credentials, authorization_code)
|
2020-10-02 12:46:19 +00:00
|
|
|
|
|
|
|
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
|
|
|
|
|
2021-03-08 13:13:15 +00:00
|
|
|
def self.authorize_tokens_params(credentials, authorization_code)
|
|
|
|
{
|
2021-05-18 11:27:02 +00:00
|
|
|
client_secret: credentials[:client_secret],
|
|
|
|
code: authorization_code,
|
|
|
|
grant_type: 'authorization_code',
|
|
|
|
client_id: credentials[:client_id],
|
|
|
|
redirect_uri: ExternalCredential.callback_url('microsoft365'),
|
2020-10-02 12:46:19 +00:00
|
|
|
}
|
2021-03-08 13:13:15 +00:00
|
|
|
end
|
|
|
|
|
|
|
|
def self.authorize_tokens_uri(tenant)
|
|
|
|
URI::HTTPS.build(
|
2020-10-02 12:46:19 +00:00
|
|
|
host: 'login.microsoftonline.com',
|
2021-03-08 13:13:15 +00:00
|
|
|
path: "/#{tenant.presence || 'common'}/oauth2/v2.0/token",
|
2020-10-02 12:46:19 +00:00
|
|
|
)
|
2021-03-08 13:13:15 +00:00
|
|
|
end
|
|
|
|
|
|
|
|
def self.refresh_token(token)
|
|
|
|
return token if token[:created_at] >= Time.zone.now - 50.minutes
|
|
|
|
|
|
|
|
params = refresh_token_params(token)
|
|
|
|
uri = refresh_token_uri(token)
|
2020-10-02 12:46:19 +00:00
|
|
|
|
|
|
|
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
|
|
|
|
|
2021-02-25 17:46:39 +00:00
|
|
|
token.merge(result.symbolize_keys).merge(
|
|
|
|
created_at: Time.zone.now,
|
|
|
|
)
|
2020-10-02 12:46:19 +00:00
|
|
|
end
|
|
|
|
|
2021-03-08 13:13:15 +00:00
|
|
|
def self.refresh_token_params(credentials)
|
|
|
|
{
|
2021-05-18 11:27:02 +00:00
|
|
|
client_id: credentials[:client_id],
|
|
|
|
client_secret: credentials[:client_secret],
|
|
|
|
refresh_token: credentials[:refresh_token],
|
|
|
|
grant_type: 'refresh_token',
|
2021-03-08 13:13:15 +00:00
|
|
|
}
|
|
|
|
end
|
|
|
|
|
|
|
|
def self.refresh_token_uri(credentials)
|
|
|
|
tenant = credentials[:client_tenant].presence || 'common'
|
|
|
|
|
|
|
|
URI::HTTPS.build(
|
|
|
|
host: 'login.microsoftonline.com',
|
|
|
|
path: "/#{tenant}/oauth2/v2.0/token",
|
|
|
|
)
|
|
|
|
end
|
|
|
|
|
2020-10-02 12:46:19 +00:00
|
|
|
def self.user_info(id_token)
|
|
|
|
split = id_token.split('.')[1]
|
|
|
|
return if split.blank?
|
|
|
|
|
|
|
|
JSON.parse(Base64.decode64(split)).symbolize_keys
|
|
|
|
end
|
|
|
|
|
|
|
|
end
|