microsoft365.rb 8.1 KB

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