diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/account_edi_proxy_client/models | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/account_edi_proxy_client/models')
5 files changed, 277 insertions, 0 deletions
diff --git a/addons/account_edi_proxy_client/models/__init__.py b/addons/account_edi_proxy_client/models/__init__.py new file mode 100644 index 00000000..0df13f7a --- /dev/null +++ b/addons/account_edi_proxy_client/models/__init__.py @@ -0,0 +1,3 @@ +from . import account_edi_format +from . import account_edi_proxy_user +from . import res_company diff --git a/addons/account_edi_proxy_client/models/account_edi_format.py b/addons/account_edi_proxy_client/models/account_edi_format.py new file mode 100644 index 00000000..0dc8bd9a --- /dev/null +++ b/addons/account_edi_proxy_client/models/account_edi_format.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +from odoo import models + + +class AccountEdiFormat(models.Model): + _inherit = 'account.edi.format' + + # ------------------------------------------------------------------------- + # Helpers + # ------------------------------------------------------------------------- + + def _get_proxy_user(self, company): + '''Returns the proxy_user associated with this edi format. + ''' + self.ensure_one() + return company.account_edi_proxy_client_ids.filtered(lambda u: u.edi_format_id == self) + + # ------------------------------------------------------------------------- + # To override + # ------------------------------------------------------------------------- + + def _get_proxy_identification(self, company): + '''Returns the key that will identify company uniquely for this edi format (for example, the vat) + or raises a UserError (if the user didn't fill the related field). + TO OVERRIDE + ''' + return False diff --git a/addons/account_edi_proxy_client/models/account_edi_proxy_auth.py b/addons/account_edi_proxy_client/models/account_edi_proxy_auth.py new file mode 100644 index 00000000..b515ff67 --- /dev/null +++ b/addons/account_edi_proxy_client/models/account_edi_proxy_auth.py @@ -0,0 +1,48 @@ +import base64 +import hashlib +import hmac +import json +import requests +import time +import werkzeug.urls + + +class OdooEdiProxyAuth(requests.auth.AuthBase): + """ For routes that needs to be authenticated and verified for access. + Allows: + 1) to preserve the integrity of the message between the endpoints. + 2) to check user access rights and account validity + 3) to avoid that multiple database use the same credentials, via a refresh_token that expire after 24h. + """ + + def __init__(self, user=False): + self.id_client = user and user.id_client or False + self.refresh_token = user and user.refresh_token or False + + def __call__(self, request): + # We don't sign request that still don't have a id_client/refresh_token + if not self.id_client or not self.refresh_token: + return request + # craft the message (timestamp|url path|id_client|query params|body content) + msg_timestamp = int(time.time()) + parsed_url = werkzeug.urls.url_parse(request.path_url) + + body = request.body + if isinstance(body, bytes): + body = body.decode() + body = json.loads(body) + + message = '%s|%s|%s|%s|%s' % ( + msg_timestamp, # timestamp + parsed_url.path, # url path + self.id_client, + json.dumps(werkzeug.urls.url_decode(parsed_url.query), sort_keys=True), # url query params sorted by key + json.dumps(body, sort_keys=True)) # http request body + h = hmac.new(base64.b64decode(self.refresh_token), message.encode(), digestmod=hashlib.sha256) + + request.headers.update({ + 'odoo-edi-client-id': self.id_client, + 'odoo-edi-signature': h.hexdigest(), + 'odoo-edi-timestamp': msg_timestamp, + }) + return request diff --git a/addons/account_edi_proxy_client/models/account_edi_proxy_user.py b/addons/account_edi_proxy_client/models/account_edi_proxy_user.py new file mode 100644 index 00000000..9e71a9a3 --- /dev/null +++ b/addons/account_edi_proxy_client/models/account_edi_proxy_user.py @@ -0,0 +1,190 @@ +from odoo import models, fields, _ +from odoo.exceptions import UserError +from .account_edi_proxy_auth import OdooEdiProxyAuth + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.fernet import Fernet +import requests +import uuid +import base64 +import logging + + +_logger = logging.getLogger(__name__) + + +SERVER_URL = 'https://l10n-it-edi.api.odoo.com' +TIMEOUT = 30 + + +class AccountEdiProxyError(Exception): + + def __init__(self, code, message=False): + self.code = code + self.message = message + super().__init__(message or code) + + +class AccountEdiProxyClientUser(models.Model): + """Represents a user of the proxy for an electronic invoicing format. + An edi_proxy_user has a unique identification on a specific format (for example, the vat for Peppol) which + allows to identify him when receiving a document addressed to him. It is linked to a specific company on a specific + Odoo database. + It also owns a key with which each file should be decrypted with (the proxy encrypt all the files with the public key). + """ + _name = 'account_edi_proxy_client.user' + _description = 'Account EDI proxy user' + + active = fields.Boolean(default=True) + id_client = fields.Char(required=True, index=True) + company_id = fields.Many2one('res.company', string='Company', required=True, + default=lambda self: self.env.company) + edi_format_id = fields.Many2one('account.edi.format', required=True) + edi_format_code = fields.Char(related='edi_format_id.code', readonly=True) + edi_identification = fields.Char(required=True, help="The unique id that identifies this user for on the edi format, typically the vat") + private_key = fields.Binary(required=True, attachment=False, groups="base.group_system", help="The key to encrypt all the user's data") + refresh_token = fields.Char(groups="base.group_system") + + _sql_constraints = [ + ('unique_id_client', 'unique(id_client)', 'This id_client is already used on another user.'), + ('unique_edi_identification_per_format', 'unique(edi_identification, edi_format_id)', 'This edi identification is already assigned to a user'), + ] + + def _make_request(self, url, params=False): + ''' Make a request to proxy and handle the generic elements of the reponse (errors, new refresh token). + ''' + payload = { + 'jsonrpc': '2.0', + 'method': 'call', + 'params': params or {}, + 'id': uuid.uuid4().hex, + } + + if self.env['ir.config_parameter'].get_param('account_edi_proxy_client.demo', False): + # Last barrier : in case the demo mode is not handled by the caller, we block access. + raise Exception("Can't access the proxy in demo mode") + + try: + response = requests.post( + url, + json=payload, + timeout=TIMEOUT, + headers={'content-type': 'application/json'}, + auth=OdooEdiProxyAuth(user=self)).json() + except (ValueError, requests.exceptions.ConnectionError, requests.exceptions.MissingSchema, requests.exceptions.Timeout, requests.exceptions.HTTPError): + raise AccountEdiProxyError('connection_error', + _('The url that this service requested returned an error. The url it tried to contact was %s', url)) + + if 'error' in response: + message = _('The url that this service requested returned an error. The url it tried to contact was %s. %s', url, response['error']['message']) + if response['error']['code'] == 404: + message = _('The url that this service does not exist. The url it tried to contact was %s', url) + raise AccountEdiProxyError('connection_error', message) + + proxy_error = response['result'].pop('proxy_error', False) + if proxy_error: + error_code = proxy_error['code'] + if error_code == 'refresh_token_expired': + self._renew_token() + return self._make_request(url, params) + if error_code == 'no_such_user': + # This error is also raised if the user didn't exchange data and someone else claimed the edi_identificaiton. + self.active = False + raise AccountEdiProxyError(error_code, proxy_error['message'] or False) + + return response['result'] + + def _register_proxy_user(self, company, edi_format, edi_identification): + ''' Generate the public_key/private_key that will be used to encrypt the file, send a request to the proxy + to register the user with the public key and create the user with the private key. + + :param company: the company of the user. + :param edi_identification: The unique ID that identifies this user on this edi network and to which the files will be addressed. + Typically the vat. + ''' + # public_exponent=65537 is a default value that should be used most of the time, as per the documentation of cryptography. + # key_size=2048 is considered a reasonable default key size, as per the documentation of cryptography. + # see https://cryptography.io/en/latest/hazmat/primitives/asymmetric/rsa/ + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + backend=default_backend() + ) + private_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption() + ) + public_key = private_key.public_key() + public_pem = public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo + ) + if self.env['ir.config_parameter'].get_param('account_edi_proxy_client.demo', False): + # simulate registration + response = {'id_client': 'demo', 'refresh_token': 'demo'} + else: + try: + response = self._make_request(SERVER_URL + '/iap/account_edi/1/create_user', params={ + 'dbuuid': company.env['ir.config_parameter'].get_param('database.uuid'), + 'company_id': company.id, + 'edi_format_code': edi_format.code, + 'edi_identification': edi_identification, + 'public_key': base64.b64encode(public_pem) + }) + except AccountEdiProxyError as e: + raise UserError(e.message) + if 'error' in response: + raise UserError(response['error']) + + self.create({ + 'id_client': response['id_client'], + 'company_id': company.id, + 'edi_format_id': edi_format.id, + 'edi_identification': edi_identification, + 'private_key': base64.b64encode(private_pem), + 'refresh_token': response['refresh_token'], + }) + + def _renew_token(self): + ''' Request the proxy for a new refresh token. + + Request to the proxy should be made with a refresh token that expire after 24h to avoid + that multiple database use the same credentials. When receiving an error for an expired refresh_token, + This method makes a request to get a new refresh token. + ''' + response = self._make_request(SERVER_URL + '/iap/account_edi/1/renew_token') + if 'error' in response: + # can happen if the database was duplicated and the refresh_token was refreshed by the other database. + # we don't want two database to be able to query the proxy with the same user + # because it could lead to not inconsistent data. + _logger.error(response['error']) + raise UserError('Proxy error, please contact Odoo (code: 3)') + self.refresh_token = response['refresh_token'] + + def _decrypt_data(self, data, symmetric_key): + ''' Decrypt the data. Note that the data is encrypted with a symmetric key, which is encrypted with an asymmetric key. + We must therefore decrypt the symmetric key. + + :param data: The data to decrypt. + :param symmetric_key: The symmetric_key encrypted with self.private_key.public_key() + ''' + private_key = serialization.load_pem_private_key( + base64.b64decode(self.private_key), + password=None, + backend=default_backend() + ) + key = private_key.decrypt( + base64.b64decode(symmetric_key), + padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None + ) + ) + f = Fernet(key) + return f.decrypt(base64.b64decode(data)) diff --git a/addons/account_edi_proxy_client/models/res_company.py b/addons/account_edi_proxy_client/models/res_company.py new file mode 100644 index 00000000..69e8ce4d --- /dev/null +++ b/addons/account_edi_proxy_client/models/res_company.py @@ -0,0 +1,9 @@ +# -*- coding:utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = 'res.company' + + account_edi_proxy_client_ids = fields.One2many('account_edi_proxy_client.user', inverse_name='company_id') |
