diff --git a/setup/shopinvader_fastapi_auth_partner/odoo/addons/shopinvader_fastapi_auth_partner b/setup/shopinvader_fastapi_auth_partner/odoo/addons/shopinvader_fastapi_auth_partner new file mode 120000 index 0000000000..7bd7b6cbb0 --- /dev/null +++ b/setup/shopinvader_fastapi_auth_partner/odoo/addons/shopinvader_fastapi_auth_partner @@ -0,0 +1 @@ +../../../../shopinvader_fastapi_auth_partner \ No newline at end of file diff --git a/setup/shopinvader_fastapi_auth_partner/setup.py b/setup/shopinvader_fastapi_auth_partner/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/shopinvader_fastapi_auth_partner/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/shopinvader_fastapi_auth_partner/README.rst b/shopinvader_fastapi_auth_partner/README.rst new file mode 100644 index 0000000000..b33e81a3f3 --- /dev/null +++ b/shopinvader_fastapi_auth_partner/README.rst @@ -0,0 +1,87 @@ +============================================================= +Shopinvader Auth Partner authentication for FastAPI endpoints +============================================================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:2d541bb8cb7014b523724a433fb30146e0a3afb1190677f90431e36e41cd3d96 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-shopinvader%2Fodoo--shopinvader-lightgray.png?logo=github + :target: https://github.com/shopinvader/odoo-shopinvader/tree/16.0/shopinvader_fastapi_auth_partner + :alt: shopinvader/odoo-shopinvader + +|badge1| |badge2| |badge3| + +This module provides the ``auth_jwt_authenticated_or_anonymous_partner`` and +``auth_jwt_authenticated_or_anonymous_partner_auto_create`` FastAPI dependencies. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +This module provide the following FastAPI dependencies: + +``def auth_jwt_authenticated_or_anonymous_partner() -> Partner`` + + This dependency returns the authenticated partner from ``fast_api_auth_jwt`` + ``auth_jwt_optionally_authenticated_partner``. If not authenticated or no partner is + found, look for the ``shopinvader_anonymous_partner`` cookie in the request and return + the corresponding partner. + + If not partner is found, raise a 401 (unauthorized). + +``def auth_jwt_authenticated_or_anonymous_partner_auto_create() -> Partner`` + + This dependency returns the authenticated partner from ``fast_api_auth_jwt`` + ``auth_jwt_optionally_authenticated_partner``. If not authenticated or no partner is + found, look for the ``shopinvader_anonymous_partner`` cookie in the request and return + the corresponding partner. + + If no partner is found, create an anonymous partner, set the corresponding cookie and + return the newly created partner. + +The record sets returned from these functions are bound either to the Odoo user defined +on the JWT validaator (if authenticated), or to the Odoo user defined on the FastAPI +endpoint. + +These dependencies are suitable and intended to override the +``odoo.addon.fastapi.dependencies.authenticated_partner_impl``. + +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 to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Akretion + +Maintainers +~~~~~~~~~~~ + +This module is part of the `shopinvader/odoo-shopinvader `_ project on GitHub. + +You are welcome to contribute. diff --git a/shopinvader_fastapi_auth_partner/__init__.py b/shopinvader_fastapi_auth_partner/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/shopinvader_fastapi_auth_partner/__manifest__.py b/shopinvader_fastapi_auth_partner/__manifest__.py new file mode 100644 index 0000000000..07f03e06e5 --- /dev/null +++ b/shopinvader_fastapi_auth_partner/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2023 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +{ + "name": "Shopinvader Auth Partner authentication for FastAPI endpoints", + "summary": """ + Provide Partner and Anonymous Partner authentication to FastAPI routes.""", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "Akretion", + "maintainers": [], + "website": "https://github.com/shopinvader/odoo-shopinvader", + "depends": ["fastapi_auth_partner", "shopinvader_anonymous_partner"], + "data": [], + "demo": [], +} diff --git a/shopinvader_fastapi_auth_partner/dependencies.py b/shopinvader_fastapi_auth_partner/dependencies.py new file mode 100644 index 0000000000..978fa8ab45 --- /dev/null +++ b/shopinvader_fastapi_auth_partner/dependencies.py @@ -0,0 +1,66 @@ +# Copyright 2023 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +import logging +import sys + +from fastapi import Depends, HTTPException, Request, Response, status + +from odoo.api import Environment + +from odoo.addons.base.models.res_partner import Partner +from odoo.addons.fastapi.dependencies import odoo_env +from odoo.addons.fastapi_auth_partner.dependencies import ( + auth_partner_optionally_authenticated_partner, +) + +if sys.version_info >= (3, 9): + from typing import Annotated +else: + from typing_extensions import Annotated + +_logger = logging.getLogger(__name__) + + +def auth_partner_authenticated_or_anonymous_partner( + partner: Annotated[ + Partner, + Depends(auth_partner_optionally_authenticated_partner), + ], + env: Annotated[Environment, Depends(odoo_env)], + request: Request, +) -> Partner: + if partner: + return partner + anonymous_partner = env["res.partner"]._get_anonymous_partner__cookie( + request.cookies + ) + if anonymous_partner: + return env["res.partner"].browse(anonymous_partner.id) + _logger.info( + "Partner auth authentication failed and no anonymous partner cookie found." + ) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + + +def auth_partner_authenticated_or_anonymous_partner_autocreate( + partner: Annotated[ + Partner, + Depends(auth_partner_optionally_authenticated_partner), + ], + env: Annotated[Environment, Depends(odoo_env)], + request: Request, + response: Response, +) -> Partner: + if partner: + return partner + anonymous_partner = env["res.partner"]._get_anonymous_partner__cookie( + request.cookies + ) + if not anonymous_partner: + anonymous_partner = env["res.partner"]._create_anonymous_partner__cookie( + response + ) + return env["res.partner"].browse(anonymous_partner.id) diff --git a/shopinvader_fastapi_auth_partner/readme/DESCRIPTION.rst b/shopinvader_fastapi_auth_partner/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..d1dd86e70e --- /dev/null +++ b/shopinvader_fastapi_auth_partner/readme/DESCRIPTION.rst @@ -0,0 +1,2 @@ +This module provides the ``auth_jwt_authenticated_or_anonymous_partner`` and +``auth_jwt_authenticated_or_anonymous_partner_auto_create`` FastAPI dependencies. diff --git a/shopinvader_fastapi_auth_partner/readme/USAGE.rst b/shopinvader_fastapi_auth_partner/readme/USAGE.rst new file mode 100644 index 0000000000..b97c66a4eb --- /dev/null +++ b/shopinvader_fastapi_auth_partner/readme/USAGE.rst @@ -0,0 +1,27 @@ +This module provide the following FastAPI dependencies: + +``def auth_jwt_authenticated_or_anonymous_partner() -> Partner`` + + This dependency returns the authenticated partner from ``fast_api_auth_jwt`` + ``auth_jwt_optionally_authenticated_partner``. If not authenticated or no partner is + found, look for the ``shopinvader_anonymous_partner`` cookie in the request and return + the corresponding partner. + + If not partner is found, raise a 401 (unauthorized). + +``def auth_jwt_authenticated_or_anonymous_partner_auto_create() -> Partner`` + + This dependency returns the authenticated partner from ``fast_api_auth_jwt`` + ``auth_jwt_optionally_authenticated_partner``. If not authenticated or no partner is + found, look for the ``shopinvader_anonymous_partner`` cookie in the request and return + the corresponding partner. + + If no partner is found, create an anonymous partner, set the corresponding cookie and + return the newly created partner. + +The record sets returned from these functions are bound either to the Odoo user defined +on the JWT validaator (if authenticated), or to the Odoo user defined on the FastAPI +endpoint. + +These dependencies are suitable and intended to override the +``odoo.addon.fastapi.dependencies.authenticated_partner_impl``. diff --git a/shopinvader_fastapi_auth_partner/tests/__init__.py b/shopinvader_fastapi_auth_partner/tests/__init__.py new file mode 100644 index 0000000000..96c06ebe89 --- /dev/null +++ b/shopinvader_fastapi_auth_partner/tests/__init__.py @@ -0,0 +1 @@ +from . import test_auth_partner_or_anonymous diff --git a/shopinvader_fastapi_auth_partner/tests/test_auth_partner_or_anonymous.py b/shopinvader_fastapi_auth_partner/tests/test_auth_partner_or_anonymous.py new file mode 100644 index 0000000000..3523733232 --- /dev/null +++ b/shopinvader_fastapi_auth_partner/tests/test_auth_partner_or_anonymous.py @@ -0,0 +1,191 @@ +# Copyright 2023 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +import sys +from unittest import mock + +from fastapi import Depends, FastAPI, status + +from odoo import tests + +from odoo.addons.base.models.res_partner import Partner +from odoo.addons.fastapi_auth_partner.tests.test_auth import CommonTestAuth +from odoo.addons.shopinvader_fastapi_auth_partner.dependencies import ( + auth_partner_authenticated_or_anonymous_partner, + auth_partner_authenticated_or_anonymous_partner_autocreate, +) + +if sys.version_info >= (3, 9): + from typing import Annotated +else: + from typing_extensions import Annotated + +app = FastAPI() + + +@app.get("/test/shopinvader_auth_partner_or_anonymous") +def shopinvader_auth_partner_or_anonymous( + partner: Annotated[ + Partner, + Depends(auth_partner_authenticated_or_anonymous_partner), + ] +): + return {"partner_id": partner.id} + + +@app.get("/test/shopinvader_auth_partner_or_anonymous_autocreate") +def shopinvader_auth_partner_or_anonymous_autocreate( + partner: Annotated[ + Partner, + Depends(auth_partner_authenticated_or_anonymous_partner_autocreate), + ] +): + return {"partner_id": partner.id} + + +class TestBase(CommonTestAuth): + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.test_anonymous_partner = cls.env[ + "res.partner" + ]._create_anonymous_partner__cookie(response=mock.MagicMock()) + cls.default_fastapi_app = app + cls.test_partner = cls.env["res.partner"].create( + { + "name": "Loriot", + "email": "loriot@example.org", + "auth_partner_ids": [ + ( + 0, + 0, + { + "password": "supersecret", + "directory_id": cls.demo_app.directory_id.id, + }, + ) + ], + } + ) + + def _add_anonymous_cookie(self, client, force_value=None): + client.cookies.set( + "shopinvader-anonymous-partner", + force_value or self.test_anonymous_partner.anonymous_token, + ) + + +@tests.tagged("post_install", "-at_install") +class TestAuthPartnerOrAnonymous(TestBase): + def test_unauthenticated(self) -> None: + # unauthenticated returns 401 + with self._create_test_client() as client: + resp = client.get("/test/shopinvader_auth_partner_or_anonymous") + self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED, resp.text) + + def test_partner_authenticated_valid_partner(self) -> None: + with self._create_test_client() as client: + self._login(client) + resp = client.get("/test/shopinvader_auth_partner_or_anonymous") + resp.raise_for_status() + self.assertEqual(resp.json()["partner_id"], self.test_partner.id, resp.text) + + def test_partner_authenticated_unknown_partner(self) -> None: + with self._create_test_client() as client: + self._login(client) + # The account of the partner have been deleted and the partner try + # to reconnect with an old cookie + self.test_partner.unlink() + resp = client.get("/test/shopinvader_auth_partner_or_anonymous") + self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED, resp.text) + + def test_cookie_valid_anonymous_partner(self) -> None: + with self._create_test_client() as client: + self._add_anonymous_cookie(client) + resp = client.get("/test/shopinvader_auth_partner_or_anonymous") + resp.raise_for_status() + self.assertEqual( + resp.json()["partner_id"], self.test_anonymous_partner.id, resp.text + ) + + def test_cookie_invalid_token(self) -> None: + with self._create_test_client() as client: + self._add_anonymous_cookie(client, "invalid_cookie") + resp = client.get("/test/shopinvader_auth_partner_or_anonymous") + self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED, resp.text) + + def test_valid_partner_valid_cookie(self) -> None: + """Auth partner has priority over the anonymous partner cookie.""" + with self._create_test_client() as client: + self._login(client) + self._add_anonymous_cookie(client) + resp = client.get("/test/shopinvader_auth_partner_or_anonymous") + resp.raise_for_status() + self.assertEqual(resp.json()["partner_id"], self.test_partner.id, resp.text) + + def test_valid_partner_invalid_cookie(self) -> None: + """Auth Partner has priority over the anonymous partner cookie.""" + with self._create_test_client() as client: + self._login(client) + self._add_anonymous_cookie(client, "invalid_cookie") + resp = client.get("/test/shopinvader_auth_partner_or_anonymous") + resp.raise_for_status() + self.assertEqual(resp.json()["partner_id"], self.test_partner.id, resp.text) + + def test_invalid_partner_valid_cookie(self) -> None: + """Invalid Auth Partner has priority over the anonymous partner cookie.""" + with self._create_test_client() as client: + self._login(client) + self.test_partner.unlink() + self._add_anonymous_cookie(client) + resp = client.get("/test/shopinvader_auth_partner_or_anonymous") + self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED, resp.text) + + def test_invalid_partner_invalid_cookie(self) -> None: + """Auth Partner has priority over the anonymous partner cookie.""" + with self._create_test_client() as client: + self._login(client) + self.test_partner.unlink() + self._add_anonymous_cookie(client, "invalid_cookie") + resp = client.get("/test/shopinvader_auth_partner_or_anonymous") + self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED, resp.text) + + +@tests.tagged("post_install", "-at_install") +class TestAuthJwtOrAnonymousAutocreate(TestBase): + def test_unauthenticated_creates_anonymous(self) -> None: + # Unauthenticated creates an anonymous partner and sets the cookie. + with self._create_test_client() as client: + resp = client.get("/test/shopinvader_auth_partner_or_anonymous_autocreate") + resp.raise_for_status() + anonymous_partner_id = resp.json()["partner_id"] + anonymous_token = resp.cookies["shopinvader-anonymous-partner"] + self.assertEqual( + anonymous_token, + self.env["res.partner"].browse(anonymous_partner_id).anonymous_token, + ) + # Second call with anonymous partner cookie returns same partner. + resp = client.get( + "/test/shopinvader_auth_partner_or_anonymous_autocreate", + headers={ + "Cookie": f"shopinvader-anonymous-partner={anonymous_token}", + }, + ) + resp.raise_for_status() + self.assertEqual(resp.json()["partner_id"], anonymous_partner_id) + + def test_partner_authenticated_valid_partner(self) -> None: + with self._create_test_client() as client: + self._login(client) + resp = client.get("/test/shopinvader_auth_partner_or_anonymous_autocreate") + resp.raise_for_status() + self.assertEqual(resp.json()["partner_id"], self.test_partner.id, resp.text) + + def test_partner_authenticated_unknown_partner(self) -> None: + with self._create_test_client() as client: + self._login(client) + self.test_partner.unlink() + resp = client.get("/test/shopinvader_auth_partner_or_anonymous_autocreate") + self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED, resp.text)