microsoft365.rb 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. # Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
  2. class ExternalCredential::Microsoft365
  3. def self.app_verify(params)
  4. request_account_to_link(params, false)
  5. params
  6. end
  7. def self.request_account_to_link(credentials = {}, app_required = true)
  8. external_credential = ExternalCredential.find_by(name: 'microsoft365')
  9. raise Exceptions::UnprocessableEntity, __('No Microsoft 365 app configured!') if !external_credential && app_required
  10. if external_credential
  11. if credentials[:client_id].blank?
  12. credentials[:client_id] = external_credential.credentials['client_id']
  13. end
  14. if credentials[:client_secret].blank?
  15. credentials[:client_secret] = external_credential.credentials['client_secret']
  16. end
  17. # client_tenant may be empty. Set only if key is nonexistant at all
  18. if !credentials.key? :client_tenant
  19. credentials[:client_tenant] = external_credential.credentials['client_tenant']
  20. end
  21. end
  22. raise Exceptions::UnprocessableEntity, __("The required parameter 'client_id' is missing.") if credentials[:client_id].blank?
  23. raise Exceptions::UnprocessableEntity, __("The required parameter 'client_secret' is missing.") if credentials[:client_secret].blank?
  24. authorize_url = generate_authorize_url(credentials)
  25. {
  26. authorize_url: authorize_url,
  27. }
  28. end
  29. def self.link_account(_request_token, params)
  30. # return to admin interface if admin Consent is in process and user clicks on "Back to app"
  31. return "#{Setting.get('http_type')}://#{Setting.get('fqdn')}/#channels/microsoft365/error/AADSTS65004" if params[:error_description].present? && params[:error_description].include?('AADSTS65004')
  32. external_credential = ExternalCredential.find_by(name: 'microsoft365')
  33. raise Exceptions::UnprocessableEntity, __('No Microsoft 365 app configured!') if !external_credential
  34. raise Exceptions::UnprocessableEntity, __("The required parameter 'code' is missing.") if !params[:code]
  35. response = authorize_tokens(external_credential.credentials, params[:code])
  36. %w[refresh_token access_token expires_in scope token_type id_token].each do |key|
  37. raise Exceptions::UnprocessableEntity, "No #{key} for authorization request found!" if response[key.to_sym].blank?
  38. end
  39. user_data = user_info(response[:id_token])
  40. raise Exceptions::UnprocessableEntity, __("The user's 'preferred_username' could not be extracted from 'id_token'.") if user_data[:preferred_username].blank?
  41. channel_options = {
  42. inbound: {
  43. adapter: 'imap',
  44. options: {
  45. auth_type: 'XOAUTH2',
  46. host: 'outlook.office365.com',
  47. ssl: 'ssl',
  48. ssl_verify: true,
  49. user: user_data[:preferred_username],
  50. },
  51. },
  52. outbound: {
  53. adapter: 'smtp',
  54. options: {
  55. host: 'smtp.office365.com',
  56. port: 587,
  57. user: user_data[:preferred_username],
  58. authentication: 'xoauth2',
  59. ssl_verify: true
  60. },
  61. },
  62. auth: response.merge(
  63. provider: 'microsoft365',
  64. type: 'XOAUTH2',
  65. client_id: external_credential.credentials[:client_id],
  66. client_secret: external_credential.credentials[:client_secret],
  67. client_tenant: external_credential.credentials[:client_tenant],
  68. ),
  69. }
  70. if params[:channel_id]
  71. existing_channel = Channel.where(area: 'Microsoft365::Account').find(params[:channel_id])
  72. # Check if current user of the channel is matching the user from the token.
  73. token_user = user_data[:preferred_username]&.downcase
  74. inbound_user = channel_user(existing_channel, :inbound)&.downcase
  75. outbound_user = channel_user(existing_channel, :outbound)&.downcase
  76. if (inbound_user.present? && inbound_user != token_user) || (outbound_user.present? && outbound_user != token_user)
  77. return "#{Setting.get('http_type')}://#{Setting.get('fqdn')}/#channels/microsoft365/error/user_mismatch"
  78. end
  79. channel_options[:inbound][:options][:folder] = existing_channel.options[:inbound][:options][:folder]
  80. channel_options[:inbound][:options][:keep_on_server] = existing_channel.options[:inbound][:options][:keep_on_server]
  81. existing_channel.update!(
  82. options: channel_options,
  83. )
  84. existing_channel.refresh_xoauth2!
  85. return existing_channel
  86. end
  87. migrate_channel = nil
  88. Channel.where(area: 'Email::Account').find_each do |channel|
  89. next if channel.options.dig(:inbound, :options, :host)&.downcase != 'outlook.office365.com'
  90. next if channel.options.dig(:outbound, :options, :host)&.downcase != 'smtp.office365.com'
  91. next if channel.options.dig(:outbound, :options, :user)&.downcase != user_data[:preferred_username].downcase && channel.options.dig(:outbound, :email)&.downcase != user_data[:preferred_username].downcase
  92. migrate_channel = channel
  93. break
  94. end
  95. if migrate_channel
  96. channel_options[:inbound][:options][:folder] = migrate_channel.options[:inbound][:options][:folder]
  97. channel_options[:inbound][:options][:keep_on_server] = migrate_channel.options[:inbound][:options][:keep_on_server]
  98. backup = {
  99. attributes: {
  100. area: migrate_channel.area,
  101. options: migrate_channel.options,
  102. last_log_in: migrate_channel.last_log_in,
  103. last_log_out: migrate_channel.last_log_out,
  104. status_in: migrate_channel.status_in,
  105. status_out: migrate_channel.status_out,
  106. },
  107. migrated_at: Time.zone.now,
  108. }
  109. migrate_channel.update(
  110. area: 'Microsoft365::Account',
  111. options: channel_options.merge(backup_imap_classic: backup),
  112. last_log_in: nil,
  113. last_log_out: nil,
  114. )
  115. return migrate_channel
  116. end
  117. email_addresses = [
  118. {
  119. name: "#{Setting.get('product_name')} Support",
  120. email: user_data[:preferred_username],
  121. },
  122. ]
  123. email_addresses.each do |email|
  124. next if !EmailAddress.exists?(email: email[:email])
  125. raise Exceptions::UnprocessableEntity, "Duplicate email address or email alias #{email[:email]} found!"
  126. end
  127. # create channel
  128. channel = Channel.create!(
  129. area: 'Microsoft365::Account',
  130. group_id: Group.first.id,
  131. options: channel_options,
  132. active: false,
  133. created_by_id: 1,
  134. updated_by_id: 1,
  135. )
  136. email_addresses.each do |user_alias|
  137. EmailAddress.create!(
  138. channel_id: channel.id,
  139. name: user_alias[:name],
  140. email: user_alias[:email],
  141. active: true,
  142. created_by_id: 1,
  143. updated_by_id: 1,
  144. )
  145. end
  146. channel
  147. end
  148. 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')
  149. params = {
  150. 'client_id' => credentials[:client_id],
  151. 'redirect_uri' => ExternalCredential.callback_url('microsoft365'),
  152. 'scope' => scope,
  153. 'response_type' => 'code',
  154. 'access_type' => 'offline',
  155. 'prompt' => credentials[:prompt] || 'login',
  156. }
  157. tenant = credentials[:client_tenant].presence || 'common'
  158. uri = URI::HTTPS.build(
  159. host: 'login.microsoftonline.com',
  160. path: "/#{tenant}/oauth2/v2.0/authorize",
  161. query: params.to_query
  162. )
  163. uri.to_s
  164. end
  165. def self.authorize_tokens(credentials, authorization_code)
  166. uri = authorize_tokens_uri(credentials[:client_tenant])
  167. params = authorize_tokens_params(credentials, authorization_code)
  168. response = UserAgent.post(uri.to_s, params)
  169. if response.code != 200 && response.body.blank?
  170. Rails.logger.error "Request failed! (code: #{response.code})"
  171. raise "Request failed! (code: #{response.code})"
  172. end
  173. result = JSON.parse(response.body)
  174. if result['error'] && response.code != 200
  175. Rails.logger.error "Request failed! ERROR: #{result['error']} (#{result['error_description']}, params: #{params.to_json})"
  176. raise "Request failed! ERROR: #{result['error']} (#{result['error_description']})"
  177. end
  178. result[:created_at] = Time.zone.now
  179. result.symbolize_keys
  180. end
  181. def self.authorize_tokens_params(credentials, authorization_code)
  182. {
  183. client_secret: credentials[:client_secret],
  184. code: authorization_code,
  185. grant_type: 'authorization_code',
  186. client_id: credentials[:client_id],
  187. redirect_uri: ExternalCredential.callback_url('microsoft365'),
  188. }
  189. end
  190. def self.authorize_tokens_uri(tenant)
  191. URI::HTTPS.build(
  192. host: 'login.microsoftonline.com',
  193. path: "/#{tenant.presence || 'common'}/oauth2/v2.0/token",
  194. )
  195. end
  196. def self.refresh_token(token)
  197. return token if token[:created_at] >= 50.minutes.ago
  198. params = refresh_token_params(token)
  199. uri = refresh_token_uri(token)
  200. response = UserAgent.post(uri.to_s, params)
  201. if response.code != 200 && response.body.blank?
  202. Rails.logger.error "Request failed! (code: #{response.code})"
  203. raise "Request failed! (code: #{response.code})"
  204. end
  205. result = JSON.parse(response.body)
  206. if result['error'] && response.code != 200
  207. Rails.logger.error "Request failed! ERROR: #{result['error']} (#{result['error_description']}, params: #{params.to_json})"
  208. raise "Request failed! ERROR: #{result['error']} (#{result['error_description']})"
  209. end
  210. token.merge(result.symbolize_keys).merge(
  211. created_at: Time.zone.now,
  212. )
  213. end
  214. def self.refresh_token_params(credentials)
  215. {
  216. client_id: credentials[:client_id],
  217. client_secret: credentials[:client_secret],
  218. refresh_token: credentials[:refresh_token],
  219. grant_type: 'refresh_token',
  220. }
  221. end
  222. def self.refresh_token_uri(credentials)
  223. tenant = credentials[:client_tenant].presence || 'common'
  224. URI::HTTPS.build(
  225. host: 'login.microsoftonline.com',
  226. path: "/#{tenant}/oauth2/v2.0/token",
  227. )
  228. end
  229. def self.user_info(id_token)
  230. split = id_token.split('.')[1]
  231. return if split.blank?
  232. JSON.parse(Base64.decode64(split)).symbolize_keys
  233. end
  234. def self.channel_user(channel, key)
  235. channel.options.dig(key.to_sym, :options, :user)
  236. end
  237. end