From 192adb47e6c637c94bf0f29188dbd00216cf5223 Mon Sep 17 00:00:00 2001 From: Dave Lasley Date: Tue, 10 Oct 2017 12:15:47 -0700 Subject: [PATCH 01/20] [ADD] base_json_request: Module to allow standard JSON requests * Create a module that circumvents the JSON-RPC requirement for all requests with application/json --- base_json_request/README.rst | 50 ++++++++++++++++++++++++++ base_json_request/__init__.py | 4 +++ base_json_request/__manifest__.py | 18 ++++++++++ base_json_request/hooks.py | 13 +++++++ base_json_request/http.py | 60 +++++++++++++++++++++++++++++++ 5 files changed, 145 insertions(+) create mode 100644 base_json_request/README.rst create mode 100644 base_json_request/__init__.py create mode 100644 base_json_request/__manifest__.py create mode 100644 base_json_request/hooks.py create mode 100644 base_json_request/http.py diff --git a/base_json_request/README.rst b/base_json_request/README.rst new file mode 100644 index 0000000..5451ed3 --- /dev/null +++ b/base_json_request/README.rst @@ -0,0 +1,50 @@ +.. image:: https://img.shields.io/badge/licence-LGPL--3-blue.svg + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 + +================= +Base JSON Request +================= + +This module allows you to receive JSON requests in Odoo that are not +RPC. + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/210/10.0 + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues +`_. In case of trouble, please +check there if your issue has already been reported. If you spotted it first, +help us smashing it by providing a detailed and welcomed feedback. + +Credits +======= + +Images +------ + +* Odoo Community Association: `Icon `_. + +Contributors +------------ + +* Dave Lasley + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +To contribute to this module, please visit https://odoo-community.org. diff --git a/base_json_request/__init__.py b/base_json_request/__init__.py new file mode 100644 index 0000000..c484ab2 --- /dev/null +++ b/base_json_request/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from .hooks import post_load diff --git a/base_json_request/__manifest__.py b/base_json_request/__manifest__.py new file mode 100644 index 0000000..05ee396 --- /dev/null +++ b/base_json_request/__manifest__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +{ + 'name': 'Base JSON Request', + 'summary': 'Allows you to receive JSON requests that are not RPC.', + 'version': '10.0.1.0.0', + 'category': 'Authentication', + 'website': 'https://laslabs.com/', + 'author': 'LasLabs, Odoo Community Association (OCA)', + 'license': 'LGPL-3', + 'installable': True, + 'depends': [ + 'web', + ], + 'post_load': 'post_load', +} diff --git a/base_json_request/hooks.py b/base_json_request/hooks.py new file mode 100644 index 0000000..84e827e --- /dev/null +++ b/base_json_request/hooks.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import http + +from .http import _handle_exception, __init__ + + +def post_load(): + """Monkey patch HTTP methods.""" + http.JsonRequest._handle_exception = _handle_exception + http.JsonRequest.__init__ = __init__ diff --git a/base_json_request/http.py b/base_json_request/http.py new file mode 100644 index 0000000..f5b1713 --- /dev/null +++ b/base_json_request/http.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import json + +from werkzeug.exceptions import BadRequest + +from odoo import http + + +old_handle_exception = http.JsonRequest._handle_exception +old_init = http.JsonRequest.__init__ + + +def __init__(self, *args): + try: + old_init(self, *args) + except BadRequest as e: + try: + args = self.httprequest.args + self.jsonrequest = args + self.params = json.loads(self.jsonrequest.get('params', "{}")) + self.context = self.params.pop('context', + dict(self.session.context)) + except ValueError: + raise e + + +def _handle_exception(self, exception): + """ Override the original method to handle Werkzeug exceptions. + + Args: + exception (Exception): Exception object that is being thrown. + + Returns: + BaseResponse: JSON Response. + """ + + # For some reason a try/except here still raised... + code = getattr(exception, 'code', None) + if code is None: + return old_handle_exception( + self, exception, + ) + + error = { + 'data': http.serialize_exception(exception), + 'code': code, + } + + try: + error['message'] = exception.description + except AttributeError: + try: + error['message'] = exception.message + except AttributeError: + error['message'] = 'Internal Server Error' + + return self._json_response(error=error) From 2839174135d40bb537caf5637202c7777fc1abd9 Mon Sep 17 00:00:00 2001 From: Dave Lasley Date: Tue, 10 Oct 2017 14:12:21 -0700 Subject: [PATCH 02/20] [ADD] base_web_hook: Create abstract web hooks * Create a module to allow for the receipt of web hooks in an abstract fashion --- base_web_hook/README.rst | 49 +++++++++++++ base_web_hook/__init__.py | 4 ++ base_web_hook/__manifest__.py | 21 ++++++ base_web_hook/controllers/__init__.py | 4 ++ base_web_hook/controllers/main.py | 17 +++++ base_web_hook/models/__init__.py | 6 ++ base_web_hook/models/web_hook.py | 91 ++++++++++++++++++++++++ base_web_hook/models/web_hook_adapter.py | 34 +++++++++ base_web_hook/models/web_hook_generic.py | 28 ++++++++ requirements.txt | 1 + 10 files changed, 255 insertions(+) create mode 100644 base_web_hook/README.rst create mode 100644 base_web_hook/__init__.py create mode 100644 base_web_hook/__manifest__.py create mode 100644 base_web_hook/controllers/__init__.py create mode 100644 base_web_hook/controllers/main.py create mode 100644 base_web_hook/models/__init__.py create mode 100644 base_web_hook/models/web_hook.py create mode 100644 base_web_hook/models/web_hook_adapter.py create mode 100644 base_web_hook/models/web_hook_generic.py create mode 100644 requirements.txt diff --git a/base_web_hook/README.rst b/base_web_hook/README.rst new file mode 100644 index 0000000..0694b5f --- /dev/null +++ b/base_web_hook/README.rst @@ -0,0 +1,49 @@ +.. image:: https://img.shields.io/badge/licence-LGPL--3-blue.svg + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 + +============= +Base Web Hook +============= + +This module provides an abstract core for receiving and processing web hooks. + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/210/10.0 + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues +`_. In case of trouble, please +check there if your issue has already been reported. If you spotted it first, +help us smashing it by providing a detailed and welcomed feedback. + +Credits +======= + +Images +------ + +* Odoo Community Association: `Icon `_. + +Contributors +------------ + +* Dave Lasley + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +To contribute to this module, please visit https://odoo-community.org. diff --git a/base_web_hook/__init__.py b/base_web_hook/__init__.py new file mode 100644 index 0000000..c484ab2 --- /dev/null +++ b/base_web_hook/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from .hooks import post_load diff --git a/base_web_hook/__manifest__.py b/base_web_hook/__manifest__.py new file mode 100644 index 0000000..4ba6026 --- /dev/null +++ b/base_web_hook/__manifest__.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +{ + 'name': 'Base Web Hook', + 'summary': 'Provides an abstract system for defining and receiving web ' + 'hooks.', + 'version': '10.0.1.0.0', + 'category': 'Tools', + 'website': 'https://laslabs.com/', + 'author': 'LasLabs, Odoo Community Association (OCA)', + 'license': 'LGPL-3', + 'installable': True, + 'external_dependencies': { + 'python': ['slugify'], + }, + 'depends': [ + 'base_json_request', + ], +} diff --git a/base_web_hook/controllers/__init__.py b/base_web_hook/controllers/__init__.py new file mode 100644 index 0000000..c484ab2 --- /dev/null +++ b/base_web_hook/controllers/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from .hooks import post_load diff --git a/base_web_hook/controllers/main.py b/base_web_hook/controllers/main.py new file mode 100644 index 0000000..4739beb --- /dev/null +++ b/base_web_hook/controllers/main.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import http + + +class WebHookController(http.Controller): + + @http.route( + ['/base_web_hook/'], + type='json', + auth='none', + ) + def receive(self, slug, **kwargs): + hook = self.env['web.hook'].search_by_slug(slug) + return hook.receive(kwargs) diff --git a/base_web_hook/models/__init__.py b/base_web_hook/models/__init__.py new file mode 100644 index 0000000..0bfdbc1 --- /dev/null +++ b/base_web_hook/models/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from . import web_hook +from . import web_hook_adapter +from . import web_hook_generic diff --git a/base_web_hook/models/web_hook.py b/base_web_hook/models/web_hook.py new file mode 100644 index 0000000..39bf794 --- /dev/null +++ b/base_web_hook/models/web_hook.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + +try: + from slugify import slugify +except ImportError: + _logger.info('`python-slugify` Python library not installed.') + + +class WebHook(models.Model): + + _name = 'web.hook' + _description = 'Web Hook' + + name = fields.Char( + required=True, + ) + interface = fields.Reference( + selection='_get_interface_types', + readonly=True, + help='This is the interface that the web hook represents. It is ' + 'created automatically upon creation of the web hook, and ' + 'is also deleted with it.', + ) + interface_type = fields.Selection( + selection='_get_interface_types', + required=True, + ) + uri = fields.Char( + help='This is the URI that is used to call the web hook externally.', + compute='_compute_uri', + ) + + @api.model + def _get_interface_types(self): + """Return the web hook interface models that are installed.""" + adapter = self.env['web.hook.adapter'] + return [ + (m, self.env[m]._description) for m in adapter._inherit_children + ] + + @api.multi + @api.depends('slug') + def _compute_uri(self): + for record in self: + if isinstance(record.id, models.NewId): + # Do not compute slug until saved + continue + name = slugify(record.name or '').strip().strip('-') + record.uri = '/base_web_hook/%s-%d' % (name, record.id) + + @api.model + def create(self, vals): + """Create the interface for the record and assign to ``interface``.""" + record = super(WebHook, self).create(vals) + interface = self.env[vals['interface_type']].create({ + 'hook_id': record.id, + }) + record.interface = interface + return record + + @api.model + def search_by_slug(self, slug): + _, record_id = slug.strip().rsplit('-', 1) + return self.browse(record_id) + + @api.multi + def receive(self, data=None): + """This method is used to receive a web hook. + + It simply passes the received data to the underlying interface's + ``receive`` method for processing, and returns the result. The + result returned by the interface must be JSON serializable. + + Args: + data (dict, optional): Data to pass to the hook's ``receive`` + method. + + Returns: + mixed: A JSON serializable return from the interface's + ``receive`` method. + """ + self.ensure_one() + return self.interface.receive() diff --git a/base_web_hook/models/web_hook_adapter.py b/base_web_hook/models/web_hook_adapter.py new file mode 100644 index 0000000..fe57b9d --- /dev/null +++ b/base_web_hook/models/web_hook_adapter.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import api, fields, models + + +class WebHookAdapter(models.AbstractModel): + """This is the model that should be inherited for new web hooks.""" + + _name = 'web.hook.adapter' + _description = 'Web Hook Adapter' + + hook_id = fields.Many2one( + string='Hook', + comodel_name='web.hook', + required=True, + ondelete='cascade', + ) + + @api.multi + def receive(self, data=None): + """This should be overridden by inherited models to receive web hooks. + + It can expect a singleton, although can ``self.ensure_one()`` if + desired. + + Args: + data (dict, optional): Data that was received with the hook. + + Returns: + mixed: A JSON serializable return, or ``None``. + """ + raise NotImplementedError() diff --git a/base_web_hook/models/web_hook_generic.py b/base_web_hook/models/web_hook_generic.py new file mode 100644 index 0000000..0ae04d1 --- /dev/null +++ b/base_web_hook/models/web_hook_generic.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import api, fields, models + + +class WebHookGeneric(models.Model): + """This is the model that should be inherited for new web hooks.""" + + _name = 'web.hook.generic' + _inherit = 'web.hook.adapter' + _description = 'Web Hook - Generic' + + @api.multi + def receive(self, data=None): + """This should be overridden by inherited models to receive web hooks. + + It can expect a singleton, although can ``self.ensure_one()`` if + desired. + + Args: + data (dict, optional): Data that was received with the hook. + + Returns: + mixed: A JSON serializable return, or ``None``. + """ + raise NotImplementedError() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..794a00a --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +python-slugify From 071843bc80d2cc0a92e8bd1b77795b0294d3d685 Mon Sep 17 00:00:00 2001 From: Dave Lasley Date: Wed, 11 Oct 2017 11:38:31 -0700 Subject: [PATCH 03/20] Add token adapter/interface mechanism --- base_web_hook/controllers/main.py | 2 +- base_web_hook/models/__init__.py | 4 +- base_web_hook/models/web_hook.py | 32 ++++++-- base_web_hook/models/web_hook_adapter.py | 13 +++ base_web_hook/models/web_hook_generic.py | 28 ------- base_web_hook/models/web_hook_token.py | 82 +++++++++++++++++++ .../models/web_hook_token_adapter.py | 41 ++++++++++ base_web_hook/models/web_hook_token_plain.py | 19 +++++ base_web_hook/models/website.py | 12 +++ 9 files changed, 195 insertions(+), 38 deletions(-) delete mode 100644 base_web_hook/models/web_hook_generic.py create mode 100644 base_web_hook/models/web_hook_token.py create mode 100644 base_web_hook/models/web_hook_token_adapter.py create mode 100644 base_web_hook/models/web_hook_token_plain.py create mode 100644 base_web_hook/models/website.py diff --git a/base_web_hook/controllers/main.py b/base_web_hook/controllers/main.py index 4739beb..c9e4c6a 100644 --- a/base_web_hook/controllers/main.py +++ b/base_web_hook/controllers/main.py @@ -14,4 +14,4 @@ class WebHookController(http.Controller): ) def receive(self, slug, **kwargs): hook = self.env['web.hook'].search_by_slug(slug) - return hook.receive(kwargs) + return hook.receive(kwargs, http.request.httprequest.get_data()) diff --git a/base_web_hook/models/__init__.py b/base_web_hook/models/__init__.py index 0bfdbc1..0b92e76 100644 --- a/base_web_hook/models/__init__.py +++ b/base_web_hook/models/__init__.py @@ -3,4 +3,6 @@ from . import web_hook from . import web_hook_adapter -from . import web_hook_generic +from . import web_hook_token +from . import web_hook_token_adapter +from . import web_hook_token_plain diff --git a/base_web_hook/models/web_hook.py b/base_web_hook/models/web_hook.py index 39bf794..1d1e8b5 100644 --- a/base_web_hook/models/web_hook.py +++ b/base_web_hook/models/web_hook.py @@ -4,7 +4,9 @@ import logging -from odoo import api, fields, models +from werkzeug.exceptions import Unauthorized + +from odoo import api, fields, models, _ _logger = logging.getLogger(__name__) @@ -37,6 +39,10 @@ class WebHook(models.Model): help='This is the URI that is used to call the web hook externally.', compute='_compute_uri', ) + token_id = fields.Many2one( + string='Token', + comodel_name='web.hook.token', + ) @api.model def _get_interface_types(self): @@ -72,20 +78,30 @@ def search_by_slug(self, slug): return self.browse(record_id) @api.multi - def receive(self, data=None): + def receive(self, data=None, data_string=None): """This method is used to receive a web hook. - It simply passes the received data to the underlying interface's - ``receive`` method for processing, and returns the result. The - result returned by the interface must be JSON serializable. + First it extracts the token, then validates using ``token.validate`` + and raises as ``Unauthorized`` if it is invalid. It then passes the + received data to the underlying interface's ``receive`` method for + processing, and returns the result. The result returned by the + interface must be JSON serializable. Args: - data (dict, optional): Data to pass to the hook's ``receive`` - method. + data (dict, optional): Parsed data that was received in the + request. + data_string (str, optional): The raw data that was received in the + request body. Returns: mixed: A JSON serializable return from the interface's ``receive`` method. """ self.ensure_one() - return self.interface.receive() + token = self.interface.extract_token(data) + if not self.token_id.validate(token, data, data_string): + raise Unauthorized(_( + 'The request could not be processed: ' + 'An invalid token was received.' + )) + return self.interface.receive(data) diff --git a/base_web_hook/models/web_hook_adapter.py b/base_web_hook/models/web_hook_adapter.py index fe57b9d..061e4bc 100644 --- a/base_web_hook/models/web_hook_adapter.py +++ b/base_web_hook/models/web_hook_adapter.py @@ -32,3 +32,16 @@ def receive(self, data=None): mixed: A JSON serializable return, or ``None``. """ raise NotImplementedError() + + @api.multi + def extract_token(self, data=None): + """Extract the token from the data and return it. + + Args: + data (dict, optional): Data that was received with the hook. + + Returns: + mixed: The token data. Should be compatible with the hook's token + interface (the ``token`` parameter of ``token_id.validate``). + """ + raise NotImplementedError() diff --git a/base_web_hook/models/web_hook_generic.py b/base_web_hook/models/web_hook_generic.py deleted file mode 100644 index 0ae04d1..0000000 --- a/base_web_hook/models/web_hook_generic.py +++ /dev/null @@ -1,28 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2017 LasLabs Inc. -# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). - -from odoo import api, fields, models - - -class WebHookGeneric(models.Model): - """This is the model that should be inherited for new web hooks.""" - - _name = 'web.hook.generic' - _inherit = 'web.hook.adapter' - _description = 'Web Hook - Generic' - - @api.multi - def receive(self, data=None): - """This should be overridden by inherited models to receive web hooks. - - It can expect a singleton, although can ``self.ensure_one()`` if - desired. - - Args: - data (dict, optional): Data that was received with the hook. - - Returns: - mixed: A JSON serializable return, or ``None``. - """ - raise NotImplementedError() diff --git a/base_web_hook/models/web_hook_token.py b/base_web_hook/models/web_hook_token.py new file mode 100644 index 0000000..3700839 --- /dev/null +++ b/base_web_hook/models/web_hook_token.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import random +import string + +from odoo import api, fields, models + + +class WebHookToken(models.Model): + """This represents a generic token for use in a secure web hook exchange. + + It serves as an interface for token adapters. No logic should need to be + added to this model in inherited modules. + """ + + _name = 'web.hook.token' + _description = 'Web Hook Token' + + hook_id = fields.Many2one( + string='Hook', + comodel_name='web.hook', + required=True, + ondelete='cascade', + ) + token = fields.Reference( + selection='_get_token_types', + readonly=True, + help='This is the token used for hook authentication. It is ' + 'created automatically upon creation of the web hook, and ' + 'is also deleted with it.', + ) + token_type = fields.Selection( + selection='_get_token_types', + required=True, + ) + secret = fields.Char( + help='This is the secret that is configured for the token exchange. ' + 'This configuration is typically performed when setting the ' + 'token up in the remote system. For ease, a secure random value ' + 'has been provided as a default.', + default=lambda s: s._default_secret(), + ) + + @api.model + def _get_token_types(self): + """Return the web hook token interface models that are installed.""" + adapter = self.env['web.hook.token.adapter'] + return [ + (m, self.env[m]._description) for m in adapter._inherit_children + ] + + @api.model + def _default_secret(self, length=254): + characters = string.printable.split()[0] + return ''.join( + random.choice(characters) for _ in range(length) + ) + + @api.multi + def validate(self, token, data=None, data_string=None): + """This method is used to validate a web hook. + + It simply passes the received data to the underlying token's + ``validate`` method for processing, and returns the result. + + Args: + token (mixed): The "secure" token string that should be validated + against the dataset. Typically a string. + data (dict, optional): Parsed data that was received with the + request. + data_string (str, optional): Raw form data that was received in + the request. This is useful for computation of hashes, because + Python dictionaries do not maintain sort order and thus are + useless for crypto. + + Returns: + bool: If the token is valid or not. + """ + self.ensure_one() + return self.token.validate(token, data, data_string) diff --git a/base_web_hook/models/web_hook_token_adapter.py b/base_web_hook/models/web_hook_token_adapter.py new file mode 100644 index 0000000..1398657 --- /dev/null +++ b/base_web_hook/models/web_hook_token_adapter.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import api, fields, models + + +class WebHookTokenAdapter(models.AbstractModel): + """This should be inherited by all token interfaces.""" + + _name = 'web.hook.token.adapter' + _description = 'Web Hook Token Adapter' + + token_id = fields.Many2one( + string='Token', + comodel_name='web.hook.token', + required=True, + ondelete='cascade', + ) + + @api.multi + def validate(self, token_string, data, data_string): + """Return ``True`` if the token is valid. Otherwise, ``False``. + + Child models should inherit this method to provide token validation + logic. + + Args: + token_string (str): The "secure" token string that should be + validated against the dataset. + data (dict, optional): Parsed data that was received with the + request. + data_string (str, optional): Raw form data that was received in + the request. This is useful for computation of hashes, because + Python dictionaries do not maintain sort order and thus are + useless for crypto. + + Returns: + bool: If the token is valid or not. + """ + raise NotImplementedError() diff --git a/base_web_hook/models/web_hook_token_plain.py b/base_web_hook/models/web_hook_token_plain.py new file mode 100644 index 0000000..eb5258f --- /dev/null +++ b/base_web_hook/models/web_hook_token_plain.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import api, models + + +class WebHookTokenPlain(models.Model): + """This is a plain text token.""" + + _name = 'web.hook.token.plain' + _inherit = 'web.hook.token.adapter' + _description = 'Web Hook Token - Plain' + + @api.multi + def validate(self, token_string, _, _): + """Return ``True`` if the received token is the same as configured. + """ + return token_string == self.token_id.secret diff --git a/base_web_hook/models/website.py b/base_web_hook/models/website.py new file mode 100644 index 0000000..078074b --- /dev/null +++ b/base_web_hook/models/website.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import api, fields, models + + +class Website(models.Model): + _inherit = 'website' + + + From adcb447b9acc09379c2c4ede8239c889b9283648 Mon Sep 17 00:00:00 2001 From: Dave Lasley Date: Wed, 11 Oct 2017 12:45:16 -0700 Subject: [PATCH 04/20] Add headers to token verification --- base_web_hook/controllers/main.py | 6 +++++- base_web_hook/models/web_hook.py | 6 ++++-- base_web_hook/models/web_hook_token.py | 12 ++++++++++-- base_web_hook/models/web_hook_token_adapter.py | 9 +++++---- base_web_hook/models/web_hook_token_plain.py | 2 +- 5 files changed, 25 insertions(+), 10 deletions(-) diff --git a/base_web_hook/controllers/main.py b/base_web_hook/controllers/main.py index c9e4c6a..c581411 100644 --- a/base_web_hook/controllers/main.py +++ b/base_web_hook/controllers/main.py @@ -14,4 +14,8 @@ class WebHookController(http.Controller): ) def receive(self, slug, **kwargs): hook = self.env['web.hook'].search_by_slug(slug) - return hook.receive(kwargs, http.request.httprequest.get_data()) + return hook.receive( + data=kwargs, + data_string=http.request.httprequest.get_data(), + headers=http.request.httprequest.headers, + ) diff --git a/base_web_hook/models/web_hook.py b/base_web_hook/models/web_hook.py index 1d1e8b5..7e2956b 100644 --- a/base_web_hook/models/web_hook.py +++ b/base_web_hook/models/web_hook.py @@ -78,7 +78,7 @@ def search_by_slug(self, slug): return self.browse(record_id) @api.multi - def receive(self, data=None, data_string=None): + def receive(self, data=None, data_string=None, headers=None): """This method is used to receive a web hook. First it extracts the token, then validates using ``token.validate`` @@ -92,6 +92,8 @@ def receive(self, data=None, data_string=None): request. data_string (str, optional): The raw data that was received in the request body. + headers (dict, optional): Dictionary of headers that were + received with the request. Returns: mixed: A JSON serializable return from the interface's @@ -99,7 +101,7 @@ def receive(self, data=None, data_string=None): """ self.ensure_one() token = self.interface.extract_token(data) - if not self.token_id.validate(token, data, data_string): + if not self.token_id.validate(token, data, data_string, headers): raise Unauthorized(_( 'The request could not be processed: ' 'An invalid token was received.' diff --git a/base_web_hook/models/web_hook_token.py b/base_web_hook/models/web_hook_token.py index 3700839..c610809 100644 --- a/base_web_hook/models/web_hook_token.py +++ b/base_web_hook/models/web_hook_token.py @@ -59,7 +59,7 @@ def _default_secret(self, length=254): ) @api.multi - def validate(self, token, data=None, data_string=None): + def validate(self, token, data=None, data_string=None, headers=None): """This method is used to validate a web hook. It simply passes the received data to the underlying token's @@ -74,9 +74,17 @@ def validate(self, token, data=None, data_string=None): the request. This is useful for computation of hashes, because Python dictionaries do not maintain sort order and thus are useless for crypto. + headers (dict, optional): Dictionary of headers that were + received with the request. Returns: bool: If the token is valid or not. """ self.ensure_one() - return self.token.validate(token, data, data_string) + if data is None: + data = {} + if data_string is None: + data_string = '' + if headers is None: + headers = {} + return self.token.validate(token, data, data_string, headers) diff --git a/base_web_hook/models/web_hook_token_adapter.py b/base_web_hook/models/web_hook_token_adapter.py index 1398657..c5193ef 100644 --- a/base_web_hook/models/web_hook_token_adapter.py +++ b/base_web_hook/models/web_hook_token_adapter.py @@ -19,7 +19,7 @@ class WebHookTokenAdapter(models.AbstractModel): ) @api.multi - def validate(self, token_string, data, data_string): + def validate(self, token_string, data, data_string, headers): """Return ``True`` if the token is valid. Otherwise, ``False``. Child models should inherit this method to provide token validation @@ -28,12 +28,13 @@ def validate(self, token_string, data, data_string): Args: token_string (str): The "secure" token string that should be validated against the dataset. - data (dict, optional): Parsed data that was received with the - request. - data_string (str, optional): Raw form data that was received in + data (dict): Parsed data that was received with the request. + data_string (str): Raw form data that was received in the request. This is useful for computation of hashes, because Python dictionaries do not maintain sort order and thus are useless for crypto. + headers (dict): Dictionary of headers that were received with the + request. Returns: bool: If the token is valid or not. diff --git a/base_web_hook/models/web_hook_token_plain.py b/base_web_hook/models/web_hook_token_plain.py index eb5258f..99a27cd 100644 --- a/base_web_hook/models/web_hook_token_plain.py +++ b/base_web_hook/models/web_hook_token_plain.py @@ -13,7 +13,7 @@ class WebHookTokenPlain(models.Model): _description = 'Web Hook Token - Plain' @api.multi - def validate(self, token_string, _, _): + def validate(self, token_string, *_, **__): """Return ``True`` if the received token is the same as configured. """ return token_string == self.token_id.secret From c93ca2a86b8d0e8e175846090457116d0dc47cd6 Mon Sep 17 00:00:00 2001 From: Dave Lasley Date: Wed, 11 Oct 2017 13:25:43 -0700 Subject: [PATCH 05/20] Add inherits on adapters --- base_web_hook/models/web_hook.py | 14 +++++++++++++- base_web_hook/models/web_hook_adapter.py | 10 ++++++---- base_web_hook/models/web_hook_token_adapter.py | 1 + 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/base_web_hook/models/web_hook.py b/base_web_hook/models/web_hook.py index 7e2956b..ed9fe8f 100644 --- a/base_web_hook/models/web_hook.py +++ b/base_web_hook/models/web_hook.py @@ -99,11 +99,23 @@ def receive(self, data=None, data_string=None, headers=None): mixed: A JSON serializable return from the interface's ``receive`` method. """ + self.ensure_one() - token = self.interface.extract_token(data) + + # Convert optional args to proper types for interfaces + if data is None: + data = {} + if data_string is None: + data_string = '' + if headers is None: + headers = {} + + token = self.interface.extract_token(data, headers) + if not self.token_id.validate(token, data, data_string, headers): raise Unauthorized(_( 'The request could not be processed: ' 'An invalid token was received.' )) + return self.interface.receive(data) diff --git a/base_web_hook/models/web_hook_adapter.py b/base_web_hook/models/web_hook_adapter.py index 061e4bc..82e9d87 100644 --- a/base_web_hook/models/web_hook_adapter.py +++ b/base_web_hook/models/web_hook_adapter.py @@ -10,6 +10,7 @@ class WebHookAdapter(models.AbstractModel): _name = 'web.hook.adapter' _description = 'Web Hook Adapter' + _inherits = {'web.hook': 'hook_id'} hook_id = fields.Many2one( string='Hook', @@ -19,14 +20,14 @@ class WebHookAdapter(models.AbstractModel): ) @api.multi - def receive(self, data=None): + def receive(self, data): """This should be overridden by inherited models to receive web hooks. It can expect a singleton, although can ``self.ensure_one()`` if desired. Args: - data (dict, optional): Data that was received with the hook. + data (dict): Data that was received with the hook. Returns: mixed: A JSON serializable return, or ``None``. @@ -34,11 +35,12 @@ def receive(self, data=None): raise NotImplementedError() @api.multi - def extract_token(self, data=None): + def extract_token(self, data, headers): """Extract the token from the data and return it. Args: - data (dict, optional): Data that was received with the hook. + data (dict): Data that was received with the hook. + headers (dict): Headers that were received with the request. Returns: mixed: The token data. Should be compatible with the hook's token diff --git a/base_web_hook/models/web_hook_token_adapter.py b/base_web_hook/models/web_hook_token_adapter.py index c5193ef..9a19074 100644 --- a/base_web_hook/models/web_hook_token_adapter.py +++ b/base_web_hook/models/web_hook_token_adapter.py @@ -10,6 +10,7 @@ class WebHookTokenAdapter(models.AbstractModel): _name = 'web.hook.token.adapter' _description = 'Web Hook Token Adapter' + _inherits = {'web.hook.token': 'token_id'} token_id = fields.Many2one( string='Token', From 8fe5b0894c1a6fc61a0ecca6f1a45f4db2e9de31 Mon Sep 17 00:00:00 2001 From: Dave Lasley Date: Wed, 11 Oct 2017 13:33:50 -0700 Subject: [PATCH 06/20] Usability --- base_web_hook/models/web_hook_token.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/base_web_hook/models/web_hook_token.py b/base_web_hook/models/web_hook_token.py index c610809..56c6a14 100644 --- a/base_web_hook/models/web_hook_token.py +++ b/base_web_hook/models/web_hook_token.py @@ -59,7 +59,7 @@ def _default_secret(self, length=254): ) @api.multi - def validate(self, token, data=None, data_string=None, headers=None): + def validate(self, token, data, data_string, headers): """This method is used to validate a web hook. It simply passes the received data to the underlying token's @@ -68,23 +68,17 @@ def validate(self, token, data=None, data_string=None, headers=None): Args: token (mixed): The "secure" token string that should be validated against the dataset. Typically a string. - data (dict, optional): Parsed data that was received with the + data (dict): Parsed data that was received with the request. - data_string (str, optional): Raw form data that was received in + data_string (str): Raw form data that was received in the request. This is useful for computation of hashes, because Python dictionaries do not maintain sort order and thus are useless for crypto. - headers (dict, optional): Dictionary of headers that were + headers (dict): Dictionary of headers that were received with the request. Returns: bool: If the token is valid or not. """ self.ensure_one() - if data is None: - data = {} - if data_string is None: - data_string = '' - if headers is None: - headers = {} return self.token.validate(token, data, data_string, headers) From c2e781c96ca2fd362394b3f57236925a2fa80fef Mon Sep 17 00:00:00 2001 From: Dave Lasley Date: Wed, 11 Oct 2017 13:35:52 -0700 Subject: [PATCH 07/20] Send headers to hook receive --- base_web_hook/__init__.py | 3 ++- base_web_hook/controllers/__init__.py | 2 +- base_web_hook/models/web_hook.py | 2 +- base_web_hook/models/web_hook_adapter.py | 3 ++- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/base_web_hook/__init__.py b/base_web_hook/__init__.py index c484ab2..489ebcc 100644 --- a/base_web_hook/__init__.py +++ b/base_web_hook/__init__.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). -from .hooks import post_load +from . import controllers +from . import models diff --git a/base_web_hook/controllers/__init__.py b/base_web_hook/controllers/__init__.py index c484ab2..c02fd56 100644 --- a/base_web_hook/controllers/__init__.py +++ b/base_web_hook/controllers/__init__.py @@ -1,4 +1,4 @@ # -*- coding: utf-8 -*- # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). -from .hooks import post_load +from . import main diff --git a/base_web_hook/models/web_hook.py b/base_web_hook/models/web_hook.py index ed9fe8f..253aada 100644 --- a/base_web_hook/models/web_hook.py +++ b/base_web_hook/models/web_hook.py @@ -118,4 +118,4 @@ def receive(self, data=None, data_string=None, headers=None): 'An invalid token was received.' )) - return self.interface.receive(data) + return self.interface.receive(data, headers) diff --git a/base_web_hook/models/web_hook_adapter.py b/base_web_hook/models/web_hook_adapter.py index 82e9d87..5c42b5a 100644 --- a/base_web_hook/models/web_hook_adapter.py +++ b/base_web_hook/models/web_hook_adapter.py @@ -20,7 +20,7 @@ class WebHookAdapter(models.AbstractModel): ) @api.multi - def receive(self, data): + def receive(self, data, headers): """This should be overridden by inherited models to receive web hooks. It can expect a singleton, although can ``self.ensure_one()`` if @@ -28,6 +28,7 @@ def receive(self, data): Args: data (dict): Data that was received with the hook. + headers (dict): Headers that were received with the request. Returns: mixed: A JSON serializable return, or ``None``. From 24004e3c6cf89ef604935adc1e06ba3d0d456e8d Mon Sep 17 00:00:00 2001 From: Dave Lasley Date: Wed, 11 Oct 2017 16:29:27 -0700 Subject: [PATCH 08/20] Views and usability --- base_web_hook/__manifest__.py | 3 ++ base_web_hook/models/web_hook.py | 36 ++++++++++++++++- base_web_hook/models/web_hook_token.py | 27 +------------ base_web_hook/views/web_hook_view.xml | 55 ++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 26 deletions(-) create mode 100644 base_web_hook/views/web_hook_view.xml diff --git a/base_web_hook/__manifest__.py b/base_web_hook/__manifest__.py index 4ba6026..ebfb5e0 100644 --- a/base_web_hook/__manifest__.py +++ b/base_web_hook/__manifest__.py @@ -18,4 +18,7 @@ 'depends': [ 'base_json_request', ], + 'data': [ + 'views/web_hook_view.xml', + ], } diff --git a/base_web_hook/models/web_hook.py b/base_web_hook/models/web_hook.py index 253aada..f0ae781 100644 --- a/base_web_hook/models/web_hook.py +++ b/base_web_hook/models/web_hook.py @@ -3,6 +3,8 @@ # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). import logging +import random +import string from werkzeug.exceptions import Unauthorized @@ -43,6 +45,23 @@ class WebHook(models.Model): string='Token', comodel_name='web.hook.token', ) + token_type = fields.Selection( + selection=lambda s: s._get_token_types(), + required=True, + ) + token_secret = fields.Char( + help='This is the secret that is configured for the token exchange. ' + 'This configuration is typically performed when setting the ' + 'token up in the remote system. For ease, a secure random value ' + 'has been provided as a default.', + default=lambda s: s._default_secret(), + ) + company_id = fields.Many2one( + string='Company', + comodel_name='res.company', + required=True, + default=lambda s: s.env.user.company_id.id, + ) @api.model def _get_interface_types(self): @@ -53,7 +72,7 @@ def _get_interface_types(self): ] @api.multi - @api.depends('slug') + @api.depends('name') def _compute_uri(self): for record in self: if isinstance(record.id, models.NewId): @@ -62,6 +81,21 @@ def _compute_uri(self): name = slugify(record.name or '').strip().strip('-') record.uri = '/base_web_hook/%s-%d' % (name, record.id) + @api.model + def _get_token_types(self): + """Return the web hook token interface models that are installed.""" + adapter = self.env['web.hook.token.adapter'] + return [ + (m, self.env[m]._description) for m in adapter._inherit_children + ] + + @api.model + def _default_secret(self, length=254): + characters = string.printable.split()[0] + return ''.join( + random.choice(characters) for _ in range(length) + ) + @api.model def create(self, vals): """Create the interface for the record and assign to ``interface``.""" diff --git a/base_web_hook/models/web_hook_token.py b/base_web_hook/models/web_hook_token.py index 56c6a14..9c9ff9c 100644 --- a/base_web_hook/models/web_hook_token.py +++ b/base_web_hook/models/web_hook_token.py @@ -2,9 +2,6 @@ # Copyright 2017 LasLabs Inc. # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). -import random -import string - from odoo import api, fields, models @@ -32,32 +29,12 @@ class WebHookToken(models.Model): 'is also deleted with it.', ) token_type = fields.Selection( - selection='_get_token_types', - required=True, + related='hook_id.token_type', ) secret = fields.Char( - help='This is the secret that is configured for the token exchange. ' - 'This configuration is typically performed when setting the ' - 'token up in the remote system. For ease, a secure random value ' - 'has been provided as a default.', - default=lambda s: s._default_secret(), + related='hook_id.token_secret', ) - @api.model - def _get_token_types(self): - """Return the web hook token interface models that are installed.""" - adapter = self.env['web.hook.token.adapter'] - return [ - (m, self.env[m]._description) for m in adapter._inherit_children - ] - - @api.model - def _default_secret(self, length=254): - characters = string.printable.split()[0] - return ''.join( - random.choice(characters) for _ in range(length) - ) - @api.multi def validate(self, token, data, data_string, headers): """This method is used to validate a web hook. diff --git a/base_web_hook/views/web_hook_view.xml b/base_web_hook/views/web_hook_view.xml new file mode 100644 index 0000000..713df6f --- /dev/null +++ b/base_web_hook/views/web_hook_view.xml @@ -0,0 +1,55 @@ + + + + + + + web.hook.form + web.hook + +
+ +
+ + + + + + + + + + + + + + + + + + web.hook.tree + web.hook + + + + + + + + + + + Web Hooks + web.hook + form + tree,form + + + + + From 87d90a3f20e572e2967f235fd5ecdab8f3339e9a Mon Sep 17 00:00:00 2001 From: Dave Lasley Date: Sat, 14 Oct 2017 14:19:14 -0700 Subject: [PATCH 09/20] Add full URI --- base_web_hook/models/web_hook.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/base_web_hook/models/web_hook.py b/base_web_hook/models/web_hook.py index f0ae781..dad9936 100644 --- a/base_web_hook/models/web_hook.py +++ b/base_web_hook/models/web_hook.py @@ -37,6 +37,12 @@ class WebHook(models.Model): selection='_get_interface_types', required=True, ) + uri_path = fields.Char( + help='This is the URI path that is used to call the web hook.', + compute='_compute_uri_path', + store=True, + readonly=True, + ) uri = fields.Char( help='This is the URI that is used to call the web hook externally.', compute='_compute_uri', @@ -73,13 +79,20 @@ def _get_interface_types(self): @api.multi @api.depends('name') - def _compute_uri(self): + def _compute_uri_path(self): for record in self: if isinstance(record.id, models.NewId): # Do not compute slug until saved continue name = slugify(record.name or '').strip().strip('-') - record.uri = '/base_web_hook/%s-%d' % (name, record.id) + record.uri_path = '/base_web_hook/%s-%d' % (name, record.id) + + @api.multi + @api.depends('uri_path') + def _compute_uri(self): + base_uri = self.env['ir.config_parameter'].get_param('web.base.url') + for record in self.filtered(lambda r: r.uri_path): + record.uri = '%s%s' % (base_uri, record.uri_path) @api.model def _get_token_types(self): From 616ade0812e9fba01a1afcfe7edccf48b59b92f8 Mon Sep 17 00:00:00 2001 From: Dave Lasley Date: Mon, 16 Oct 2017 10:23:37 -0700 Subject: [PATCH 10/20] Add auth note in receive --- base_web_hook/models/web_hook_adapter.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/base_web_hook/models/web_hook_adapter.py b/base_web_hook/models/web_hook_adapter.py index 5c42b5a..c187661 100644 --- a/base_web_hook/models/web_hook_adapter.py +++ b/base_web_hook/models/web_hook_adapter.py @@ -23,6 +23,8 @@ class WebHookAdapter(models.AbstractModel): def receive(self, data, headers): """This should be overridden by inherited models to receive web hooks. + The data has already been authenticated at this point in the workflow. + It can expect a singleton, although can ``self.ensure_one()`` if desired. From 854e366379bb928fd8eb48bef07da5c8132118c1 Mon Sep 17 00:00:00 2001 From: Dave Lasley Date: Mon, 16 Oct 2017 12:15:33 -0700 Subject: [PATCH 11/20] Allow circumvention of automatic adapter creation on hook creation --- base_web_hook/models/web_hook.py | 9 +++++---- base_web_hook/models/web_hook_token_adapter.py | 11 +++++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/base_web_hook/models/web_hook.py b/base_web_hook/models/web_hook.py index dad9936..a53eec2 100644 --- a/base_web_hook/models/web_hook.py +++ b/base_web_hook/models/web_hook.py @@ -113,10 +113,11 @@ def _default_secret(self, length=254): def create(self, vals): """Create the interface for the record and assign to ``interface``.""" record = super(WebHook, self).create(vals) - interface = self.env[vals['interface_type']].create({ - 'hook_id': record.id, - }) - record.interface = interface + if not self._context.get('web_hook_no_interface'): + interface = self.env[vals['interface_type']].create({ + 'hook_id': record.id, + }) + record.interface = interface return record @api.model diff --git a/base_web_hook/models/web_hook_token_adapter.py b/base_web_hook/models/web_hook_token_adapter.py index 9a19074..d5afa08 100644 --- a/base_web_hook/models/web_hook_token_adapter.py +++ b/base_web_hook/models/web_hook_token_adapter.py @@ -19,6 +19,17 @@ class WebHookTokenAdapter(models.AbstractModel): ondelete='cascade', ) + @api.model + def create(self, vals): + """If creating from the adapter level, circumvent adapter creation. + + An adapter is implicitly created and managed from within the + ``web.hook`` model. This is desirable in most instances, but it should + also be possible to create an adapter directly. + """ + context_self = self.with_context(web_hook_no_interface=True) + return super(WebHookTokenAdapter, context_self).create(vals) + @api.multi def validate(self, token_string, data, data_string, headers): """Return ``True`` if the token is valid. Otherwise, ``False``. From 9057d8fc1ab151c880ef39c6475fc6a8e7bb1a86 Mon Sep 17 00:00:00 2001 From: Dave Lasley Date: Tue, 17 Oct 2017 12:42:06 -0700 Subject: [PATCH 12/20] Usability updates & add a request bin --- base_web_hook/models/__init__.py | 1 + base_web_hook/models/web_hook_adapter.py | 11 +++++ base_web_hook/models/web_hook_request_bin.py | 46 +++++++++++++++++++ .../models/web_hook_request_bin_request.py | 24 ++++++++++ .../models/web_hook_token_adapter.py | 11 ----- base_web_hook/models/web_hook_token_plain.py | 12 ++--- base_web_hook/models/web_hook_token_user.py | 19 ++++++++ 7 files changed, 107 insertions(+), 17 deletions(-) create mode 100644 base_web_hook/models/web_hook_request_bin.py create mode 100644 base_web_hook/models/web_hook_request_bin_request.py create mode 100644 base_web_hook/models/web_hook_token_user.py diff --git a/base_web_hook/models/__init__.py b/base_web_hook/models/__init__.py index 0b92e76..581d920 100644 --- a/base_web_hook/models/__init__.py +++ b/base_web_hook/models/__init__.py @@ -6,3 +6,4 @@ from . import web_hook_token from . import web_hook_token_adapter from . import web_hook_token_plain +from . import web_hook_token_user diff --git a/base_web_hook/models/web_hook_adapter.py b/base_web_hook/models/web_hook_adapter.py index c187661..0a6067f 100644 --- a/base_web_hook/models/web_hook_adapter.py +++ b/base_web_hook/models/web_hook_adapter.py @@ -19,6 +19,17 @@ class WebHookAdapter(models.AbstractModel): ondelete='cascade', ) + @api.model + def create(self, vals): + """If creating from the adapter level, circumvent adapter creation. + + An adapter is implicitly created and managed from within the + ``web.hook`` model. This is desirable in most instances, but it should + also be possible to create an adapter directly. + """ + context_self = self.with_context(web_hook_no_interface=True) + return super(WebHookAdapter, context_self).create(vals) + @api.multi def receive(self, data, headers): """This should be overridden by inherited models to receive web hooks. diff --git a/base_web_hook/models/web_hook_request_bin.py b/base_web_hook/models/web_hook_request_bin.py new file mode 100644 index 0000000..cc08390 --- /dev/null +++ b/base_web_hook/models/web_hook_request_bin.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import api, fields, models +from odoo.http import request + + +class WebHookRequestBin(models.Model): + """This is an abstract Request Bin to be used for testing web hooks. + + It simply saves the request data for later evaluation. This is incredibly + useful when implementing web hook from undocumented sources. + """ + + _name = 'web.hook.request.bin' + _description = 'Web Hook - Request Bin' + _inherit = 'web.hook.adapter' + + request_ids = fields.One2many( + string='Requests', + comodel_name='web.hook.request.bin.request', + inverse_name='bin_id', + ) + + @api.multi + def receive(self, data, headers): + """Capture the request. + + Args: + data (dict): Data that was received with the hook. + headers (dict): Headers that were received with the request. + """ + self.env['web.hook.request.bin.request'].create({ + 'bin_id': self.id, + 'uri': request.httprequest.url, + 'method': request.httprequest.method, + 'headers': headers, + 'data': data, + 'cookies': request.httprequest.cookies, + }) + + @api.multi + def extract_token(self, data, headers): + """No tokens are required in this implementation.""" + return True diff --git a/base_web_hook/models/web_hook_request_bin_request.py b/base_web_hook/models/web_hook_request_bin_request.py new file mode 100644 index 0000000..5a2e076 --- /dev/null +++ b/base_web_hook/models/web_hook_request_bin_request.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import fields, models + + +class WebHookRequestBinRequest(models.Model): + """This is a single request, saved by the RequestBin""" + + _name = 'web.hook.request.bin.request' + _description = 'Web Hook Request Bin Request' + + bin_id = fields.Many2one( + string='Request Bin', + comodel_name='web.hook.request.bin', + required=True, + ondelete='cascade', + ) + uri = fields.Char() + method = fields.Char() + headers = fields.Serialized() + data = fields.Serialized() + cookies = fields.Serialized() diff --git a/base_web_hook/models/web_hook_token_adapter.py b/base_web_hook/models/web_hook_token_adapter.py index d5afa08..9a19074 100644 --- a/base_web_hook/models/web_hook_token_adapter.py +++ b/base_web_hook/models/web_hook_token_adapter.py @@ -19,17 +19,6 @@ class WebHookTokenAdapter(models.AbstractModel): ondelete='cascade', ) - @api.model - def create(self, vals): - """If creating from the adapter level, circumvent adapter creation. - - An adapter is implicitly created and managed from within the - ``web.hook`` model. This is desirable in most instances, but it should - also be possible to create an adapter directly. - """ - context_self = self.with_context(web_hook_no_interface=True) - return super(WebHookTokenAdapter, context_self).create(vals) - @api.multi def validate(self, token_string, data, data_string, headers): """Return ``True`` if the token is valid. Otherwise, ``False``. diff --git a/base_web_hook/models/web_hook_token_plain.py b/base_web_hook/models/web_hook_token_plain.py index 99a27cd..28a384d 100644 --- a/base_web_hook/models/web_hook_token_plain.py +++ b/base_web_hook/models/web_hook_token_plain.py @@ -5,15 +5,15 @@ from odoo import api, models -class WebHookTokenPlain(models.Model): - """This is a plain text token.""" +class WebHookTokenUser(models.Model): + """This is a token that requires a valid user session.""" - _name = 'web.hook.token.plain' + _name = 'web.hook.token.user' _inherit = 'web.hook.token.adapter' - _description = 'Web Hook Token - Plain' + _description = 'Web Hook Token - User Session' @api.multi - def validate(self, token_string, *_, **__): + def validate(self, *_, **__): """Return ``True`` if the received token is the same as configured. """ - return token_string == self.token_id.secret + return bool(self.env.user) diff --git a/base_web_hook/models/web_hook_token_user.py b/base_web_hook/models/web_hook_token_user.py new file mode 100644 index 0000000..99a27cd --- /dev/null +++ b/base_web_hook/models/web_hook_token_user.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import api, models + + +class WebHookTokenPlain(models.Model): + """This is a plain text token.""" + + _name = 'web.hook.token.plain' + _inherit = 'web.hook.token.adapter' + _description = 'Web Hook Token - Plain' + + @api.multi + def validate(self, token_string, *_, **__): + """Return ``True`` if the received token is the same as configured. + """ + return token_string == self.token_id.secret From 162e6189e720bd9b63a71e5c3b5ee07d0c70c470 Mon Sep 17 00:00:00 2001 From: Dave Lasley Date: Tue, 17 Oct 2017 13:52:44 -0700 Subject: [PATCH 13/20] Remove unused model and add user to request bin --- base_web_hook/models/web_hook_request_bin_request.py | 5 +++++ base_web_hook/models/website.py | 12 ------------ 2 files changed, 5 insertions(+), 12 deletions(-) delete mode 100644 base_web_hook/models/website.py diff --git a/base_web_hook/models/web_hook_request_bin_request.py b/base_web_hook/models/web_hook_request_bin_request.py index 5a2e076..1fc1bc2 100644 --- a/base_web_hook/models/web_hook_request_bin_request.py +++ b/base_web_hook/models/web_hook_request_bin_request.py @@ -22,3 +22,8 @@ class WebHookRequestBinRequest(models.Model): headers = fields.Serialized() data = fields.Serialized() cookies = fields.Serialized() + user_id = fields.Many2one( + string='User', + comodel_name='res.users', + default=lambda s: s.env.user.id, + ) diff --git a/base_web_hook/models/website.py b/base_web_hook/models/website.py deleted file mode 100644 index 078074b..0000000 --- a/base_web_hook/models/website.py +++ /dev/null @@ -1,12 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2017 LasLabs Inc. -# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). - -from odoo import api, fields, models - - -class Website(models.Model): - _inherit = 'website' - - - From e310be4dc7deb28083f6aa3162b8d95164d2e109 Mon Sep 17 00:00:00 2001 From: Dave Lasley Date: Tue, 17 Oct 2017 14:15:19 -0700 Subject: [PATCH 14/20] Add an HTTP endpoint and an always true token --- base_web_hook/controllers/main.py | 15 +++++++-- base_web_hook/models/__init__.py | 12 ++++++- base_web_hook/models/web_hook.py | 35 ++++++++++++++++----- base_web_hook/models/web_hook_token_none.py | 19 +++++++++++ base_web_hook/views/web_hook_view.xml | 6 ++-- 5 files changed, 75 insertions(+), 12 deletions(-) create mode 100644 base_web_hook/models/web_hook_token_none.py diff --git a/base_web_hook/controllers/main.py b/base_web_hook/controllers/main.py index c581411..c67d2eb 100644 --- a/base_web_hook/controllers/main.py +++ b/base_web_hook/controllers/main.py @@ -8,11 +8,22 @@ class WebHookController(http.Controller): @http.route( - ['/base_web_hook/'], + ['/base_web_hook/json/.json'], type='json', auth='none', ) - def receive(self, slug, **kwargs): + def json_receive(self, *args, **kwargs): + return self._receive(*args, **kwargs) + + @http.route( + ['/base_web_hook/'], + type='http', + auth='none', + ) + def http_receive(self, *args, **kwargs): + return self._receive(*args, **kwargs) + + def _receive(self, slug, **kwargs): hook = self.env['web.hook'].search_by_slug(slug) return hook.receive( data=kwargs, diff --git a/base_web_hook/models/__init__.py b/base_web_hook/models/__init__.py index 581d920..85b1ad1 100644 --- a/base_web_hook/models/__init__.py +++ b/base_web_hook/models/__init__.py @@ -1,9 +1,19 @@ # -*- coding: utf-8 -*- # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +# Concrete models from . import web_hook -from . import web_hook_adapter from . import web_hook_token + +# Adapters +from . import web_hook_adapter from . import web_hook_token_adapter + +# Token Interfaces +from . import web_hook_token_none from . import web_hook_token_plain from . import web_hook_token_user + +# Request Bin Hook +from . import web_hook_request_bin +from . import web_hook_request_bin_request diff --git a/base_web_hook/models/web_hook.py b/base_web_hook/models/web_hook.py index a53eec2..cd07bea 100644 --- a/base_web_hook/models/web_hook.py +++ b/base_web_hook/models/web_hook.py @@ -37,14 +37,31 @@ class WebHook(models.Model): selection='_get_interface_types', required=True, ) - uri_path = fields.Char( - help='This is the URI path that is used to call the web hook.', + uri_path_json = fields.Char( + help='This is the URI path that is used to call the web hook with ' + 'a JSON request.', compute='_compute_uri_path', store=True, readonly=True, ) - uri = fields.Char( - help='This is the URI that is used to call the web hook externally.', + uri_path_http = fields.Char( + help='This is the URI path that is used to call the web hook with ' + 'a form encoded request.', + compute='_compute_uri_path', + store=True, + readonly=True, + ) + uri_json = fields.Char( + string='JSON Endpoint', + help='This is the URI that is used to call the web hook externally. ' + 'This endpoint only accepts requests with a JSON mime-type.', + compute='_compute_uri', + ) + uri_http = fields.Char( + string='Form-Encoded Endpoint', + help='This is the URI that is used to call the web hook externally. ' + 'This endpoint should be used with requests that are form ' + 'encoded, not JSON.', compute='_compute_uri', ) token_id = fields.Many2one( @@ -85,14 +102,18 @@ def _compute_uri_path(self): # Do not compute slug until saved continue name = slugify(record.name or '').strip().strip('-') - record.uri_path = '/base_web_hook/%s-%d' % (name, record.id) + record.uri_path_json = '/base_web_hook/%s-%d.json' % ( + name, record.id, + ) + record.uri_path_http = '/base_web_hook/%s-%d' % (name, record.id) @api.multi - @api.depends('uri_path') + @api.depends('uri_path_http', 'uri_path_json') def _compute_uri(self): base_uri = self.env['ir.config_parameter'].get_param('web.base.url') for record in self.filtered(lambda r: r.uri_path): - record.uri = '%s%s' % (base_uri, record.uri_path) + record.uri_json = '%s%s' % (base_uri, record.uri_path_json) + record.uri_http = '%s%s' % (base_uri, record.uri_path_http) @api.model def _get_token_types(self): diff --git a/base_web_hook/models/web_hook_token_none.py b/base_web_hook/models/web_hook_token_none.py new file mode 100644 index 0000000..ccfc188 --- /dev/null +++ b/base_web_hook/models/web_hook_token_none.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import api, models + + +class WebHookTokenNone(models.Model): + """This is a token that will validate under all circumstances.""" + + _name = 'web.hook.token.none' + _inherit = 'web.hook.token.adapter' + _description = 'Web Hook Token - None' + + @api.multi + def validate(self, token_string, *_, **__): + """Return ``True`` if the received token is the same as configured. + """ + return True diff --git a/base_web_hook/views/web_hook_view.xml b/base_web_hook/views/web_hook_view.xml index 713df6f..639d4b7 100644 --- a/base_web_hook/views/web_hook_view.xml +++ b/base_web_hook/views/web_hook_view.xml @@ -19,7 +19,8 @@ - + + @@ -34,7 +35,8 @@ - + + From 82552395366868b81e3210c426064d0b7909d2ac Mon Sep 17 00:00:00 2001 From: Dave Lasley Date: Tue, 17 Oct 2017 14:46:06 -0700 Subject: [PATCH 15/20] Bug fixes - it works! --- base_web_hook/controllers/main.py | 12 ++++++++---- base_web_hook/models/web_hook.py | 16 +++++++++++----- base_web_hook/models/web_hook_request_bin.py | 2 +- base_web_hook/models/web_hook_token.py | 12 +++++++++++- 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/base_web_hook/controllers/main.py b/base_web_hook/controllers/main.py index c67d2eb..50439ce 100644 --- a/base_web_hook/controllers/main.py +++ b/base_web_hook/controllers/main.py @@ -2,6 +2,8 @@ # Copyright 2017 LasLabs Inc. # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +import json + from odoo import http @@ -10,7 +12,7 @@ class WebHookController(http.Controller): @http.route( ['/base_web_hook/json/.json'], type='json', - auth='none', + auth='public', ) def json_receive(self, *args, **kwargs): return self._receive(*args, **kwargs) @@ -18,13 +20,15 @@ def json_receive(self, *args, **kwargs): @http.route( ['/base_web_hook/'], type='http', - auth='none', + auth='public', ) def http_receive(self, *args, **kwargs): - return self._receive(*args, **kwargs) + return json.dumps( + self._receive(*args, **kwargs), + ) def _receive(self, slug, **kwargs): - hook = self.env['web.hook'].search_by_slug(slug) + hook = http.request.env['web.hook'].search_by_slug(slug) return hook.receive( data=kwargs, data_string=http.request.httprequest.get_data(), diff --git a/base_web_hook/models/web_hook.py b/base_web_hook/models/web_hook.py index cd07bea..2d70065 100644 --- a/base_web_hook/models/web_hook.py +++ b/base_web_hook/models/web_hook.py @@ -67,6 +67,7 @@ class WebHook(models.Model): token_id = fields.Many2one( string='Token', comodel_name='web.hook.token', + readonly=True, ) token_type = fields.Selection( selection=lambda s: s._get_token_types(), @@ -111,7 +112,7 @@ def _compute_uri_path(self): @api.depends('uri_path_http', 'uri_path_json') def _compute_uri(self): base_uri = self.env['ir.config_parameter'].get_param('web.base.url') - for record in self.filtered(lambda r: r.uri_path): + for record in self.filtered(lambda r: r.uri_path_json): record.uri_json = '%s%s' % (base_uri, record.uri_path_json) record.uri_http = '%s%s' % (base_uri, record.uri_path_http) @@ -132,19 +133,24 @@ def _default_secret(self, length=254): @api.model def create(self, vals): - """Create the interface for the record and assign to ``interface``.""" + """Create the interface and token.""" record = super(WebHook, self).create(vals) if not self._context.get('web_hook_no_interface'): - interface = self.env[vals['interface_type']].create({ + record.interface = self.env[vals['interface_type']].create({ 'hook_id': record.id, }) - record.interface = interface + token = self.env['web.hook.token'].create({ + 'hook_id': record.id, + 'token_type': record.token_type, + 'secret': record.token_secret, + }) + record.token_id = token.id return record @api.model def search_by_slug(self, slug): _, record_id = slug.strip().rsplit('-', 1) - return self.browse(record_id) + return self.browse(int(record_id)) @api.multi def receive(self, data=None, data_string=None, headers=None): diff --git a/base_web_hook/models/web_hook_request_bin.py b/base_web_hook/models/web_hook_request_bin.py index cc08390..1ec7c1c 100644 --- a/base_web_hook/models/web_hook_request_bin.py +++ b/base_web_hook/models/web_hook_request_bin.py @@ -35,7 +35,7 @@ def receive(self, data, headers): 'bin_id': self.id, 'uri': request.httprequest.url, 'method': request.httprequest.method, - 'headers': headers, + 'headers': dict(headers), 'data': data, 'cookies': request.httprequest.cookies, }) diff --git a/base_web_hook/models/web_hook_token.py b/base_web_hook/models/web_hook_token.py index 9c9ff9c..6018dc6 100644 --- a/base_web_hook/models/web_hook_token.py +++ b/base_web_hook/models/web_hook_token.py @@ -22,7 +22,7 @@ class WebHookToken(models.Model): ondelete='cascade', ) token = fields.Reference( - selection='_get_token_types', + selection=lambda s: s.env['web.hook']._get_token_types(), readonly=True, help='This is the token used for hook authentication. It is ' 'created automatically upon creation of the web hook, and ' @@ -35,6 +35,16 @@ class WebHookToken(models.Model): related='hook_id.token_secret', ) + @api.model + def create(self, vals): + """Create the token.""" + record = super(WebHookToken, self).create(vals) + if not self._context.get('web_hook_no_token'): + record.token = self.env[vals['token_type']].create({ + 'token_id': record.id, + }) + return record + @api.multi def validate(self, token, data, data_string, headers): """This method is used to validate a web hook. From f203779c202ab50b8e96380a99b3ec31012305c3 Mon Sep 17 00:00:00 2001 From: Dave Lasley Date: Wed, 18 Oct 2017 14:57:52 -0700 Subject: [PATCH 16/20] Add security and remove csrf from http route --- base_web_hook/README.rst | 5 +++++ base_web_hook/__manifest__.py | 1 + base_web_hook/controllers/main.py | 1 + base_web_hook/security/ir.model.access.csv | 12 ++++++++++++ 4 files changed, 19 insertions(+) create mode 100644 base_web_hook/security/ir.model.access.csv diff --git a/base_web_hook/README.rst b/base_web_hook/README.rst index 0694b5f..abb83a8 100644 --- a/base_web_hook/README.rst +++ b/base_web_hook/README.rst @@ -12,6 +12,11 @@ This module provides an abstract core for receiving and processing web hooks. :alt: Try me on Runbot :target: https://runbot.odoo-community.org/runbot/210/10.0 +Known Issues +============ + +* Security is too lax; public can read too much + Bug Tracker =========== diff --git a/base_web_hook/__manifest__.py b/base_web_hook/__manifest__.py index ebfb5e0..4d4c411 100644 --- a/base_web_hook/__manifest__.py +++ b/base_web_hook/__manifest__.py @@ -19,6 +19,7 @@ 'base_json_request', ], 'data': [ + 'security/ir.model.access.csv', 'views/web_hook_view.xml', ], } diff --git a/base_web_hook/controllers/main.py b/base_web_hook/controllers/main.py index 50439ce..8318f34 100644 --- a/base_web_hook/controllers/main.py +++ b/base_web_hook/controllers/main.py @@ -21,6 +21,7 @@ def json_receive(self, *args, **kwargs): ['/base_web_hook/'], type='http', auth='public', + csrf=False, ) def http_receive(self, *args, **kwargs): return json.dumps( diff --git a/base_web_hook/security/ir.model.access.csv b/base_web_hook/security/ir.model.access.csv new file mode 100644 index 0000000..355ae5f --- /dev/null +++ b/base_web_hook/security/ir.model.access.csv @@ -0,0 +1,12 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +access_web_hook_system,access_web_hook_system,model_web_hook,base.group_system,1,1,1,1 +access_web_hook_request_bin_system,access_web_hook_request_bin_system,model_web_hook_request_bin,base.group_system,1,1,1,1 +access_web_hook_request_bin_request_system,access_web_hook_request_bin_request_system,model_web_hook_request_bin_request,base.group_system,1,1,1,1 +access_web_hook_token_system,access_web_hook_token_system,model_web_hook_token,base.group_system,1,1,1,1 +access_web_hook_token_none_system,access_web_hook_token_none_system,model_web_hook_token_none,base.group_system,1,1,1,1 +access_web_hook_token_plain_system,access_web_hook_token_plain_system,model_web_hook_token_plain,base.group_system,1,1,1,1 +access_web_hook_token_user_system,access_web_hook_token_user_system,model_web_hook_token_user,base.group_system,1,1,1,1 + +access_web_hook_public,access_web_hook_public,model_web_hook,base.group_public,1,0,0,0 +access_web_hook_token_public,access_web_hook_token_public,model_web_hook_token,base.group_public,1,0,0,0 +access_web_hook_request_bin_request_public,access_web_hook_request_bin_request_public,model_web_hook_request_bin_request,base.group_public,0,0,1,0 From b23a071c7a464d6dc89bb1415a295717c9ec55eb Mon Sep 17 00:00:00 2001 From: Dave Lasley Date: Wed, 18 Oct 2017 15:19:10 -0700 Subject: [PATCH 17/20] Save request bin as raw strings --- base_web_hook/README.rst | 1 + base_web_hook/models/web_hook_request_bin.py | 10 +++++--- .../models/web_hook_request_bin_request.py | 25 +++++++++++++++---- base_web_hook/views/web_hook_view.xml | 1 + 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/base_web_hook/README.rst b/base_web_hook/README.rst index abb83a8..6b10242 100644 --- a/base_web_hook/README.rst +++ b/base_web_hook/README.rst @@ -16,6 +16,7 @@ Known Issues ============ * Security is too lax; public can read too much +* Buffer length should be checked before ``httprequest.get_data`` calls Bug Tracker =========== diff --git a/base_web_hook/models/web_hook_request_bin.py b/base_web_hook/models/web_hook_request_bin.py index 1ec7c1c..6004e87 100644 --- a/base_web_hook/models/web_hook_request_bin.py +++ b/base_web_hook/models/web_hook_request_bin.py @@ -2,6 +2,8 @@ # Copyright 2017 LasLabs Inc. # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +import pprint + from odoo import api, fields, models from odoo.http import request @@ -35,9 +37,11 @@ def receive(self, data, headers): 'bin_id': self.id, 'uri': request.httprequest.url, 'method': request.httprequest.method, - 'headers': dict(headers), - 'data': data, - 'cookies': request.httprequest.cookies, + 'headers': pprint.pformat(dict(headers), indent=4), + 'data_parsed': pprint.pformat(data, indent=4), + 'data_raw': request.httprequest.get_data(), + 'cookies': pprint.pformat(request.httprequest.cookies, + indent=4), }) @api.multi diff --git a/base_web_hook/models/web_hook_request_bin_request.py b/base_web_hook/models/web_hook_request_bin_request.py index 1fc1bc2..e1687cd 100644 --- a/base_web_hook/models/web_hook_request_bin_request.py +++ b/base_web_hook/models/web_hook_request_bin_request.py @@ -16,14 +16,29 @@ class WebHookRequestBinRequest(models.Model): comodel_name='web.hook.request.bin', required=True, ondelete='cascade', + readonly=True, + ) + uri = fields.Char( + readonly=True, + ) + method = fields.Char( + readonly=True, + ) + headers = fields.Text( + readonly=True, + ) + data_parsed = fields.Text( + readonly=True, + ) + data_raw = fields.Text( + readonly=True, + ) + cookies = fields.Text( + readonly=True, ) - uri = fields.Char() - method = fields.Char() - headers = fields.Serialized() - data = fields.Serialized() - cookies = fields.Serialized() user_id = fields.Many2one( string='User', comodel_name='res.users', default=lambda s: s.env.user.id, + readonly=True, ) diff --git a/base_web_hook/views/web_hook_view.xml b/base_web_hook/views/web_hook_view.xml index 639d4b7..2f1823f 100644 --- a/base_web_hook/views/web_hook_view.xml +++ b/base_web_hook/views/web_hook_view.xml @@ -19,6 +19,7 @@ + From 3ca31ab641e451841772dfbf5d956df1a8ba14ff Mon Sep 17 00:00:00 2001 From: Dave Lasley Date: Thu, 26 Oct 2017 16:01:19 -0700 Subject: [PATCH 18/20] Add an authentication-required HTTP endpoint --- base_web_hook/controllers/main.py | 9 +++++++++ base_web_hook/models/web_hook.py | 27 +++++++++++++++++++++++---- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/base_web_hook/controllers/main.py b/base_web_hook/controllers/main.py index 8318f34..1eb9619 100644 --- a/base_web_hook/controllers/main.py +++ b/base_web_hook/controllers/main.py @@ -28,6 +28,15 @@ def http_receive(self, *args, **kwargs): self._receive(*args, **kwargs), ) + @http.route( + ['/base_web_hook/authenticated/'], + type='http', + auth='user', + csrf=False, + ) + def http_receive_authenticated(self, *args, **kwargs): + return self.http_receive(*args, **kwargs) + def _receive(self, slug, **kwargs): hook = http.request.env['web.hook'].search_by_slug(slug) return hook.receive( diff --git a/base_web_hook/models/web_hook.py b/base_web_hook/models/web_hook.py index 2d70065..6c307c1 100644 --- a/base_web_hook/models/web_hook.py +++ b/base_web_hook/models/web_hook.py @@ -51,6 +51,13 @@ class WebHook(models.Model): store=True, readonly=True, ) + uri_path_http_authenticated = fields.Char( + help='This is the URI path that is used to call the web hook with ' + 'an authenticated, form encoded request.', + compute='_compute_uri_path', + store=True, + readonly=True, + ) uri_json = fields.Char( string='JSON Endpoint', help='This is the URI that is used to call the web hook externally. ' @@ -64,6 +71,14 @@ class WebHook(models.Model): 'encoded, not JSON.', compute='_compute_uri', ) + uri_http_authenticated = fields.Char( + string='Authenticated Endpoint', + help='This is the URI that is used to call the web hook externally. ' + 'This endpoint should be used with requests that are form ' + 'encoded, not JSON. This endpoint will require that a user is ' + 'authenticated, which is good for application hooks.', + compute='_compute_uri', + ) token_id = fields.Many2one( string='Token', comodel_name='web.hook.token', @@ -103,10 +118,11 @@ def _compute_uri_path(self): # Do not compute slug until saved continue name = slugify(record.name or '').strip().strip('-') - record.uri_path_json = '/base_web_hook/%s-%d.json' % ( - name, record.id, - ) - record.uri_path_http = '/base_web_hook/%s-%d' % (name, record.id) + slug = '%s-%d' % (name.record.id) + record.uri_path_json = '/base_web_hook/%s.json' % slug + record.uri_path_http = '/base_web_hook/%s' % slug + authenticated = '/base_web_hook/authenticated/%s' % slug + record.uri_path_http_authenticated = authenticated @api.multi @api.depends('uri_path_http', 'uri_path_json') @@ -115,6 +131,9 @@ def _compute_uri(self): for record in self.filtered(lambda r: r.uri_path_json): record.uri_json = '%s%s' % (base_uri, record.uri_path_json) record.uri_http = '%s%s' % (base_uri, record.uri_path_http) + record.uri_http_authenticated = '%s%s' % ( + base_uri, record.uri_path_http_authenticated, + ) @api.model def _get_token_types(self): From 675e5c7d616e4bc2f8a1ea32147a3eecb13027c3 Mon Sep 17 00:00:00 2001 From: Dave Lasley Date: Fri, 27 Oct 2017 12:18:18 -0700 Subject: [PATCH 19/20] Oops --- base_web_hook/models/web_hook.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/base_web_hook/models/web_hook.py b/base_web_hook/models/web_hook.py index 6c307c1..0e92112 100644 --- a/base_web_hook/models/web_hook.py +++ b/base_web_hook/models/web_hook.py @@ -118,7 +118,7 @@ def _compute_uri_path(self): # Do not compute slug until saved continue name = slugify(record.name or '').strip().strip('-') - slug = '%s-%d' % (name.record.id) + slug = '%s-%d' % (name, record.id) record.uri_path_json = '/base_web_hook/%s.json' % slug record.uri_path_http = '/base_web_hook/%s' % slug authenticated = '/base_web_hook/authenticated/%s' % slug From 0ee415f2e42509b6e89b19b3352fd5a90ff01bff Mon Sep 17 00:00:00 2001 From: Dave Lasley Date: Mon, 30 Oct 2017 10:18:01 -0700 Subject: [PATCH 20/20] Add some sensible security --- base_web_hook/README.rst | 2 +- base_web_hook/models/web_hook.py | 4 +++- base_web_hook/security/ir.model.access.csv | 1 - 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/base_web_hook/README.rst b/base_web_hook/README.rst index 6b10242..1f4ccda 100644 --- a/base_web_hook/README.rst +++ b/base_web_hook/README.rst @@ -15,7 +15,7 @@ This module provides an abstract core for receiving and processing web hooks. Known Issues ============ -* Security is too lax; public can read too much +* Security is too lax; public can read too much. Maybe should also add a group for hooks.. * Buffer length should be checked before ``httprequest.get_data`` calls Bug Tracker diff --git a/base_web_hook/models/web_hook.py b/base_web_hook/models/web_hook.py index 0e92112..cbec076 100644 --- a/base_web_hook/models/web_hook.py +++ b/base_web_hook/models/web_hook.py @@ -83,6 +83,7 @@ class WebHook(models.Model): string='Token', comodel_name='web.hook.token', readonly=True, + groups='base.group_system', ) token_type = fields.Selection( selection=lambda s: s._get_token_types(), @@ -94,6 +95,7 @@ class WebHook(models.Model): 'token up in the remote system. For ease, a secure random value ' 'has been provided as a default.', default=lambda s: s._default_secret(), + groups='base.group_system', ) company_id = fields.Many2one( string='Company', @@ -204,7 +206,7 @@ def receive(self, data=None, data_string=None, headers=None): if headers is None: headers = {} - token = self.interface.extract_token(data, headers) + token = self.interface.sudo().extract_token(data, headers) if not self.token_id.validate(token, data, data_string, headers): raise Unauthorized(_( diff --git a/base_web_hook/security/ir.model.access.csv b/base_web_hook/security/ir.model.access.csv index 355ae5f..17ecabe 100644 --- a/base_web_hook/security/ir.model.access.csv +++ b/base_web_hook/security/ir.model.access.csv @@ -6,7 +6,6 @@ access_web_hook_token_system,access_web_hook_token_system,model_web_hook_token,b access_web_hook_token_none_system,access_web_hook_token_none_system,model_web_hook_token_none,base.group_system,1,1,1,1 access_web_hook_token_plain_system,access_web_hook_token_plain_system,model_web_hook_token_plain,base.group_system,1,1,1,1 access_web_hook_token_user_system,access_web_hook_token_user_system,model_web_hook_token_user,base.group_system,1,1,1,1 - access_web_hook_public,access_web_hook_public,model_web_hook,base.group_public,1,0,0,0 access_web_hook_token_public,access_web_hook_token_public,model_web_hook_token,base.group_public,1,0,0,0 access_web_hook_request_bin_request_public,access_web_hook_request_bin_request_public,model_web_hook_request_bin_request,base.group_public,0,0,1,0