From e8c9ba01894d82739151508b9761d8a5b459badb Mon Sep 17 00:00:00 2001 From: std-odoo Date: Wed, 2 Feb 2022 08:21:33 +0000 Subject: [PATCH 1/3] [ADD] fetchmail_outlook, microsoft_outlook: add OAuth authentication Purpose ======= As it has been done for Gmail, we want to add the OAuth authentication for the incoming / outgoing mail server. Specifications ============== The user has to create a project on Outlook and fill the credentials in Odoo. Once it's done, he can create an incoming / outgoing mail server. For the authentication flow is a bit different from Gmail. For Outlook the user is redirected to Outlook where he'll accept the permission. Once it's done, he's redirected again to the mail server form view and the tokens are automatically added on the mail server. Technical ========= There are 3 tokens used for the OAuth authentication. 1. The authentication code. This one is only used to get the refresh token and the first access token. It's the code returned by the user browser during the authentication flow. 2. The refresh token. This one will never change once the user is authenticated. This token is used to get new access token once they are expired. 3. The access token. Those tokens have an expiration date (1 hour) and are used in the XOAUTH2 protocol to authenticate the IMAP / SMTP connection. During the authentication process, we can also give a state that will be returned by the user browser. This state contains 1. The model and the ID of the mail server (as the same mixin manage both incoming and outgoing mail server) 2. A CSRF token which sign those values and is verified once the browser redirect the user to the Odoo database. This is useful so a malicious user can not send a link to an admin to disconnect the mail server. Task-2751996 Part-of: odoo/odoo#87040 --- .../models/fetchmail_server.py | 42 ++++ .../views/fetchmail_server_views.xml | 32 +++ addons/fetchmail_outlook/__init__.py | 4 + addons/fetchmail_outlook/__manifest__.py | 17 ++ addons/fetchmail_outlook/models/__init__.py | 4 + .../models/fetchmail_server.py | 58 ++++++ addons/fetchmail_outlook/tests/__init__.py | 4 + .../tests/test_fetchmail_outlook.py | 59 ++++++ .../views/fetchmail_server_views.xml | 47 +++++ addons/google_gmail/models/ir_mail_server.py | 42 ++++ addons/microsoft_outlook/__init__.py | 5 + addons/microsoft_outlook/__manifest__.py | 18 ++ .../microsoft_outlook/controllers/__init__.py | 4 + addons/microsoft_outlook/controllers/main.py | 76 +++++++ addons/microsoft_outlook/models/__init__.py | 7 + .../models/ir_mail_server.py | 62 ++++++ .../models/microsoft_outlook_mixin.py | 187 ++++++++++++++++++ .../models/res_config_settings.py | 11 ++ .../views/ir_mail_server_views.xml | 42 ++++ .../views/res_config_settings_views.xml | 35 ++++ addons/microsoft_outlook/views/templates.xml | 15 ++ 21 files changed, 771 insertions(+) create mode 100644 addons/fetchmail_gmail/models/fetchmail_server.py create mode 100644 addons/fetchmail_gmail/views/fetchmail_server_views.xml create mode 100644 addons/fetchmail_outlook/__init__.py create mode 100644 addons/fetchmail_outlook/__manifest__.py create mode 100644 addons/fetchmail_outlook/models/__init__.py create mode 100644 addons/fetchmail_outlook/models/fetchmail_server.py create mode 100644 addons/fetchmail_outlook/tests/__init__.py create mode 100644 addons/fetchmail_outlook/tests/test_fetchmail_outlook.py create mode 100644 addons/fetchmail_outlook/views/fetchmail_server_views.xml create mode 100644 addons/google_gmail/models/ir_mail_server.py create mode 100644 addons/microsoft_outlook/__init__.py create mode 100644 addons/microsoft_outlook/__manifest__.py create mode 100644 addons/microsoft_outlook/controllers/__init__.py create mode 100644 addons/microsoft_outlook/controllers/main.py create mode 100644 addons/microsoft_outlook/models/__init__.py create mode 100644 addons/microsoft_outlook/models/ir_mail_server.py create mode 100644 addons/microsoft_outlook/models/microsoft_outlook_mixin.py create mode 100644 addons/microsoft_outlook/models/res_config_settings.py create mode 100644 addons/microsoft_outlook/views/ir_mail_server_views.xml create mode 100644 addons/microsoft_outlook/views/res_config_settings_views.xml create mode 100644 addons/microsoft_outlook/views/templates.xml diff --git a/addons/fetchmail_gmail/models/fetchmail_server.py b/addons/fetchmail_gmail/models/fetchmail_server.py new file mode 100644 index 0000000000000..7dc14848b6e45 --- /dev/null +++ b/addons/fetchmail_gmail/models/fetchmail_server.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models, _ +from odoo.exceptions import UserError + + +class FetchmailServer(models.Model): + _name = 'fetchmail.server' + _inherit = ['fetchmail.server', 'google.gmail.mixin'] + + @api.constrains('use_google_gmail_service', 'type') + def _check_use_google_gmail_service(self): + if any(server.use_google_gmail_service and server.type != 'imap' for server in self): + raise UserError(_('Gmail authentication only supports IMAP server type.')) + + @api.onchange('use_google_gmail_service') + def _onchange_use_google_gmail_service(self): + """Set the default configuration for a IMAP Gmail server.""" + if self.use_google_gmail_service: + self.server = 'imap.gmail.com' + self.type = 'imap' + self.is_ssl = True + self.port = 993 + else: + self.google_gmail_authorization_code = False + self.google_gmail_refresh_token = False + self.google_gmail_access_token = False + self.google_gmail_access_token_expiration = False + + def _imap_login(self, connection): + """Authenticate the IMAP connection. + + If the mail server is Gmail, we use the OAuth2 authentication protocol. + """ + self.ensure_one() + if self.use_google_gmail_service: + auth_string = self._generate_oauth2_string(self.user, self.google_gmail_refresh_token) + connection.authenticate('XOAUTH2', lambda x: auth_string) + connection.select('INBOX') + else: + super(FetchmailServer, self)._imap_login(connection) diff --git a/addons/fetchmail_gmail/views/fetchmail_server_views.xml b/addons/fetchmail_gmail/views/fetchmail_server_views.xml new file mode 100644 index 0000000000000..404b5fe8bdeb4 --- /dev/null +++ b/addons/fetchmail_gmail/views/fetchmail_server_views.xml @@ -0,0 +1,32 @@ + + + + fetchmail.server.view.form.inherit.gmail + fetchmail.server + 100 + + + + + + + + + + + + {'required' : [('type', '!=', 'local'), ('use_google_gmail_service', '=', False), ('password', '!=', False)], 'invisible' : [('use_google_gmail_service', '=', True)]} + + + + diff --git a/addons/fetchmail_outlook/__init__.py b/addons/fetchmail_outlook/__init__.py new file mode 100644 index 0000000000000..dc5e6b693d19d --- /dev/null +++ b/addons/fetchmail_outlook/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/addons/fetchmail_outlook/__manifest__.py b/addons/fetchmail_outlook/__manifest__.py new file mode 100644 index 0000000000000..a77db1b1c2bf5 --- /dev/null +++ b/addons/fetchmail_outlook/__manifest__.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + "name": "Fetchmail Outlook", + "version": "1.0", + "category": "Hidden", + "description": "OAuth authentication for incoming Outlook mail server", + "depends": [ + "microsoft_outlook", + "fetchmail", + ], + "data": [ + "views/fetchmail_server_views.xml", + ], + "auto_install": True, +} diff --git a/addons/fetchmail_outlook/models/__init__.py b/addons/fetchmail_outlook/models/__init__.py new file mode 100644 index 0000000000000..07f7ac623b1fa --- /dev/null +++ b/addons/fetchmail_outlook/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import fetchmail_server diff --git a/addons/fetchmail_outlook/models/fetchmail_server.py b/addons/fetchmail_outlook/models/fetchmail_server.py new file mode 100644 index 0000000000000..e333d3f594840 --- /dev/null +++ b/addons/fetchmail_outlook/models/fetchmail_server.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import _, api, models +from odoo.exceptions import UserError + + +class FetchmailServer(models.Model): + """Add the Outlook OAuth authentication on the incoming mail servers.""" + + _name = 'fetchmail.server' + _inherit = ['fetchmail.server', 'microsoft.outlook.mixin'] + + _OUTLOOK_SCOPE = 'https://outlook.office.com/IMAP.AccessAsUser.All' + + @api.constrains('use_microsoft_outlook_service', 'type', 'password', 'is_ssl') + def _check_use_microsoft_outlook_service(self): + for server in self: + if not server.use_microsoft_outlook_service: + continue + + if server.type != 'imap': + raise UserError(_('Outlook mail server %r only supports IMAP server type.') % server.name) + + if server.password: + raise UserError(_( + 'Please leave the password field empty for Outlook mail server %r. ' + 'The OAuth process does not require it') + % server.name) + + if not server.is_ssl: + raise UserError(_('SSL is required .') % server.name) + + @api.onchange('use_microsoft_outlook_service') + def _onchange_use_microsoft_outlook_service(self): + """Set the default configuration for a IMAP Outlook server.""" + if self.use_microsoft_outlook_service: + self.server = 'imap.outlook.com' + self.type = 'imap' + self.is_ssl = True + self.port = 993 + else: + self.microsoft_outlook_refresh_token = False + self.microsoft_outlook_access_token = False + self.microsoft_outlook_access_token_expiration = False + + def _imap_login(self, connection): + """Authenticate the IMAP connection. + + If the mail server is Outlook, we use the OAuth2 authentication protocol. + """ + self.ensure_one() + if self.use_microsoft_outlook_service: + auth_string = self._generate_outlook_oauth2_string(self.user) + connection.authenticate('XOAUTH2', lambda x: auth_string) + connection.select('INBOX') + else: + super()._imap_login(connection) diff --git a/addons/fetchmail_outlook/tests/__init__.py b/addons/fetchmail_outlook/tests/__init__.py new file mode 100644 index 0000000000000..18ba95fa9ae66 --- /dev/null +++ b/addons/fetchmail_outlook/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_fetchmail_outlook diff --git a/addons/fetchmail_outlook/tests/test_fetchmail_outlook.py b/addons/fetchmail_outlook/tests/test_fetchmail_outlook.py new file mode 100644 index 0000000000000..35f512cbd6161 --- /dev/null +++ b/addons/fetchmail_outlook/tests/test_fetchmail_outlook.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import time + +from unittest.mock import ANY, Mock, patch + +from odoo.exceptions import ValidationError +from odoo.tests.common import SavepointCase + + +class TestFetchmailOutlook(SavepointCase): + + @patch('odoo.addons.fetchmail.models.fetchmail.IMAP4_SSL') + def test_connect(self, mock_imap): + """Test that the connect method will use the right + authentication method with the right arguments. + """ + mock_connection = Mock() + mock_imap.return_value = mock_connection + + mail_server = self.env['fetchmail.server'].create({ + 'name': 'Test server', + 'use_microsoft_outlook_service': True, + 'user': 'test@example.com', + 'microsoft_outlook_access_token': 'test_access_token', + 'microsoft_outlook_access_token_expiration': time.time() + 1000000, + 'password': '', + 'type': 'imap', + 'is_ssl': True, + }) + + mail_server.connect() + + mock_connection.authenticate.assert_called_once_with('XOAUTH2', ANY) + args = mock_connection.authenticate.call_args[0] + + self.assertEqual(args[1](None), 'user=test@example.com\1auth=Bearer test_access_token\1\1', + msg='Should use the right access token') + + mock_connection.select.assert_called_once_with('INBOX') + + def test_constraints(self): + """Test the constraints related to the Outlook mail server.""" + with self.assertRaises(ValidationError, msg='Should ensure that the password is empty'): + self.env['fetchmail.server'].create({ + 'name': 'Test server', + 'use_microsoft_outlook_service': True, + 'password': 'test', + 'type': 'imap', + }) + + with self.assertRaises(ValidationError, msg='Should ensure that the server type is IMAP'): + self.env['fetchmail.server'].create({ + 'name': 'Test server', + 'use_microsoft_outlook_service': True, + 'password': '', + 'type': 'pop', + }) diff --git a/addons/fetchmail_outlook/views/fetchmail_server_views.xml b/addons/fetchmail_outlook/views/fetchmail_server_views.xml new file mode 100644 index 0000000000000..7164f81959aab --- /dev/null +++ b/addons/fetchmail_outlook/views/fetchmail_server_views.xml @@ -0,0 +1,47 @@ + + + + fetchmail.server.view.form.inherit.outlook + fetchmail.server + 1000 + + + + + + + + + + +
+
+ + Outlook Token Valid + + + + +
+
+ + {} + +
+
+
diff --git a/addons/google_gmail/models/ir_mail_server.py b/addons/google_gmail/models/ir_mail_server.py new file mode 100644 index 0000000000000..93b51e24e3191 --- /dev/null +++ b/addons/google_gmail/models/ir_mail_server.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import base64 + +from odoo import models, api + + +class IrMailServer(models.Model): + """Represents an SMTP server, able to send outgoing emails, with SSL and TLS capabilities.""" + + _name = 'ir.mail_server' + _inherit = ['ir.mail_server', 'google.gmail.mixin'] + + @api.onchange('smtp_encryption') + def _onchange_encryption(self): + """Do not change the SMTP configuration if it's a Gmail server + + (e.g. the port which is already set)""" + if not self.use_google_gmail_service: + super()._onchange_encryption() + + @api.onchange('use_google_gmail_service') + def _onchange_use_google_gmail_service(self): + if self.use_google_gmail_service: + self.smtp_host = 'smtp.gmail.com' + self.smtp_encryption = 'starttls' + self.smtp_port = 587 + else: + self.google_gmail_authorization_code = False + self.google_gmail_refresh_token = False + self.google_gmail_access_token = False + self.google_gmail_access_token_expiration = False + + def _smtp_login(self, connection, smtp_user, smtp_password): + if len(self) == 1 and self.use_google_gmail_service: + auth_string = self._generate_oauth2_string(smtp_user, self.google_gmail_refresh_token) + oauth_param = base64.b64encode(auth_string.encode()).decode() + connection.ehlo() + connection.docmd('AUTH', 'XOAUTH2 %s' % oauth_param) + else: + super(IrMailServer, self)._smtp_login(connection, smtp_user, smtp_password) diff --git a/addons/microsoft_outlook/__init__.py b/addons/microsoft_outlook/__init__.py new file mode 100644 index 0000000000000..7d34c7c054abd --- /dev/null +++ b/addons/microsoft_outlook/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import controllers +from . import models diff --git a/addons/microsoft_outlook/__manifest__.py b/addons/microsoft_outlook/__manifest__.py new file mode 100644 index 0000000000000..9bd75607801e5 --- /dev/null +++ b/addons/microsoft_outlook/__manifest__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + "name": "Microsoft Outlook", + "version": "1.0", + "category": "Hidden", + "description": "Outlook support for outgoing mail servers", + "depends": [ + "mail", + ], + "data": [ + "views/ir_mail_server_views.xml", + "views/res_config_settings_views.xml", + "views/templates.xml", + ], + "auto_install": True, +} diff --git a/addons/microsoft_outlook/controllers/__init__.py b/addons/microsoft_outlook/controllers/__init__.py new file mode 100644 index 0000000000000..afcdc91f726cf --- /dev/null +++ b/addons/microsoft_outlook/controllers/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -* +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import main diff --git a/addons/microsoft_outlook/controllers/main.py b/addons/microsoft_outlook/controllers/main.py new file mode 100644 index 0000000000000..9206275a184ba --- /dev/null +++ b/addons/microsoft_outlook/controllers/main.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json +import logging +import werkzeug + +from werkzeug.exceptions import Forbidden + +from odoo import http +from odoo.exceptions import UserError +from odoo.http import request +from odoo.tools import consteq + +_logger = logging.getLogger(__name__) + + +class MicrosoftOutlookController(http.Controller): + @http.route('/microsoft_outlook/confirm', type='http', auth='user') + def microsoft_outlook_callback(self, code=None, state=None, error_description=None, **kwargs): + """Callback URL during the OAuth process. + + Outlook redirects the user browser to this endpoint with the authorization code. + We will fetch the refresh token and the access token thanks to this authorization + code and save those values on the given mail server. + """ + if not request.env.user.has_group('base.group_system'): + _logger.error('Microsoft Outlook: Non system user try to link an Outlook account.') + raise Forbidden() + + try: + state = json.loads(state) + model_name = state['model'] + rec_id = state['id'] + csrf_token = state['csrf_token'] + except Exception: + _logger.error('Microsoft Outlook: Wrong state value %r.', state) + raise Forbidden() + + if error_description: + return request.render('microsoft_outlook.microsoft_outlook_oauth_error', { + 'error': error_description, + 'model_name': model_name, + 'rec_id': rec_id, + }) + + model = request.env[model_name] + + if not issubclass(type(model), request.env.registry['microsoft.outlook.mixin']): + # The model must inherits from the "microsoft.outlook.mixin" mixin + raise Forbidden() + + record = model.browse(rec_id).exists() + if not record: + raise Forbidden() + + if not csrf_token or not consteq(csrf_token, record._get_outlook_csrf_token()): + _logger.error('Microsoft Outlook: Wrong CSRF token during Outlook authentication.') + raise Forbidden() + + try: + refresh_token, access_token, expiration = record._fetch_outlook_refresh_token(code) + except UserError as e: + return request.render('microsoft_outlook.microsoft_outlook_oauth_error', { + 'error': str(e.name), + 'model_name': model_name, + 'rec_id': rec_id, + }) + + record.write({ + 'microsoft_outlook_refresh_token': refresh_token, + 'microsoft_outlook_access_token': access_token, + 'microsoft_outlook_access_token_expiration': expiration, + }) + + return werkzeug.utils.redirect(f'/web?#id={rec_id}&model={model_name}&view_type=form', 303) diff --git a/addons/microsoft_outlook/models/__init__.py b/addons/microsoft_outlook/models/__init__.py new file mode 100644 index 0000000000000..890e54b29c05f --- /dev/null +++ b/addons/microsoft_outlook/models/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import microsoft_outlook_mixin + +from . import ir_mail_server +from . import res_config_settings diff --git a/addons/microsoft_outlook/models/ir_mail_server.py b/addons/microsoft_outlook/models/ir_mail_server.py new file mode 100644 index 0000000000000..e988569fd6371 --- /dev/null +++ b/addons/microsoft_outlook/models/ir_mail_server.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import base64 + +from odoo import _, api, models +from odoo.exceptions import UserError + + +class IrMailServer(models.Model): + """Add the Outlook OAuth authentication on the outgoing mail servers.""" + + _name = 'ir.mail_server' + _inherit = ['ir.mail_server', 'microsoft.outlook.mixin'] + + _OUTLOOK_SCOPE = 'https://outlook.office.com/SMTP.Send' + + @api.constrains('use_microsoft_outlook_service', 'smtp_pass', 'smtp_encryption') + def _check_use_microsoft_outlook_service(self): + for server in self: + if not server.use_microsoft_outlook_service: + continue + + if server.smtp_pass: + raise UserError(_( + 'Please leave the password field empty for Outlook mail server %r. ' + 'The OAuth process does not require it') + % server.name) + + if server.smtp_encryption != 'starttls': + raise UserError(_( + 'Incorrect Connection Security for Outlook mail server %r. ' + 'Please set it to "TLS (STARTTLS)".') + % server.name) + + @api.onchange('smtp_encryption') + def _onchange_encryption(self): + """Do not change the SMTP configuration if it's a Outlook server + + (e.g. the port which is already set)""" + if not self.use_microsoft_outlook_service: + super()._onchange_encryption() + + @api.onchange('use_microsoft_outlook_service') + def _onchange_use_microsoft_outlook_service(self): + if self.use_microsoft_outlook_service: + self.smtp_host = 'smtp.outlook.com' + self.smtp_encryption = 'starttls' + self.smtp_port = 587 + else: + self.microsoft_outlook_refresh_token = False + self.microsoft_outlook_access_token = False + self.microsoft_outlook_access_token_expiration = False + + def _smtp_login(self, connection, smtp_user, smtp_password): + if len(self) == 1 and self.use_microsoft_outlook_service: + auth_string = self._generate_outlook_oauth2_string(smtp_user) + oauth_param = base64.b64encode(auth_string.encode()).decode() + connection.ehlo() + connection.docmd('AUTH', 'XOAUTH2 %s' % oauth_param) + else: + super()._smtp_login(connection, smtp_user, smtp_password) diff --git a/addons/microsoft_outlook/models/microsoft_outlook_mixin.py b/addons/microsoft_outlook/models/microsoft_outlook_mixin.py new file mode 100644 index 0000000000000..7453dae1d0811 --- /dev/null +++ b/addons/microsoft_outlook/models/microsoft_outlook_mixin.py @@ -0,0 +1,187 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import hashlib +import hmac +import json +import logging +import time +import requests + +from werkzeug.urls import url_encode, url_join + +from odoo import _, api, fields, models +from odoo.exceptions import AccessError, UserError + +_logger = logging.getLogger(__name__) + + +class MicrosoftOutlookMixin(models.AbstractModel): + + _name = 'microsoft.outlook.mixin' + _description = 'Microsoft Outlook Mixin' + + _OUTLOOK_SCOPE = None + _OUTLOOK_ENDPOINT = 'https://login.microsoftonline.com/common/oauth2/v2.0/' + + use_microsoft_outlook_service = fields.Boolean('Outlook Authentication') + is_microsoft_outlook_configured = fields.Boolean('Is Outlook Credential Configured', + compute='_compute_is_microsoft_outlook_configured') + microsoft_outlook_refresh_token = fields.Char(string='Outlook Refresh Token', + groups='base.group_system', copy=False) + microsoft_outlook_access_token = fields.Char(string='Outlook Access Token', + groups='base.group_system', copy=False) + microsoft_outlook_access_token_expiration = fields.Integer(string='Outlook Access Token Expiration Timestamp', + groups='base.group_system', copy=False) + microsoft_outlook_uri = fields.Char(compute='_compute_outlook_uri', string='Authentication URI', + help='The URL to generate the authorization code from Outlook', groups='base.group_system') + + @api.depends('use_microsoft_outlook_service') + def _compute_is_microsoft_outlook_configured(self): + Config = self.env['ir.config_parameter'].sudo() + microsoft_outlook_client_id = Config.get_param('microsoft_outlook_client_id') + microsoft_outlook_client_secret = Config.get_param('microsoft_outlook_client_secret') + self.is_microsoft_outlook_configured = microsoft_outlook_client_id and microsoft_outlook_client_secret + + @api.depends('use_microsoft_outlook_service') + def _compute_outlook_uri(self): + Config = self.env['ir.config_parameter'].sudo() + base_url = Config.get_param('web.base.url') + microsoft_outlook_client_id = Config.get_param('microsoft_outlook_client_id') + + for record in self: + if not record.id or not record.use_microsoft_outlook_service or not record.is_microsoft_outlook_configured: + record.microsoft_outlook_uri = False + continue + + record.microsoft_outlook_uri = url_join(self._OUTLOOK_ENDPOINT, 'authorize?%s' % url_encode({ + 'client_id': microsoft_outlook_client_id, + 'response_type': 'code', + 'redirect_uri': url_join(base_url, '/microsoft_outlook/confirm'), + 'response_mode': 'query', + # offline_access is needed to have the refresh_token + 'scope': 'offline_access %s' % self._OUTLOOK_SCOPE, + 'state': json.dumps({ + 'model': record._name, + 'id': record.id, + 'csrf_token': record._get_outlook_csrf_token(), + }) + })) + + def open_microsoft_outlook_uri(self): + """Open the URL to accept the Outlook permission. + + This is done with an action, so we can force the user the save the form. + We need him to save the form so the current mail server record exist in DB, and + we can include the record ID in the URL. + """ + self.ensure_one() + + if not self.env.user.has_group('base.group_system'): + raise AccessError(_('Only the administrator can link an Outlook mail server.')) + + if not self.use_microsoft_outlook_service or not self.is_microsoft_outlook_configured: + raise UserError(_('Please configure your Outlook credentials.')) + + return { + 'type': 'ir.actions.act_url', + 'url': self.microsoft_outlook_uri, + } + + def _fetch_outlook_refresh_token(self, authorization_code): + """Request the refresh token and the initial access token from the authorization code. + + :return: + refresh_token, access_token, access_token_expiration + """ + response = self._fetch_outlook_token('authorization_code', code=authorization_code) + return ( + response['refresh_token'], + response['access_token'], + int(time.time()) + response['expires_in'], + ) + + def _fetch_outlook_access_token(self, refresh_token): + """Refresh the access token thanks to the refresh token. + + :return: + access_token, access_token_expiration + """ + response = self._fetch_outlook_token('refresh_token', refresh_token=refresh_token) + return ( + response['access_token'], + int(time.time()) + response['expires_in'], + ) + + def _fetch_outlook_token(self, grant_type, **values): + """Generic method to request an access token or a refresh token. + + Return the JSON response of the Outlook API and manage the errors which can occur. + + :param grant_type: Depends the action we want to do (refresh_token or authorization_code) + :param values: Additional parameters that will be given to the Outlook endpoint + """ + Config = self.env['ir.config_parameter'].sudo() + base_url = Config.get_param('web.base.url') + microsoft_outlook_client_id = Config.get_param('microsoft_outlook_client_id') + microsoft_outlook_client_secret = Config.get_param('microsoft_outlook_client_secret') + + response = requests.post( + url_join(self._OUTLOOK_ENDPOINT, 'token'), + data={ + 'client_id': microsoft_outlook_client_id, + 'client_secret': microsoft_outlook_client_secret, + 'scope': 'offline_access %s' % self._OUTLOOK_SCOPE, + 'redirect_uri': url_join(base_url, '/microsoft_outlook/confirm'), + 'grant_type': grant_type, + **values, + }, + timeout=10, + ) + + if not response.ok: + try: + error_description = response.json()['error_description'] + except Exception: + error_description = _('Unknown error.') + raise UserError(_('An error occurred when fetching the access token. %s') % error_description) + + return response.json() + + def _generate_outlook_oauth2_string(self, login): + """Generate a OAuth2 string which can be used for authentication. + + :param user: Email address of the Outlook account to authenticate + :return: The SASL argument for the OAuth2 mechanism. + """ + self.ensure_one() + now_timestamp = int(time.time()) + if not self.microsoft_outlook_access_token \ + or not self.microsoft_outlook_access_token_expiration \ + or self.microsoft_outlook_access_token_expiration < now_timestamp: + if not self.microsoft_outlook_refresh_token: + raise UserError(_('Please login your Outlook mail server before using it.')) + ( + self.microsoft_outlook_access_token, + self.microsoft_outlook_access_token_expiration, + ) = self._fetch_outlook_access_token(self.microsoft_outlook_refresh_token) + _logger.info( + 'Microsoft Outlook: fetch new access token. It expires in %i minutes', + (self.microsoft_outlook_access_token_expiration - now_timestamp) // 60) + else: + _logger.info( + 'Microsoft Outlook: reuse existing access token. It expires in %i minutes', + (self.microsoft_outlook_access_token_expiration - now_timestamp) // 60) + + return 'user=%s\1auth=Bearer %s\1\1' % (login, self.microsoft_outlook_access_token) + + def _get_outlook_csrf_token(self): + """Generate a CSRF token that will be verified in `microsoft_outlook_callback`. + + This will prevent a malicious person to make an admin user disconnect the mail servers. + """ + self.ensure_one() + _logger.info('Microsoft Outlook: generate CSRF token for %s #%i', self._name, self.id) + secret = self.env['ir.config_parameter'].sudo().get_param('database.secret') + data = str((self.env.cr.dbname, 'microsoft_outlook_oauth', self._name, self.id)) + return hmac.new(secret.encode('utf-8'), data.encode('utf-8'), hashlib.sha256).hexdigest() diff --git a/addons/microsoft_outlook/models/res_config_settings.py b/addons/microsoft_outlook/models/res_config_settings.py new file mode 100644 index 0000000000000..620ecace671e7 --- /dev/null +++ b/addons/microsoft_outlook/models/res_config_settings.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + microsoft_outlook_client_identifier = fields.Char('Outlook Client Id', config_parameter='microsoft_outlook_client_id') + microsoft_outlook_client_secret = fields.Char('Outlook Client Secret', config_parameter='microsoft_outlook_client_secret') diff --git a/addons/microsoft_outlook/views/ir_mail_server_views.xml b/addons/microsoft_outlook/views/ir_mail_server_views.xml new file mode 100644 index 0000000000000..1eb8f38e930c7 --- /dev/null +++ b/addons/microsoft_outlook/views/ir_mail_server_views.xml @@ -0,0 +1,42 @@ + + + + ir.mail_server.view.form.inherit.outlook + ir.mail_server + + + + + + + + + + +
+
+ + Outlook Token Valid + + + + +
+
+
+
+
diff --git a/addons/microsoft_outlook/views/res_config_settings_views.xml b/addons/microsoft_outlook/views/res_config_settings_views.xml new file mode 100644 index 0000000000000..94cdb4b6705c8 --- /dev/null +++ b/addons/microsoft_outlook/views/res_config_settings_views.xml @@ -0,0 +1,35 @@ + + + + + res.config.settings.view.form.inherit.microsoft_outlook + res.config.settings + + +
+
+
+ Outlook Credentials +
+ Send and receive email with your Outlook account. +
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/microsoft_outlook/views/templates.xml b/addons/microsoft_outlook/views/templates.xml new file mode 100644 index 0000000000000..7139c4bb6e642 --- /dev/null +++ b/addons/microsoft_outlook/views/templates.xml @@ -0,0 +1,15 @@ + + + + From 8880fbab647dca791cd12cacca4579e2fc0fa476 Mon Sep 17 00:00:00 2001 From: Denis Roussel Date: Mon, 28 Mar 2022 15:27:34 +0000 Subject: [PATCH 2/3] [FIX] microsoft_outlook: Fix f-strings formatting closes odoo/odoo#87389 Signed-off-by: Thibault Delavallee (tde) --- addons/microsoft_outlook/controllers/main.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/addons/microsoft_outlook/controllers/main.py b/addons/microsoft_outlook/controllers/main.py index 9206275a184ba..fad1b731d837b 100644 --- a/addons/microsoft_outlook/controllers/main.py +++ b/addons/microsoft_outlook/controllers/main.py @@ -6,6 +6,7 @@ import werkzeug from werkzeug.exceptions import Forbidden +from werkzeug.urls import url_encode from odoo import http from odoo.exceptions import UserError @@ -72,5 +73,10 @@ def microsoft_outlook_callback(self, code=None, state=None, error_description=No 'microsoft_outlook_access_token': access_token, 'microsoft_outlook_access_token_expiration': expiration, }) - - return werkzeug.utils.redirect(f'/web?#id={rec_id}&model={model_name}&view_type=form', 303) + url_params = { + 'id': rec_id, + 'model': model_name, + 'view_type': 'form' + } + url = '/web?#' + url_encode(url_params) + return werkzeug.utils.redirect(url, 303) From 980143d544ebfdba2e0f0b18ee20c79982864c83 Mon Sep 17 00:00:00 2001 From: std-odoo Date: Tue, 29 Mar 2022 10:24:26 +0000 Subject: [PATCH 3/3] [FIX] microsoft_outlook: do not auto install microsoft_outlook Purpose ======= Do not auto install microsoft_outlook. fetchmail_outlook is still auto installed because it depends on microsoft_outlook. Task-2751996 closes odoo/odoo#87461 Signed-off-by: Thibault Delavallee (tde) --- addons/microsoft_outlook/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/microsoft_outlook/__manifest__.py b/addons/microsoft_outlook/__manifest__.py index 9bd75607801e5..7ae700cd02dd0 100644 --- a/addons/microsoft_outlook/__manifest__.py +++ b/addons/microsoft_outlook/__manifest__.py @@ -14,5 +14,5 @@ "views/res_config_settings_views.xml", "views/templates.xml", ], - "auto_install": True, + "auto_install": False, }