Initial commit: Security packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:51 +02:00
commit bb469e4763
1399 changed files with 278378 additions and 0 deletions

View file

@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import microsoft_outlook_mixin
from . import fetchmail_server
from . import ir_mail_server
from . import res_config_settings

View file

@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, fields, 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'
server_type = fields.Selection(selection_add=[('outlook', 'Outlook OAuth Authentication')], ondelete={'outlook': 'set default'})
def _compute_server_type_info(self):
outlook_servers = self.filtered(lambda server: server.server_type == 'outlook')
outlook_servers.server_type_info = _(
'Connect your personal Outlook account using OAuth. \n'
'You will be redirected to the Outlook login page to accept '
'the permissions.')
super(FetchmailServer, self - outlook_servers)._compute_server_type_info()
@api.depends('server_type')
def _compute_is_microsoft_outlook_configured(self):
outlook_servers = self.filtered(lambda server: server.server_type == 'outlook')
(self - outlook_servers).is_microsoft_outlook_configured = False
super(FetchmailServer, outlook_servers)._compute_is_microsoft_outlook_configured()
@api.constrains('server_type', 'is_ssl')
def _check_use_microsoft_outlook_service(self):
for server in self:
if server.server_type == 'outlook' and not server.is_ssl:
raise UserError(_('SSL is required for the server %r.', server.name))
@api.onchange('server_type')
def onchange_server_type(self):
"""Set the default configuration for a IMAP Outlook server."""
if self.server_type == 'outlook':
self.server = 'imap.outlook.com'
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
super(FetchmailServer, self).onchange_server_type()
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.server_type == 'outlook':
auth_string = self._generate_outlook_oauth2_string(self.user)
connection.authenticate('XOAUTH2', lambda x: auth_string)
connection.select('INBOX')
else:
super()._imap_login(connection)
def _get_connection_type(self):
"""Return which connection must be used for this mail server (IMAP or POP).
The Outlook mail server used an IMAP connection.
"""
self.ensure_one()
return 'imap' if self.server_type == 'outlook' else super()._get_connection_type()

View file

@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
from odoo import _, api, fields, 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'
smtp_authentication = fields.Selection(
selection_add=[('outlook', 'Outlook OAuth Authentication')],
ondelete={'outlook': 'set default'})
@api.depends('smtp_authentication')
def _compute_is_microsoft_outlook_configured(self):
outlook_servers = self.filtered(lambda server: server.smtp_authentication == 'outlook')
(self - outlook_servers).is_microsoft_outlook_configured = False
super(IrMailServer, outlook_servers)._compute_is_microsoft_outlook_configured()
def _compute_smtp_authentication_info(self):
outlook_servers = self.filtered(lambda server: server.smtp_authentication == 'outlook')
outlook_servers.smtp_authentication_info = _(
'Connect your Outlook account with the OAuth Authentication process. \n'
'By default, only a user with a matching email address will be able to use this server. '
'To extend its use, you should set a "mail.default.from" system parameter.')
super(IrMailServer, self - outlook_servers)._compute_smtp_authentication_info()
@api.constrains('smtp_authentication', 'smtp_pass', 'smtp_encryption', 'smtp_user')
def _check_use_microsoft_outlook_service(self):
outlook_servers = self.filtered(lambda server: server.smtp_authentication == 'outlook')
for server in outlook_servers:
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))
if not server.smtp_user:
raise UserError(_(
'Please fill the "Username" field with your Outlook/Office365 username (your email address). '
'This should be the same account as the one used for the Outlook OAuthentication Token.'))
@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 self.smtp_authentication != 'outlook':
super()._onchange_encryption()
@api.onchange('smtp_authentication')
def _onchange_smtp_authentication_outlook(self):
if self.smtp_authentication == 'outlook':
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
@api.onchange('smtp_user', 'smtp_authentication')
def _on_change_smtp_user_outlook(self):
"""The Outlook mail servers can only be used for the user personal email address."""
if self.smtp_authentication == 'outlook':
self.from_filter = self.smtp_user
def _smtp_login(self, connection, smtp_user, smtp_password):
if len(self) == 1 and self.smtp_authentication == 'outlook':
auth_string = self._generate_outlook_oauth2_string(smtp_user)
oauth_param = base64.b64encode(auth_string.encode()).decode()
connection.ehlo()
connection.docmd('AUTH', f'XOAUTH2 {oauth_param}')
else:
super()._smtp_login(connection, smtp_user, smtp_password)

View file

@ -0,0 +1,194 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
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
from odoo.tools.misc import hmac
_logger = logging.getLogger(__name__)
class MicrosoftOutlookMixin(models.AbstractModel):
_name = 'microsoft.outlook.mixin'
_description = 'Microsoft Outlook Mixin'
_OUTLOOK_SCOPE = None
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')
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('is_microsoft_outlook_configured')
def _compute_outlook_uri(self):
Config = self.env['ir.config_parameter'].sudo()
base_url = self.get_base_url()
microsoft_outlook_client_id = Config.get_param('microsoft_outlook_client_id')
for record in self:
if not record.id or not record.is_microsoft_outlook_configured:
record.microsoft_outlook_uri = False
continue
record.microsoft_outlook_uri = url_join(self._get_microsoft_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.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()) + int(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['refresh_token'],
response['access_token'],
int(time.time()) + int(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 = self.get_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._get_microsoft_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 connect with your Outlook account before using it.'))
(
self.microsoft_outlook_refresh_token,
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)
return hmac(
env=self.env(su=True),
scope='microsoft_outlook_oauth',
message=(self._name, self.id),
)
@api.model
def _get_microsoft_endpoint(self):
return self.env["ir.config_parameter"].sudo().get_param(
'microsoft_outlook.endpoint',
'https://login.microsoftonline.com/common/oauth2/v2.0/',
)

View file

@ -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')