diff --git a/setup/shopinvader_api_signin_jwt/odoo/addons/shopinvader_api_signin_jwt b/setup/shopinvader_api_signin_jwt/odoo/addons/shopinvader_api_signin_jwt new file mode 120000 index 0000000000..15a95e6209 --- /dev/null +++ b/setup/shopinvader_api_signin_jwt/odoo/addons/shopinvader_api_signin_jwt @@ -0,0 +1 @@ +../../../../shopinvader_api_signin_jwt \ No newline at end of file diff --git a/setup/shopinvader_api_signin_jwt/setup.py b/setup/shopinvader_api_signin_jwt/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/shopinvader_api_signin_jwt/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/shopinvader_api_signin_jwt/README.rst b/shopinvader_api_signin_jwt/README.rst new file mode 100644 index 0000000000..b51b529b53 --- /dev/null +++ b/shopinvader_api_signin_jwt/README.rst @@ -0,0 +1,67 @@ +========================== +Shopinvader Api Signin JWT +========================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:0c78d267b23414519ffd0969275305ffdcffeb4dcea4e76f63c2d8d3d0d35735 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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_api_signin_jwt + :alt: shopinvader/odoo-shopinvader + +|badge1| |badge2| |badge3| + +This addon adds a web API to signin into the application and create a partner +if the email in the jwt payload is unknown. + +**Table of contents** + +.. contents:: + :local: + +Known issues / Roadmap +====================== + +* Manage anonymous cart (see https://github.com/shopinvader/odoo-shopinvader/issues/1428) +* Use ``fastapi_auth_jwt.auth_jwt_authenticated_odoo_env`` dependency for the env (see https://github.com/OCA/rest-framework/issues/406) + +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 +~~~~~~~ + +* ACSONE SA/NV + +Contributors +~~~~~~~~~~~~ + +* Quentin Groulard + +Maintainers +~~~~~~~~~~~ + +This module is part of the `shopinvader/odoo-shopinvader `_ project on GitHub. + +You are welcome to contribute. diff --git a/shopinvader_api_signin_jwt/__init__.py b/shopinvader_api_signin_jwt/__init__.py new file mode 100644 index 0000000000..62a5d54f85 --- /dev/null +++ b/shopinvader_api_signin_jwt/__init__.py @@ -0,0 +1 @@ +from . import routers diff --git a/shopinvader_api_signin_jwt/__manifest__.py b/shopinvader_api_signin_jwt/__manifest__.py new file mode 100644 index 0000000000..cd51a78571 --- /dev/null +++ b/shopinvader_api_signin_jwt/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Shopinvader Api Signin JWT", + "summary": """This module adds a signin service with jwt token.""", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "ACSONE SA/NV", + "website": "https://github.com/shopinvader/odoo-shopinvader", + "depends": ["fastapi_auth_jwt"], + "data": [ + "security/res_groups.xml", + "security/acl_res_partner.xml", + ], + "demo": [], + "installable": True, +} diff --git a/shopinvader_api_signin_jwt/readme/CONTRIBUTORS.rst b/shopinvader_api_signin_jwt/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..5914f5529e --- /dev/null +++ b/shopinvader_api_signin_jwt/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Quentin Groulard diff --git a/shopinvader_api_signin_jwt/readme/DESCRIPTION.rst b/shopinvader_api_signin_jwt/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..9d807c0111 --- /dev/null +++ b/shopinvader_api_signin_jwt/readme/DESCRIPTION.rst @@ -0,0 +1,2 @@ +This addon adds a web API to signin into the application and create a partner +if the email in the jwt payload is unknown. diff --git a/shopinvader_api_signin_jwt/readme/ROADMAP.rst b/shopinvader_api_signin_jwt/readme/ROADMAP.rst new file mode 100644 index 0000000000..945ce1163b --- /dev/null +++ b/shopinvader_api_signin_jwt/readme/ROADMAP.rst @@ -0,0 +1,2 @@ +* Manage anonymous cart (see https://github.com/shopinvader/odoo-shopinvader/issues/1428) +* Use ``fastapi_auth_jwt.auth_jwt_authenticated_odoo_env`` dependency for the env (see https://github.com/OCA/rest-framework/issues/406) diff --git a/shopinvader_api_signin_jwt/routers/__init__.py b/shopinvader_api_signin_jwt/routers/__init__.py new file mode 100644 index 0000000000..20258b5295 --- /dev/null +++ b/shopinvader_api_signin_jwt/routers/__init__.py @@ -0,0 +1 @@ +from .signin import signin_router diff --git a/shopinvader_api_signin_jwt/routers/signin.py b/shopinvader_api_signin_jwt/routers/signin.py new file mode 100644 index 0000000000..0b9b688b40 --- /dev/null +++ b/shopinvader_api_signin_jwt/routers/signin.py @@ -0,0 +1,87 @@ +# Copyright 2023 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import logging +from typing import Annotated, Union + +from fastapi import APIRouter, Depends, Response, status + +from odoo import api, models + +from odoo.addons.base.models.res_partner import Partner +from odoo.addons.fastapi.dependencies import odoo_env +from odoo.addons.fastapi_auth_jwt.dependencies import ( + Payload, + auth_jwt_authenticated_payload, + auth_jwt_default_validator_name, + auth_jwt_optionally_authenticated_partner, +) + +_logger = logging.getLogger(__name__) + +signin_router = APIRouter(tags=["signin"]) + + +@signin_router.post("/signin", status_code=200) +def signin( + env: Annotated[api.Environment, Depends(odoo_env)], + partner: Annotated[Partner, Depends(auth_jwt_optionally_authenticated_partner)], + payload: Annotated[Payload, Depends(auth_jwt_authenticated_payload)], + response: Response, +) -> None: + """ + Authenticate the partner based on a JWT token or a session cookie. + Set the session cookie if allowed. + Return HTTP code 201 if res.partner created (case of the first signin). + """ + if not partner: + env[ + "shopinvader_api_signin_jwt.signin_router.helper" + ]._create_partner_from_payload(payload) + response.status_code = status.HTTP_201_CREATED + + +@signin_router.post("/signout") +def signout( + env: Annotated[api.Environment, Depends(odoo_env)], + default_validator_name: Annotated[ + Union[str, None], Depends(auth_jwt_default_validator_name) + ], + response: Response, +) -> None: + """ + Remove the session cookie. + """ + validator = ( + env["auth.jwt.validator"].sudo()._get_validator_by_name(default_validator_name) + ) + if not validator: + _logger.info("No validator found with name '%s'", default_validator_name) + return + if not validator.cookie_name: + _logger.info("Cookie name not set for validator %s", validator.name) + return + response.delete_cookie( + key=validator.cookie_name, + path=validator.cookie_path or "/", + secure=validator.cookie_secure, + httponly=True, + ) + + +class ShopinvaderApSigninJwtRouterHelper(models.AbstractModel): + _name = "shopinvader_api_signin_jwt.signin_router.helper" + _description = "ShopInvader API Signin Jwt Router Helper" + + @api.model + def _get_partner_create_vals(self, payload: Payload): + return {"name": payload.get("name"), "email": payload.get("email")} + + @api.model + def _create_partner_from_payload(self, payload: Payload): + partner = ( + self.env["res.partner"] + .sudo() + .create(self._get_partner_create_vals(payload)) + ) + return self.env["res.partner"].browse(partner.id) diff --git a/shopinvader_api_signin_jwt/security/acl_res_partner.xml b/shopinvader_api_signin_jwt/security/acl_res_partner.xml new file mode 100644 index 0000000000..64f0f24fab --- /dev/null +++ b/shopinvader_api_signin_jwt/security/acl_res_partner.xml @@ -0,0 +1,15 @@ + + + + + Shopinvader Signin: user read/write/create partners + + + + + + + + + diff --git a/shopinvader_api_signin_jwt/security/res_groups.xml b/shopinvader_api_signin_jwt/security/res_groups.xml new file mode 100644 index 0000000000..3b0ca2b5a3 --- /dev/null +++ b/shopinvader_api_signin_jwt/security/res_groups.xml @@ -0,0 +1,15 @@ + + + + + Shopinvader Signin user + + + + diff --git a/shopinvader_api_signin_jwt/static/description/index.html b/shopinvader_api_signin_jwt/static/description/index.html new file mode 100644 index 0000000000..d2ccff8bd8 --- /dev/null +++ b/shopinvader_api_signin_jwt/static/description/index.html @@ -0,0 +1,425 @@ + + + + + + +Shopinvader Api Signin JWT + + + +
+

Shopinvader Api Signin JWT

+ + +

Beta License: AGPL-3 shopinvader/odoo-shopinvader

+

This addon adds a web API to signin into the application and create a partner +if the email in the jwt payload is unknown.

+

Table of contents

+ +
+

Known issues / Roadmap

+ +
+
+

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

+
    +
  • ACSONE SA/NV
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is part of the shopinvader/odoo-shopinvader project on GitHub.

+

You are welcome to contribute.

+
+
+
+ + diff --git a/shopinvader_api_signin_jwt/tests/__init__.py b/shopinvader_api_signin_jwt/tests/__init__.py new file mode 100644 index 0000000000..a54b73281f --- /dev/null +++ b/shopinvader_api_signin_jwt/tests/__init__.py @@ -0,0 +1 @@ +from . import test_signin diff --git a/shopinvader_api_signin_jwt/tests/test_signin.py b/shopinvader_api_signin_jwt/tests/test_signin.py new file mode 100644 index 0000000000..3243e0283c --- /dev/null +++ b/shopinvader_api_signin_jwt/tests/test_signin.py @@ -0,0 +1,92 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import time + +import jwt + +from odoo.addons.fastapi.tests.common import FastAPITransactionCase +from odoo.addons.fastapi_auth_jwt.dependencies import auth_jwt_default_validator_name + +from ..routers import signin_router + + +class SigninCase(FastAPITransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + api_signin_jwt_group = cls.env.ref( + "shopinvader_api_signin_jwt.shopinvader_signin_user_group" + ) + user_with_rights = cls.env["res.users"].create( + { + "name": "Test User With Rights", + "login": "user_with_rights", + "groups_id": [(6, 0, [api_signin_jwt_group.id])], + } + ) + + cls.validator = cls.env["auth.jwt.validator"].create( + { + "name": "test", + "audience": "auth_jwt_test_signin_api", + "issuer": "testissuer", + "signature_type": "secret", + "secret_algorithm": "HS256", + "secret_key": "secret", + "user_id_strategy": "static", + "static_user_id": cls.env.ref("base.user_demo").id, + "partner_id_strategy": "email", + "partner_id_required": False, + } + ) + + cls.default_fastapi_running_user = user_with_rights + cls.default_fastapi_router = signin_router + cls.default_fastapi_dependency_overrides = { + auth_jwt_default_validator_name: (lambda: "test") + } + + def _get_token(self): + payload = { + "aud": self.validator.audience, + "iss": self.validator.issuer, + "exp": time.time() + 60, + "email": "test@mail.com", + "name": "Test partner", + "email_verified": True, + } + access_token = jwt.encode( + payload, + key=self.validator.secret_key, + algorithm=self.validator.secret_algorithm, + ) + return "Bearer " + access_token + + def test_signin(self): + token = self._get_token() + partner = self.env["res.partner"].search([("email", "=", "test@mail.com")]) + self.assertFalse(partner) + # Call signin with unknown partner + with self._create_test_client() as client: + res = client.post("/signin", headers={"Authorization": token}) + self.assertEqual(res.status_code, 201) + partner = self.env["res.partner"].search([("email", "=", "test@mail.com")]) + self.assertTrue(partner) + self.assertEqual(partner.name, "Test partner") + # Try again now that partner exists + with self._create_test_client() as client: + res = client.post("/signin", headers={"Authorization": token}) + self.assertEqual(res.status_code, 200) + + def test_signout(self): + self.validator.write({"cookie_enabled": True, "cookie_name": "test_cookie"}) + token = self._get_token() + with self._create_test_client() as client: + res = client.post("/signin", headers={"Authorization": token}) + cookie = res.cookies.get("test_cookie") + self.assertTrue(cookie) + with self._create_test_client() as client: + res = client.post("/signout") + cookie = res.cookies.get("test_cookie") + self.assertFalse(cookie)