microsoft365.rb 8.8 KB

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