diff --git a/requirements.txt b/requirements.txt index ae0ca88c..0cb8c43d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +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/__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 071c2fc5..f976d7be 100644 --- a/webservice/__manifest__.py +++ b/webservice/__manifest__.py @@ -15,7 +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"]}, + "external_dependencies": {"python": ["requests-oauthlib", "oauthlib"]}, "data": [ "security/ir.model.access.csv", "views/webservice_backend.xml", diff --git a/webservice/components/request_adapter.py b/webservice/components/request_adapter.py index 35a05b6d..fb31e973 100644 --- a/webservice/components/request_adapter.py +++ b/webservice/components/request_adapter.py @@ -4,14 +4,17 @@ # 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 +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" @@ -72,11 +75,14 @@ def _get_url(self, url=None, url_params=None, **kwargs): return url.format(**url_params) -class OAuth2RestRequestsAdapter(Component): - _name = "oauth2.requests" - _webservice_protocol = "http+oauth2" +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 @@ -133,9 +139,10 @@ def _fetch_new_token(self, old_token): "oauth2_client_secret", "oauth2_token_url", "oauth2_audience", + "redirect_url", ] )[0] - client = BackendApplicationClient(client_id=oauth_params["oauth2_clientid"]) + client = self.get_client(oauth_params) with OAuth2Session(client=client) as session: token = session.fetch_token( token_url=oauth_params["oauth2_token_url"], @@ -160,3 +167,71 @@ def _request(self, method, url=None, url_params=None, **kwargs): 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..65bf580c --- /dev/null +++ b/webservice/controllers/oauth2.py @@ -0,0 +1,63 @@ +# 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 + uid = request.session.uid + user = request.env["res.users"].sudo().browse(uid) + cids = request.httprequest.cookies.get("cids", str(user.company_id.id)) + cids = [int(cid) for cid in cids.split(",")] + 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 9a046cb6..0da8d540 100644 --- a/webservice/models/webservice_backend.py +++ b/webservice/models/webservice_backend.py @@ -3,10 +3,12 @@ # @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): @@ -23,7 +25,7 @@ class WebserviceBackend(models.Model): ("none", "Public"), ("user_pwd", "Username & password"), ("api_key", "API Key"), - ("oauth2", "OAuth2 Backend Application Flow (Client Credentials Grant)"), + ("oauth2", "OAuth2"), ], required=True, ) @@ -31,14 +33,33 @@ class WebserviceBackend(models.Model): 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, + compute="_compute_oauth2_flow", + ) 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"), @@ -82,7 +103,10 @@ 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: @@ -94,9 +118,42 @@ def _get_adapter(self): def _get_adapter_protocol(self): protocol = self.protocol if self.auth_type.startswith("oauth2"): - protocol += f"+{self.auth_type}" + protocol += f"+{self.auth_type}-{self.oauth2_flow}" return protocol + @api.depends("auth_type") + def _compute_oauth2_flow(self): + 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 @@ -109,8 +166,11 @@ 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": {}, } diff --git a/webservice/tests/test_oauth2.py b/webservice/tests/test_oauth2.py index c8203f48..86a13cf0 100644 --- a/webservice/tests/test_oauth2.py +++ b/webservice/tests/test_oauth2.py @@ -4,6 +4,7 @@ import json import os import time +from urllib.parse import quote import responses from oauthlib.oauth2.rfc6749.errors import InvalidGrantError @@ -11,7 +12,7 @@ from .common import CommonWebService, mock_cursor -class TestWebService(CommonWebService): +class TestWebServiceOauth2BackendApplication(CommonWebService): @classmethod def _setup_records(cls): @@ -19,8 +20,9 @@ def _setup_records(cls): cls.url = "https://localhost.demo.odoo/" os.environ["SERVER_ENV_CONFIG"] = "\n".join( [ - "[webservice_backend.test_oauth2]", + "[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", @@ -30,10 +32,11 @@ def _setup_records(cls): cls.webservice = cls.env["webservice.backend"].create( { "name": "WebService OAuth2", - "tech_name": "test_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", @@ -45,7 +48,7 @@ def _setup_records(cls): def test_get_adapter_protocol(self): protocol = self.webservice._get_adapter_protocol() - self.assertEqual(protocol, "http+oauth2") + self.assertEqual(protocol, "http+oauth2-backend_application") @responses.activate def test_fetch_token(self): @@ -65,7 +68,7 @@ def test_fetch_token(self): with mock_cursor(self.env.cr): result = self.webservice.call("get", url=f"{self.url}endpoint") - self.webservice.refresh() + self.webservice.invalidate_recordset() self.assertTrue("cool_token" in self.webservice.oauth2_token) self.assertEqual(result, b"OK") @@ -99,7 +102,7 @@ def test_update_token(self): 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.webservice.invalidate_recordset() self.assertTrue("cool_token" in self.webservice.oauth2_token) self.assertEqual(result, b"OK") @@ -135,3 +138,78 @@ def test_update_token_with_error(self): 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 83ca4735..045c1194 100644 --- a/webservice/views/webservice_backend.xml +++ b/webservice/views/webservice_backend.xml @@ -8,7 +8,14 @@ webservice.backend
-
+
+