diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..0cb8c43d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +# generated from manifests external_dependencies +oauthlib +requests-oauthlib diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 00000000..2cb24f43 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1 @@ +responses diff --git a/webservice/README.rst b/webservice/README.rst index c673a2ff..43889081 100644 --- a/webservice/README.rst +++ b/webservice/README.rst @@ -7,7 +7,7 @@ WebService !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:b21de3647819aeba7178e146f697f7d79b8cf865eaf19d5cf45f3bdd0bb5802f + !! source digest: sha256:a554d8b0213fbcf9d0a3516b0eb65705733d42498e3c009f6105dea07ae6da67 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Production%2FStable-green.png @@ -58,6 +58,7 @@ Contributors ~~~~~~~~~~~~ * Enric Tobella +* Alexandre Fayolle Maintainers ~~~~~~~~~~~ diff --git a/webservice/__init__.py b/webservice/__init__.py index f24d3e24..f64c35ff 100644 --- a/webservice/__init__.py +++ b/webservice/__init__.py @@ -1,2 +1,3 @@ from . import components from . import models +from . import controllers diff --git a/webservice/__manifest__.py b/webservice/__manifest__.py index a9d40e5d..28949986 100644 --- a/webservice/__manifest__.py +++ b/webservice/__manifest__.py @@ -15,6 +15,7 @@ "author": "Creu Blanca, Camptocamp, Odoo Community Association (OCA)", "website": "https://github.com/OCA/web-api", "depends": ["component", "server_environment"], + "external_dependencies": {"python": ["requests-oauthlib", "oauthlib"]}, "data": [ "security/ir.model.access.csv", "security/ir_rule.xml", diff --git a/webservice/components/request_adapter.py b/webservice/components/request_adapter.py index b92f1feb..fb31e973 100644 --- a/webservice/components/request_adapter.py +++ b/webservice/components/request_adapter.py @@ -3,10 +3,18 @@ # @author Simone Orsi # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import json +import logging +import time + import requests +from oauthlib.oauth2 import BackendApplicationClient, WebApplicationClient +from requests_oauthlib import OAuth2Session from odoo.addons.component.core import Component +_logger = logging.getLogger(__name__) + class BaseRestRequestsAdapter(Component): _name = "base.requests" @@ -65,3 +73,165 @@ def _get_url(self, url=None, url_params=None, **kwargs): url = self.collection.url url_params = url_params or kwargs return url.format(**url_params) + + +class BackendApplicationOAuth2RestRequestsAdapter(Component): + _name = "oauth2.requests.backend.application" + _webservice_protocol = "http+oauth2-backend_application" + _inherit = "base.requests" + + def get_client(self, oauth_params: dict): + return BackendApplicationClient(client_id=oauth_params["oauth2_clientid"]) + + def __init__(self, *args, **kw): + super().__init__(*args, **kw) + # cached value to avoid hitting the database each time we need the token + self._token = {} + + def _is_token_valid(self, token): + """Validate given oauth2 token. + + We consider that a token in valid if it has at least 10% of + its valid duration. So if a token has a validity of 1h, we will + renew it if we try to use it 6 minutes before its expiration date. + """ + expires_at = token.get("expires_at", 0) + expires_in = token.get("expires_in", 3600) # default to 1h + now = time.time() + return now <= (expires_at - 0.1 * expires_in) + + @property + def token(self): + """Return a valid oauth2 token. + + The tokens are stored in the database, and we check if they are still + valid, and renew them if needed. + """ + if self._is_token_valid(self._token): + return self._token + backend = self.collection + with backend.env.registry.cursor() as cr: + cr.execute( + "SELECT oauth2_token FROM webservice_backend " + "WHERE id=%s " + "FOR NO KEY UPDATE", # prevent concurrent token fetching + (backend.id,), + ) + token_str = cr.fetchone()[0] or "{}" + token = json.loads(token_str) + if self._is_token_valid(token): + self._token = token + else: + new_token = self._fetch_new_token(old_token=token) + cr.execute( + "UPDATE webservice_backend " "SET oauth2_token=%s " "WHERE id=%s", + (json.dumps(new_token), backend.id), + ) + self._token = new_token + return self._token + + def _fetch_new_token(self, old_token): + # TODO: check if the old token has a refresh_token that can + # be used (and use it in that case) + oauth_params = self.collection.sudo().read( + [ + "oauth2_clientid", + "oauth2_client_secret", + "oauth2_token_url", + "oauth2_audience", + "redirect_url", + ] + )[0] + client = self.get_client(oauth_params) + with OAuth2Session(client=client) as session: + token = session.fetch_token( + token_url=oauth_params["oauth2_token_url"], + cliend_id=oauth_params["oauth2_clientid"], + client_secret=oauth_params["oauth2_client_secret"], + audience=oauth_params.get("oauth2_audience") or "", + ) + return token + + def _request(self, method, url=None, url_params=None, **kwargs): + url = self._get_url(url=url, url_params=url_params) + new_kwargs = kwargs.copy() + new_kwargs.update( + { + "headers": self._get_headers(**kwargs), + "timeout": None, + } + ) + client = BackendApplicationClient(client_id=self.collection.oauth2_clientid) + with OAuth2Session(client=client, token=self.token) as session: + # pylint: disable=E8106 + request = session.request(method, url, **new_kwargs) + request.raise_for_status() + return request.content + + +class WebApplicationOAuth2RestRequestsAdapter(Component): + _name = "oauth2.requests.web.application" + _webservice_protocol = "http+oauth2-web_application" + _inherit = "oauth2.requests.backend.application" + + def get_client(self, oauth_params: dict): + return WebApplicationClient( + client_id=oauth_params["oauth2_clientid"], + code=oauth_params.get("oauth2_autorization"), + redirect_uri=oauth_params["redirect_url"], + ) + + def _fetch_token_from_authorization(self, authorization_code): + + oauth_params = self.collection.sudo().read( + [ + "oauth2_clientid", + "oauth2_client_secret", + "oauth2_token_url", + "oauth2_audience", + "redirect_url", + ] + )[0] + client = WebApplicationClient(client_id=oauth_params["oauth2_clientid"]) + + with OAuth2Session( + client=client, redirect_uri=oauth_params.get("redirect_url") + ) as session: + token = session.fetch_token( + oauth_params["oauth2_token_url"], + client_secret=oauth_params["oauth2_client_secret"], + code=authorization_code, + audience=oauth_params.get("oauth2_audience") or "", + include_client_id=True, + ) + return token + + def redirect_to_authorize(self, **authorization_url_extra_params): + """set the oauth2_state on the backend + :return: the webservice authorization url with the proper parameters + """ + # we are normally authenticated at this stage, so no need to sudo() + backend = self.collection + oauth_params = backend.read( + [ + "oauth2_clientid", + "oauth2_token_url", + "oauth2_audience", + "oauth2_authorization_url", + "oauth2_scope", + "redirect_url", + ] + )[0] + client = WebApplicationClient( + client_id=oauth_params["oauth2_clientid"], + ) + + with OAuth2Session( + client=client, + redirect_uri=oauth_params.get("redirect_url"), + ) as session: + authorization_url, state = session.authorization_url( + backend.oauth2_authorization_url, **authorization_url_extra_params + ) + backend.oauth2_state = state + return authorization_url diff --git a/webservice/controllers/__init__.py b/webservice/controllers/__init__.py new file mode 100644 index 00000000..005c729e --- /dev/null +++ b/webservice/controllers/__init__.py @@ -0,0 +1 @@ +from . import oauth2 diff --git a/webservice/controllers/oauth2.py b/webservice/controllers/oauth2.py new file mode 100644 index 00000000..80885b7d --- /dev/null +++ b/webservice/controllers/oauth2.py @@ -0,0 +1,64 @@ +# Copyright 2024 Camptocamp SA +# @author Alexandre Fayolle +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import json +import logging + +from oauthlib.oauth2.rfc6749 import errors +from werkzeug.urls import url_encode + +from odoo import http +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +class OAuth2Controller(http.Controller): + @http.route( + "/webservice//oauth2/redirect", + type="http", + auth="public", + csrf=False, + ) + def redirect(self, backend_id, **params): + backend = request.env["webservice.backend"].browse(backend_id).sudo() + if backend.auth_type != "oauth2" or backend.oauth2_flow != "web_application": + _logger.error("unexpected backed config for backend %d", backend_id) + raise errors.MismatchingRedirectURIError() + expected_state = backend.oauth2_state + state = params.get("state") + if state != expected_state: + _logger.error("unexpected state: %s", state) + raise errors.MismatchingStateError() + code = params.get("code") + adapter = ( + backend._get_adapter() + ) # we expect an adapter that supports web_application + token = adapter._fetch_token_from_authorization(code) + backend.write( + { + "oauth2_token": json.dumps(token), + "oauth2_state": False, + } + ) + # after saving the token, redirect to the backend form view + cids = request.httprequest.cookies.get("cids", "") + if cids: + cids = [int(cid) for cid in cids.split(",")] + else: + cids = [] + record_action = backend.get_access_action() + url_params = { + "model": backend._name, + "id": backend.id, + "active_id": backend.id, + "action": record_action.get("id"), + } + view_id = backend.get_formview_id() + if view_id: + url_params["view_id"] = view_id + + if cids: + url_params["cids"] = ",".join([str(cid) for cid in cids]) + url = "/web?#%s" % url_encode(url_params) + return request.redirect(url) diff --git a/webservice/models/webservice_backend.py b/webservice/models/webservice_backend.py index 84567f96..446e2b5e 100644 --- a/webservice/models/webservice_backend.py +++ b/webservice/models/webservice_backend.py @@ -1,10 +1,14 @@ # Copyright 2020 Creu Blanca # Copyright 2022 Camptocamp SA # @author Simone Orsi +# @author Alexandre Fayolle # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging from odoo import _, api, exceptions, fields, models +_logger = logging.getLogger(__name__) + class WebserviceBackend(models.Model): @@ -21,14 +25,40 @@ class WebserviceBackend(models.Model): ("none", "Public"), ("user_pwd", "Username & password"), ("api_key", "API Key"), + ("oauth2", "OAuth2"), ], - default="user_pwd", required=True, ) username = fields.Char(auth_type="user_pwd") password = fields.Char(auth_type="user_pwd") api_key = fields.Char(string="API Key", auth_type="api_key") api_key_header = fields.Char(string="API Key header", auth_type="api_key") + oauth2_flow = fields.Selection( + [ + ("backend_application", "Backend Application (Client Credentials Grant)"), + ("web_application", "Web Application (Authorization Code Grant)"), + ], + readonly=False, + store=True, + ) + oauth2_clientid = fields.Char(string="Client ID", auth_type="oauth2") + oauth2_client_secret = fields.Char(string="Client Secret", auth_type="oauth2") + oauth2_token_url = fields.Char(string="Token URL", auth_type="oauth2") + oauth2_authorization_url = fields.Char(string="Authorization URL") + oauth2_audience = fields.Char( + string="Audience" + # no auth_type because not required + ) + oauth2_scope = fields.Char(help="scope of the the authorization") + oauth2_token = fields.Char(help="the OAuth2 token (serialized JSON)") + redirect_url = fields.Char( + compute="_compute_redirect_url", + help="The redirect URL to be used as part of the OAuth2 authorisation flow", + ) + oauth2_state = fields.Char( + help="random key generated when authorization flow starts " + "to ensure that no CSRF attack happen" + ) content_type = fields.Selection( [ ("application/json", "JSON"), @@ -73,14 +103,59 @@ def _valid_field_parameter(self, field, name): return name in extra_params or super()._valid_field_parameter(field, name) def call(self, method, *args, **kwargs): - return getattr(self._get_adapter(), method)(*args, **kwargs) + _logger.debug("backend %s: call %s %s %s", self.name, method, args, kwargs) + response = getattr(self._get_adapter(), method)(*args, **kwargs) + _logger.debug("backend %s: response: \n%s", self.name, response) + return response def _get_adapter(self): with self.work_on(self._name) as work: return work.component( - usage="webservice.request", webservice_protocol=self.protocol + usage="webservice.request", + webservice_protocol=self._get_adapter_protocol(), ) + def _get_adapter_protocol(self): + protocol = self.protocol + if self.auth_type.startswith("oauth2"): + protocol += f"+{self.auth_type}-{self.oauth2_flow}" + return protocol + + @api.onchange("auth_type") + def _onchange_oauth2_auth_type(self): + # reset the oauth2_flow when auth_type is not oauth2 + # using a compute method interferes with the server environment mixin + for rec in self: + if rec.auth_type != "oauth2": + rec.oauth2_flow = False + + @api.depends("auth_type", "oauth2_flow") + def _compute_redirect_url(self): + get_param = self.env["ir.config_parameter"].sudo().get_param + base_url = get_param("web.base.url") + if base_url.startswith("http://"): + _logger.warning( + "web.base.url is configured in http. Oauth2 requires using https" + ) + base_url = base_url[len("http://") :] + if not base_url.startswith("https://"): + base_url = f"https://{base_url}" + for rec in self: + if rec.auth_type == "oauth2" and rec.oauth2_flow == "web_application": + rec.redirect_url = f"{base_url}/webservice/{rec.id}/oauth2/redirect" + else: + rec.redirect_url = False + + def button_authorize(self): + _logger.info("Button OAuth2 Authorize") + authorize_url = self._get_adapter().redirect_to_authorize() + _logger.info("Redirecting to %s", authorize_url) + return { + "type": "ir.actions.act_url", + "url": authorize_url, + "target": "self", + } + @property def _server_env_fields(self): base_fields = super()._server_env_fields @@ -93,6 +168,13 @@ def _server_env_fields(self): "api_key": {}, "api_key_header": {}, "content_type": {}, + "oauth2_flow": {}, + "oauth2_scope": {}, + "oauth2_clientid": {}, + "oauth2_client_secret": {}, + "oauth2_authorization_url": {}, + "oauth2_token_url": {}, + "oauth2_audience": {}, } webservice_fields.update(base_fields) return webservice_fields diff --git a/webservice/readme/CONTRIBUTORS.rst b/webservice/readme/CONTRIBUTORS.rst index 93ec993e..bfbf32a6 100644 --- a/webservice/readme/CONTRIBUTORS.rst +++ b/webservice/readme/CONTRIBUTORS.rst @@ -1 +1,2 @@ * Enric Tobella +* Alexandre Fayolle diff --git a/webservice/static/description/index.html b/webservice/static/description/index.html index 55b7d1c0..7842e76e 100644 --- a/webservice/static/description/index.html +++ b/webservice/static/description/index.html @@ -366,7 +366,7 @@

WebService

!! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:b21de3647819aeba7178e146f697f7d79b8cf865eaf19d5cf45f3bdd0bb5802f +!! source digest: sha256:a554d8b0213fbcf9d0a3516b0eb65705733d42498e3c009f6105dea07ae6da67 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

Production/Stable License: AGPL-3 OCA/web-api Translate me on Weblate Try me on Runboat

This module creates WebService frameworks to be used globally

@@ -403,6 +403,7 @@

Authors

Contributors

diff --git a/webservice/tests/__init__.py b/webservice/tests/__init__.py index c7c6b2c0..44111f05 100644 --- a/webservice/tests/__init__.py +++ b/webservice/tests/__init__.py @@ -1 +1 @@ -from . import test_webservice +from . import test_webservice, test_oauth2 diff --git a/webservice/tests/common.py b/webservice/tests/common.py index 07266402..81627131 100644 --- a/webservice/tests/common.py +++ b/webservice/tests/common.py @@ -1,5 +1,7 @@ # Copyright 2020 Creu Blanca # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from contextlib import contextmanager +from unittest import mock from odoo.tests.common import tagged @@ -11,7 +13,9 @@ class CommonWebService(TransactionComponentCase): @classmethod def _setup_context(cls): return dict( - cls.env.context, tracking_disable=True, test_queue_job_no_delay=True + cls.env.context, + tracking_disable=True, + test_queue_job_no_delay=True, ) @classmethod @@ -27,3 +31,19 @@ def setUpClass(cls): super().setUpClass() cls._setup_env() cls._setup_records() + + +@contextmanager +def mock_cursor(cr): + with mock.patch("odoo.sql_db.Connection.cursor") as mocked_cursor_call: + org_close = cr.close + org_autocommit = cr.autocommit + try: + cr.close = mock.Mock() + cr.autocommit = mock.Mock() + cr.commit = mock.Mock() + mocked_cursor_call.return_value = cr + yield + finally: + cr.close = org_close + cr.autocommit = org_autocommit diff --git a/webservice/tests/test_oauth2.py b/webservice/tests/test_oauth2.py new file mode 100644 index 00000000..0557c8dc --- /dev/null +++ b/webservice/tests/test_oauth2.py @@ -0,0 +1,215 @@ +# Copyright 2023 Camptocamp SA +# @author Alexandre Fayolle +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import json +import os +import time +from urllib.parse import quote + +import responses +from oauthlib.oauth2.rfc6749.errors import InvalidGrantError + +from .common import CommonWebService, mock_cursor + + +class TestWebServiceOauth2BackendApplication(CommonWebService): + @classmethod + def _setup_records(cls): + + res = super()._setup_records() + cls.url = "https://localhost.demo.odoo/" + os.environ["SERVER_ENV_CONFIG"] = "\n".join( + [ + "[webservice_backend.test_oauth2_back]", + "auth_type = oauth2", + "oauth2_flow = backend_application", + "oauth2_clientid = some_client_id", + "oauth2_client_secret = shh_secret", + f"oauth2_token_url = {cls.url}oauth2/token", + f"oauth2_audience = {cls.url}", + ] + ) + cls.webservice = cls.env["webservice.backend"].create( + { + "name": "WebService OAuth2", + "tech_name": "test_oauth2_back", + "auth_type": "oauth2", + "protocol": "http", + "url": cls.url, + "oauth2_flow": "backend_application", + "content_type": "application/xml", + "oauth2_clientid": "some_client_id", + "oauth2_client_secret": "shh_secret", + "oauth2_token_url": f"{cls.url}oauth2/token", + "oauth2_audience": cls.url, + } + ) + return res + + def test_get_adapter_protocol(self): + protocol = self.webservice._get_adapter_protocol() + self.assertEqual(protocol, "http+oauth2-backend_application") + + @responses.activate + def test_fetch_token(self): + duration = 3600 + expires_timestamp = time.time() + duration + responses.add( + responses.POST, + f"{self.url}oauth2/token", + json={ + "access_token": "cool_token", + "expires_at": expires_timestamp, + "expires_in": duration, + "token_type": "Bearer", + }, + ) + responses.add(responses.GET, f"{self.url}endpoint", body="OK") + + with mock_cursor(self.env.cr): + result = self.webservice.call("get", url=f"{self.url}endpoint") + self.webservice.refresh() + self.assertTrue("cool_token" in self.webservice.oauth2_token) + self.assertEqual(result, b"OK") + + @responses.activate + def test_update_token(self): + duration = 3600 + self.webservice.oauth2_token = json.dumps( + { + "access_token": "old_token", + "expires_at": time.time() + 10, # in the near future + "expires_in": duration, + "token_type": "Bearer", + } + ) + self.webservice.flush() + + expires_timestamp = time.time() + duration + responses.add( + responses.POST, + f"{self.url}oauth2/token", + json={ + "access_token": "cool_token", + "expires_at": expires_timestamp, + "expires_in": duration, + "token_type": "Bearer", + }, + ) + responses.add(responses.GET, f"{self.url}endpoint", body="OK") + + with mock_cursor(self.env.cr): + result = self.webservice.call("get", url=f"{self.url}endpoint") + self.env.cr.commit.assert_called_once_with() # one call with no args + + self.webservice.refresh() + self.assertTrue("cool_token" in self.webservice.oauth2_token) + self.assertEqual(result, b"OK") + + @responses.activate + def test_update_token_with_error(self): + duration = 3600 + self.webservice.oauth2_token = json.dumps( + { + "access_token": "old_token", + "expires_at": time.time() + 10, # in the near future + "expires_in": duration, + "token_type": "Bearer", + } + ) + self.webservice.flush() + + responses.add( + responses.POST, + f"{self.url}oauth2/token", + json={ + "error": "invalid_grant", + "error_description": "invalid grant", + }, + status=404, + ) + responses.add(responses.GET, f"{self.url}endpoint", body="NOK", status=403) + + with mock_cursor(self.env.cr): + with self.assertRaises(InvalidGrantError): + self.webservice.call("get", url=f"{self.url}endpoint") + self.env.cr.commit.assert_not_called() + self.env.cr.close.assert_called_once_with() # one call with no args + + self.webservice.refresh() + self.assertTrue("old_token" in self.webservice.oauth2_token) + + +class TestWebServiceOauth2WebApplication(CommonWebService): + @classmethod + def _setup_records(cls): + res = super()._setup_records() + cls.url = "https://localhost.demo.odoo/" + os.environ["SERVER_ENV_CONFIG"] = "\n".join( + [ + "[webservice_backend.test_oauth2_web]", + "auth_type = oauth2", + "oauth2_flow = web_application", + "oauth2_clientid = some_client_id", + "oauth2_client_secret = shh_secret", + f"oauth2_token_url = {cls.url}oauth2/token", + f"oauth2_audience = {cls.url}", + f"oauth2_authorization_url = {cls.url}/authorize", + ] + ) + cls.webservice = cls.env["webservice.backend"].create( + { + "name": "WebService OAuth2", + "tech_name": "test_oauth2_web", + "auth_type": "oauth2", + "protocol": "http", + "url": cls.url, + "oauth2_flow": "web_application", + "content_type": "application/xml", + "oauth2_clientid": "some_client_id", + "oauth2_client_secret": "shh_secret", + "oauth2_token_url": f"{cls.url}oauth2/token", + "oauth2_audience": cls.url, + "oauth2_authorization_url": f"{cls.url}/authorize", + } + ) + return res + + def test_get_adapter_protocol(self): + protocol = self.webservice._get_adapter_protocol() + self.assertEqual(protocol, "http+oauth2-web_application") + + def test_authorization_code(self): + action = self.webservice.button_authorize() + expected_action = { + "type": "ir.actions.act_url", + "target": "self", + "url": "https://localhost.demo.odoo//authorize?response_type=code&" + "client_id=some_client_id&" + f"redirect_uri={quote(self.webservice.redirect_url, safe='')}&state=", + } + self.assertEqual(action["type"], expected_action["type"]) + self.assertEqual(action["target"], expected_action["target"]) + self.assertTrue( + action["url"].startswith(expected_action["url"]), + f"Got url:\n{action['url']}\nexpected:\n{expected_action['url']}", + ) + + @responses.activate + def test_fetch_token_from_auth(self): + duration = 3600 + expires_timestamp = time.time() + duration + responses.add( + responses.POST, + self.webservice.oauth2_token_url, + json={ + "access_token": "cool_token", + "expires_at": expires_timestamp, + "expires_in": duration, + "token_type": "Bearer", + }, + ) + code = "some code" + adapter = self.webservice._get_adapter() + token = adapter._fetch_token_from_authorization(code) + self.assertEqual("cool_token", token["access_token"]) diff --git a/webservice/views/webservice_backend.xml b/webservice/views/webservice_backend.xml index 9fcf304e..b27600b5 100644 --- a/webservice/views/webservice_backend.xml +++ b/webservice/views/webservice_backend.xml @@ -8,7 +8,14 @@ webservice.backend
-
+
+