diff --git a/fastapi_auth_partner/README.rst b/fastapi_auth_partner/README.rst new file mode 100644 index 000000000..6e652714e --- /dev/null +++ b/fastapi_auth_partner/README.rst @@ -0,0 +1,140 @@ +==================== +Fastapi Auth Partner +==================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:2ebd9377ca7b035ab9fb0383513aacb5ca8645f69d5d85c171883b40b439017e + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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-OCA%2Frest--framework-lightgray.png?logo=github + :target: https://github.com/OCA/rest-framework/tree/18.0/fastapi_auth_partner + :alt: OCA/rest-framework +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/rest-framework-18-0/rest-framework-18-0-fastapi_auth_partner + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/rest-framework&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module is the FastAPI implementation of +`auth_partner <../auth_partner>`__ it provides all the routes to manage +the authentication of partners. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +First you have to add the auth router to your FastAPI endpoint and the +authentication dependency to your app dependencies: + +.. code:: python + + from odoo.addons.fastapi import dependencies + from odoo.addons.fastapi_auth_partner.dependencies import ( + auth_partner_authenticated_partner, + ) + from odoo.addons.fastapi_auth_partner.routers.auth import auth_router + + class FastapiEndpoint(models.Model): + _inherit = "fastapi.endpoint" + + def _get_fastapi_routers(self): + if self.app == "myapp": + return [ + auth_router, + ] + return super()._get_fastapi_routers() + + def _get_app_dependencies_overrides(self): + res = super()._get_app_dependencies_overrides() + if self.app == "myapp": + res.update( + { + dependencies.authenticated_partner_impl: auth_partner_authenticated_partner, + } + ) + return res + +Next you can manage your authenticable partners and directories in the +Odoo interface: + +FastAPI > Authentication > Partner + +and + +FastAPI > Authentication > Directory + +Next you must set the directory used for the authentication in the +FastAPI endpoint: + +FastAPI > FastAPI Endpoint > myapp > Directory + +Then you can use the auth router to authenticate your requests: + +- POST /auth/register to register a partner +- POST /auth/login to authenticate a partner +- POST /auth/logout to unauthenticate a partner +- POST /auth/validate_email to validate a partner email +- POST /auth/request_reset_password to request a password reset +- POST /auth/set_password to set a new password +- GET /auth/profile to get the partner profile +- GET /auth/impersonate to impersonate a partner + +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 + +Contributors +------------ + +- `Akretion `__: + + - Sébastien Beau + - Florian Mounier + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +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. + +This module is part of the `OCA/rest-framework `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/fastapi_auth_partner/__init__.py b/fastapi_auth_partner/__init__.py new file mode 100644 index 000000000..3f274f8d1 --- /dev/null +++ b/fastapi_auth_partner/__init__.py @@ -0,0 +1,4 @@ +from . import models +from . import routers +from . import schemas +from . import wizards diff --git a/fastapi_auth_partner/__manifest__.py b/fastapi_auth_partner/__manifest__.py new file mode 100644 index 000000000..19f2842c1 --- /dev/null +++ b/fastapi_auth_partner/__manifest__.py @@ -0,0 +1,33 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Fastapi Auth Partner", + "summary": """This provides an implementation of auth_partner for FastAPI""", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "author": "Akretion,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/rest-framework", + "depends": [ + "extendable_fastapi", + "auth_partner", + ], + "data": [ + "security/res_group.xml", + "security/ir_rule.xml", + "security/ir.model.access.csv", + "views/auth_partner_view.xml", + "views/auth_directory_view.xml", + "views/fastapi_endpoint_view.xml", + "wizards/wizard_auth_partner_impersonate_view.xml", + "wizards/wizard_auth_partner_reset_password_view.xml", + ], + "demo": [ + "demo/fastapi_endpoint_demo.xml", + ], + "external_dependencies": { + "python": ["itsdangerous"], + }, +} diff --git a/fastapi_auth_partner/demo/fastapi_endpoint_demo.xml b/fastapi_auth_partner/demo/fastapi_endpoint_demo.xml new file mode 100644 index 000000000..ff53c707c --- /dev/null +++ b/fastapi_auth_partner/demo/fastapi_endpoint_demo.xml @@ -0,0 +1,29 @@ + + + + Fastapi Auth Partner Demo Endpoint + + demo + /fastapi_auth_partner_demo + auth_partner + + + https://api.example.com/ + https://www.example.com/ + + + + + + diff --git a/fastapi_auth_partner/dependencies.py b/fastapi_auth_partner/dependencies.py new file mode 100644 index 000000000..22ad03067 --- /dev/null +++ b/fastapi_auth_partner/dependencies.py @@ -0,0 +1,68 @@ +# Copyright 2024 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging +from typing import Annotated, Any + +from itsdangerous import URLSafeTimedSerializer +from starlette.status import HTTP_401_UNAUTHORIZED + +from odoo.api import Environment + +from odoo.addons.base.models.res_partner import Partner +from odoo.addons.fastapi.dependencies import fastapi_endpoint, odoo_env +from odoo.addons.fastapi.models import FastapiEndpoint + +from fastapi import Cookie, Depends, HTTPException, Request, Response + +_logger = logging.getLogger(__name__) + + +Payload = dict[str, Any] + + +class AuthPartner: + def __init__(self, allow_unauthenticated: bool = False): + self.allow_unauthenticated = allow_unauthenticated + + def __call__( + self, + request: Request, + response: Response, + env: Annotated[ + Environment, + Depends(odoo_env), + ], + endpoint: Annotated[FastapiEndpoint, Depends(fastapi_endpoint)], + fastapi_auth_partner: Annotated[str | None, Cookie()] = None, + ) -> Partner: + if not fastapi_auth_partner and self.allow_unauthenticated: + return env["res.partner"].with_user(env.ref("base.public_user")).browse() + + elif fastapi_auth_partner: + directory = endpoint.sudo().directory_id + try: + vals = URLSafeTimedSerializer( + directory.cookie_secret_key or directory.secret_key + ).loads(fastapi_auth_partner, max_age=directory.cookie_duration * 60) + except Exception as e: + _logger.error("Invalid cookies error %s", e) + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED) from e + if vals["did"] == directory.id and vals["pid"]: + partner = env["res.partner"].browse(vals["pid"]).exists() + if partner: + auth_partner = partner._get_auth_partner_for_directory(directory) + if auth_partner: + if directory.sliding_session: + helper = env["fastapi.auth.service"].new( + {"endpoint_id": endpoint} + ) + helper._set_auth_cookie(auth_partner, request, response) + return partner + _logger.info("Could not determine partner from 'fastapi_auth_partner' cookie.") + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED) + + +auth_partner_authenticated_partner = AuthPartner() +auth_partner_optionally_authenticated_partner = AuthPartner(allow_unauthenticated=True) diff --git a/fastapi_auth_partner/i18n/fastapi_auth_partner.pot b/fastapi_auth_partner/i18n/fastapi_auth_partner.pot new file mode 100644 index 000000000..fd3569e94 --- /dev/null +++ b/fastapi_auth_partner/i18n/fastapi_auth_partner.pot @@ -0,0 +1,263 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fastapi_auth_partner +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_endpoint__app +msgid "App" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model,name:fastapi_auth_partner.model_auth_directory +msgid "Auth Directory" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model,name:fastapi_auth_partner.model_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__auth_partner_id +msgid "Auth Partner" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.ui.menu,name:fastapi_auth_partner.fastapi_auth +msgid "Authentication" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_endpoint__demo_auth_method +msgid "Authentication method" +msgstr "" + +#. module: fastapi_auth_partner +#: model_terms:ir.ui.view,arch_db:fastapi_auth_partner.wizard_auth_partner_impersonate_view_form +msgid "Cancel" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_auth_directory__cookie_duration +msgid "Cookie Duration" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_auth_directory__cookie_secret_key +msgid "Cookie Secret Key" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__create_uid +msgid "Created by" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__create_date +msgid "Created on" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields.selection,name:fastapi_auth_partner.selection__fastapi_endpoint__app__demo +msgid "Demo Endpoint" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_auth_service__directory_id +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_endpoint__directory_id +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__auth_directory_id +#: model:ir.ui.menu,name:fastapi_auth_partner.auth_directory_menu +msgid "Directory" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__display_name +msgid "Display Name" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_auth_service__endpoint_id +msgid "Endpoint" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model,name:fastapi_auth_partner.model_fastapi_endpoint +msgid "FastAPI Endpoint" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_auth_directory__fastapi_endpoint_ids +msgid "FastAPI Endpoints" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model,name:fastapi_auth_partner.model_fastapi_auth_service +msgid "Fastapi Auth Service" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__fastapi_endpoint_id +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_reset_password__fastapi_endpoint_id +msgid "Fastapi Endpoint" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__id +msgid "ID" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.actions.act_window,name:fastapi_auth_partner.auth_partner_action_impersonate +#: model_terms:ir.ui.view,arch_db:fastapi_auth_partner.wizard_auth_partner_impersonate_view_form +msgid "Impersonate" +msgstr "" + +#. module: fastapi_auth_partner +#. odoo-python +#: code:addons/fastapi_auth_partner/models/auth_partner.py:0 +#, python-format +msgid "Impersonation successful" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,help:fastapi_auth_partner.field_auth_directory__cookie_duration +msgid "In minute, default 525600 minutes => 1 year" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_endpoint__is_auth_partner +msgid "Is Auth Partner" +msgstr "" + +#. module: fastapi_auth_partner +#: model_terms:ir.ui.view,arch_db:fastapi_auth_partner.wizard_auth_partner_impersonate_view_form +msgid "Label" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate____last_update +msgid "Last Modified on" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__write_date +msgid "Last Updated on" +msgstr "" + +#. module: fastapi_auth_partner +#: model_terms:ir.ui.view,arch_db:fastapi_auth_partner.auth_partner_view_form +msgid "Local Impersonate" +msgstr "" + +#. module: fastapi_auth_partner +#. odoo-python +#: code:addons/fastapi_auth_partner/routers/auth.py:0 +#, python-format +msgid "No cookie secret key defined" +msgstr "" + +#. module: fastapi_auth_partner +#. odoo-python +#: code:addons/fastapi_auth_partner/models/auth_partner.py:0 +#, python-format +msgid "Only admin can impersonate locally" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.ui.menu,name:fastapi_auth_partner.auth_partner_menu +msgid "Partner" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields.selection,name:fastapi_auth_partner.selection__fastapi_endpoint__demo_auth_method__auth_partner +msgid "Partner Auth" +msgstr "" + +#. module: fastapi_auth_partner +#: model_terms:ir.ui.view,arch_db:fastapi_auth_partner.wizard_auth_partner_impersonate_view_form +msgid "Please choose an endpoint:" +msgstr "" + +#. module: fastapi_auth_partner +#. odoo-python +#: code:addons/fastapi_auth_partner/models/auth_partner.py:0 +#, python-format +msgid "Please install base_future_response for local impersonate to work" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_endpoint__public_api_url +msgid "Public Api Url" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_endpoint__public_url +msgid "Public Url" +msgstr "" + +#. module: fastapi_auth_partner +#: model_terms:ir.ui.view,arch_db:fastapi_auth_partner.auth_directory_view_form +msgid "Regenerate cookie secret key" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_auth_directory__sliding_session +msgid "Sliding Session" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,help:fastapi_auth_partner.field_fastapi_endpoint__is_auth_partner +msgid "Technical field to know if the auth method is partner" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,help:fastapi_auth_partner.field_fastapi_endpoint__public_api_url +msgid "" +"The public URL of the API.\n" +"This URL is used in impersonation to set the cookie on the right API domain if you use a reverse proxy to serve the API.\n" +"Defaults to the public_url if not set or the odoo url if not set either." +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,help:fastapi_auth_partner.field_fastapi_endpoint__public_url +msgid "" +"The public URL of the site.\n" +"This URL is used for the impersonation final redirect. And can also be used in the mail template to construct links.\n" +"Default to the public_api_url if not set or the odoo url if not set either." +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,help:fastapi_auth_partner.field_auth_directory__cookie_secret_key +msgid "The secret key used to sign the cookie" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model,name:fastapi_auth_partner.model_wizard_auth_partner_impersonate +msgid "Wizard Partner Auth Impersonate" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model,name:fastapi_auth_partner.model_wizard_auth_partner_reset_password +msgid "Wizard Partner Auth Reset Password" +msgstr "" + +#. module: fastapi_auth_partner +#. odoo-python +#: code:addons/fastapi_auth_partner/models/auth_partner.py:0 +#, python-format +msgid "" +"You are now impersonating %s\n" +"%%s" +msgstr "" diff --git a/fastapi_auth_partner/i18n/it.po b/fastapi_auth_partner/i18n/it.po new file mode 100644 index 000000000..91674ad11 --- /dev/null +++ b/fastapi_auth_partner/i18n/it.po @@ -0,0 +1,278 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fastapi_auth_partner +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2025-12-10 11:42+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.10.4\n" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_endpoint__app +msgid "App" +msgstr "Applicazione" + +#. module: fastapi_auth_partner +#: model:ir.model,name:fastapi_auth_partner.model_auth_directory +msgid "Auth Directory" +msgstr "Cartella autorizzazione" + +#. module: fastapi_auth_partner +#: model:ir.model,name:fastapi_auth_partner.model_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__auth_partner_id +msgid "Auth Partner" +msgstr "Partner autorizzazione" + +#. module: fastapi_auth_partner +#: model:ir.ui.menu,name:fastapi_auth_partner.fastapi_auth +msgid "Authentication" +msgstr "Autenticazione" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_endpoint__demo_auth_method +msgid "Authentication method" +msgstr "Metodo autenticazione" + +#. module: fastapi_auth_partner +#: model_terms:ir.ui.view,arch_db:fastapi_auth_partner.wizard_auth_partner_impersonate_view_form +msgid "Cancel" +msgstr "Annulla" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_auth_directory__cookie_duration +msgid "Cookie Duration" +msgstr "Durata cookie" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_auth_directory__cookie_secret_key +msgid "Cookie Secret Key" +msgstr "Chiave segreta cookie" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__create_uid +msgid "Created by" +msgstr "Creato da" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__create_date +msgid "Created on" +msgstr "Creato il" + +#. module: fastapi_auth_partner +#: model:ir.model.fields.selection,name:fastapi_auth_partner.selection__fastapi_endpoint__app__demo +msgid "Demo Endpoint" +msgstr "Endpoint esempio" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_auth_service__directory_id +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_endpoint__directory_id +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__auth_directory_id +#: model:ir.ui.menu,name:fastapi_auth_partner.auth_directory_menu +msgid "Directory" +msgstr "Cartella" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__display_name +msgid "Display Name" +msgstr "Nome visualizzato" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_auth_service__endpoint_id +msgid "Endpoint" +msgstr "Endpoint" + +#. module: fastapi_auth_partner +#: model:ir.model,name:fastapi_auth_partner.model_fastapi_endpoint +msgid "FastAPI Endpoint" +msgstr "Endpoint FastAPI" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_auth_directory__fastapi_endpoint_ids +msgid "FastAPI Endpoints" +msgstr "Endpoint FastAPI" + +#. module: fastapi_auth_partner +#: model:ir.model,name:fastapi_auth_partner.model_fastapi_auth_service +msgid "Fastapi Auth Service" +msgstr "Servizio autenticazione FastAPI" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__fastapi_endpoint_id +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_reset_password__fastapi_endpoint_id +msgid "Fastapi Endpoint" +msgstr "Endopoint FastAPI" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__id +msgid "ID" +msgstr "ID" + +#. module: fastapi_auth_partner +#: model:ir.actions.act_window,name:fastapi_auth_partner.auth_partner_action_impersonate +#: model_terms:ir.ui.view,arch_db:fastapi_auth_partner.wizard_auth_partner_impersonate_view_form +msgid "Impersonate" +msgstr "Imita" + +#. module: fastapi_auth_partner +#. odoo-python +#: code:addons/fastapi_auth_partner/models/auth_partner.py:0 +#, python-format +msgid "Impersonation successful" +msgstr "Imitazione riuscita" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,help:fastapi_auth_partner.field_auth_directory__cookie_duration +msgid "In minute, default 525600 minutes => 1 year" +msgstr "In minuti, predefinito minuti => 1 anno" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_endpoint__is_auth_partner +msgid "Is Auth Partner" +msgstr "È partner autorizzazione" + +#. module: fastapi_auth_partner +#: model_terms:ir.ui.view,arch_db:fastapi_auth_partner.wizard_auth_partner_impersonate_view_form +msgid "Label" +msgstr "Etichetta" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate____last_update +msgid "Last Modified on" +msgstr "Ultima modifica il" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__write_uid +msgid "Last Updated by" +msgstr "Ultimo aggiornamento di" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__write_date +msgid "Last Updated on" +msgstr "Ultimo aggiornamento il" + +#. module: fastapi_auth_partner +#: model_terms:ir.ui.view,arch_db:fastapi_auth_partner.auth_partner_view_form +msgid "Local Impersonate" +msgstr "Imitazione locale" + +#. module: fastapi_auth_partner +#. odoo-python +#: code:addons/fastapi_auth_partner/routers/auth.py:0 +#, python-format +msgid "No cookie secret key defined" +msgstr "Nessuna chiave segreta cookie definita" + +#. module: fastapi_auth_partner +#. odoo-python +#: code:addons/fastapi_auth_partner/models/auth_partner.py:0 +#, python-format +msgid "Only admin can impersonate locally" +msgstr "Solo l'amministratore può imitare localmente" + +#. module: fastapi_auth_partner +#: model:ir.ui.menu,name:fastapi_auth_partner.auth_partner_menu +msgid "Partner" +msgstr "Partner" + +#. module: fastapi_auth_partner +#: model:ir.model.fields.selection,name:fastapi_auth_partner.selection__fastapi_endpoint__demo_auth_method__auth_partner +msgid "Partner Auth" +msgstr "Autorizzazione partner" + +#. module: fastapi_auth_partner +#: model_terms:ir.ui.view,arch_db:fastapi_auth_partner.wizard_auth_partner_impersonate_view_form +msgid "Please choose an endpoint:" +msgstr "Scegliere un endpoint:" + +#. module: fastapi_auth_partner +#. odoo-python +#: code:addons/fastapi_auth_partner/models/auth_partner.py:0 +#, python-format +msgid "Please install base_future_response for local impersonate to work" +msgstr "Installare base_future_response per far funzionare l'imitazione locale" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_endpoint__public_api_url +msgid "Public Api Url" +msgstr "URL API pubblico" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_endpoint__public_url +msgid "Public Url" +msgstr "URL pubblico" + +#. module: fastapi_auth_partner +#: model_terms:ir.ui.view,arch_db:fastapi_auth_partner.auth_directory_view_form +msgid "Regenerate cookie secret key" +msgstr "Rigenera chiave segreta cookie" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_auth_directory__sliding_session +msgid "Sliding Session" +msgstr "Sessione scorrevole" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,help:fastapi_auth_partner.field_fastapi_endpoint__is_auth_partner +msgid "Technical field to know if the auth method is partner" +msgstr "Campo tecnico per sapere se il metodo di autorizzazione è partner" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,help:fastapi_auth_partner.field_fastapi_endpoint__public_api_url +msgid "" +"The public URL of the API.\n" +"This URL is used in impersonation to set the cookie on the right API domain if you use a reverse proxy to serve the API.\n" +"Defaults to the public_url if not set or the odoo url if not set either." +msgstr "" +"URL pubblico dell'API.\n" +"Questo URL viene utilizzato nell'imitazione per impostare il cookie sul " +"dominio API corretto se si utilizza un reverse proxy per servire l'API.\n" +"Il valore predefinito è public_url se non impostato, oppure l'URL di Odoo se " +"non impostato." + +#. module: fastapi_auth_partner +#: model:ir.model.fields,help:fastapi_auth_partner.field_fastapi_endpoint__public_url +msgid "" +"The public URL of the site.\n" +"This URL is used for the impersonation final redirect. And can also be used in the mail template to construct links.\n" +"Default to the public_api_url if not set or the odoo url if not set either." +msgstr "" +"URL pubblico del sito.\n" +"Questo URL viene utilizzato per il reindirizzamento finale dell'imitazione. " +"Può anche essere utilizzato nel modello di posta per creare link.\n" +"Impostato di default su public_api_url se non impostato, oppure su Odoo URL " +"se non impostato." + +#. module: fastapi_auth_partner +#: model:ir.model.fields,help:fastapi_auth_partner.field_auth_directory__cookie_secret_key +msgid "The secret key used to sign the cookie" +msgstr "La chiave segreta usata per firmare il cookie" + +#. module: fastapi_auth_partner +#: model:ir.model,name:fastapi_auth_partner.model_wizard_auth_partner_impersonate +msgid "Wizard Partner Auth Impersonate" +msgstr "Procedura guidata imitazione autorizzazione partner" + +#. module: fastapi_auth_partner +#: model:ir.model,name:fastapi_auth_partner.model_wizard_auth_partner_reset_password +msgid "Wizard Partner Auth Reset Password" +msgstr "Procedura guidata reset password autorizzazione partner" + +#. module: fastapi_auth_partner +#. odoo-python +#: code:addons/fastapi_auth_partner/models/auth_partner.py:0 +#, python-format +msgid "" +"You are now impersonating %s\n" +"%%s" +msgstr "" +"Osa si sta imitando %s\n" +"%%s" diff --git a/fastapi_auth_partner/models/__init__.py b/fastapi_auth_partner/models/__init__.py new file mode 100644 index 000000000..526f7a263 --- /dev/null +++ b/fastapi_auth_partner/models/__init__.py @@ -0,0 +1,3 @@ +from . import auth_directory +from . import auth_partner +from . import fastapi_endpoint diff --git a/fastapi_auth_partner/models/auth_directory.py b/fastapi_auth_partner/models/auth_directory.py new file mode 100644 index 000000000..b671bf96d --- /dev/null +++ b/fastapi_auth_partner/models/auth_directory.py @@ -0,0 +1,51 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo import fields, models + + +class AuthDirectory(models.Model): + _inherit = "auth.directory" + + fastapi_endpoint_ids = fields.One2many( + "fastapi.endpoint", + "directory_id", + string="FastAPI Endpoints", + ) + + cookie_secret_key = fields.Char( + groups="base.group_system", + help="The secret key used to sign the cookie", + required=True, + default=lambda self: self._generate_default_secret_key(), + ) + cookie_duration = fields.Integer( + default=525600, + help="In minute, default 525600 minutes => 1 year", + required=True, + ) + sliding_session = fields.Boolean() + + def action_regenerate_cookie_secret_key(self): + self.ensure_one() + self.cookie_secret_key = self._generate_default_secret_key() + + def _prepare_mail_context(self, context): + rv = super()._prepare_mail_context(context) + endpoint_id = self.env.context.get("_fastapi_endpoint_id") + + if endpoint_id: + endpoint = self.env["fastapi.endpoint"].browse(endpoint_id) + rv["public_url"] = endpoint.public_url or endpoint.public_api_url + + return rv + + @property + def _server_env_fields(self): + return { + **super()._server_env_fields, + "cookie_secret_key": {}, + } diff --git a/fastapi_auth_partner/models/auth_partner.py b/fastapi_auth_partner/models/auth_partner.py new file mode 100644 index 000000000..54018d1ad --- /dev/null +++ b/fastapi_auth_partner/models/auth_partner.py @@ -0,0 +1,84 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models +from odoo.exceptions import AccessDenied, UserError +from odoo.http import request + + +class AuthPartner(models.Model): + _inherit = "auth.partner" + + def local_impersonate(self): + """Local impersonate for dev mode""" + self.ensure_one() + + if not self.env.user._is_admin(): + raise AccessDenied(self.env._("Only admin can impersonate locally")) + + if not hasattr(request, "future_response"): + raise UserError( + self.env._( + "Please install base_future_response for local impersonate to work" + ) + ) + + for endpoint in self.directory_id.fastapi_endpoint_ids: + helper = self.env["fastapi.auth.service"].new({"endpoint_id": endpoint}) + helper._set_auth_cookie(self, request.httprequest, request.future_response) + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": self.env._("Impersonation successful"), + "message": self.env._("You are now impersonating %s\n%%s") % self.login, + "links": [ + { + "label": f"{endpoint.app.title()} api docs", + "url": endpoint.docs_url, + } + for endpoint in self.directory_id.fastapi_endpoint_ids + ], + "type": "success", + "sticky": False, + }, + } + + def _get_impersonate_url(self, token, **kwargs): + endpoint = kwargs.get("endpoint") + if not endpoint: + return super()._get_impersonate_url(token, **kwargs) + + base = ( + endpoint.public_api_url + or endpoint.public_url + or ( + self.env["ir.config_parameter"].sudo().get_param("web.base.url") + + endpoint.root_path + ) + ) + return f"{base.rstrip('/')}/auth/impersonate/{token}" + + def _get_impersonate_action(self, token, **kwargs): + # Get the endpoint from a wizard + endpoint_id = self.env.context.get("fastapi_endpoint_id") + endpoint = None + + if endpoint_id: + endpoint = self.env["fastapi.endpoint"].browse(endpoint_id) + + if not endpoint: + endpoints = self.directory_id.fastapi_endpoint_ids + if len(endpoints) == 1: + endpoint = endpoints + else: + wizard = self.env["ir.actions.act_window"]._for_xml_id( + "fastapi_auth_partner.auth_partner_action_impersonate" + ) + wizard["context"] = {"default_auth_partner_id": self.id} + return wizard + + return super()._get_impersonate_action(token, endpoint=endpoint, **kwargs) diff --git a/fastapi_auth_partner/models/fastapi_endpoint.py b/fastapi_auth_partner/models/fastapi_endpoint.py new file mode 100644 index 000000000..8fa9491ba --- /dev/null +++ b/fastapi_auth_partner/models/fastapi_endpoint.py @@ -0,0 +1,54 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo import fields, models + +from fastapi import APIRouter + +from ..routers.auth import auth_router + + +class FastapiEndpoint(models.Model): + _inherit = "fastapi.endpoint" + + app = fields.Selection( + selection_add=[("demo", "Demo Endpoint")], ondelete={"demo": "cascade"} + ) + demo_auth_method = fields.Selection( + selection_add=[ + ("auth_partner", "Partner Auth"), + ], + string="Authentication method", + ) + directory_id = fields.Many2one("auth.directory") + + is_auth_partner = fields.Boolean( + compute="_compute_is_auth_partner", + help="Technical field to know if the auth method is partner", + ) + public_api_url: str = fields.Char( + help="The public URL of the API.\n" + "This URL is used in impersonation to set the cookie on the right API " + "domain if you use a reverse proxy to serve the API.\n" + "Defaults to the public_url if not set or the odoo url if not set either." + ) + # More info in https://github.com/OCA/rest-framework/pull/438/files + public_url: str = fields.Char( + help="The public URL of the site.\n" + "This URL is used for the impersonation final redirect. " + "And can also be used in the mail template to construct links.\n" + "Default to the public_api_url if not set or the odoo url if not set either." + ) + + def _get_fastapi_routers(self) -> list[APIRouter]: + routers = super()._get_fastapi_routers() + if self.app == "demo" and self.demo_auth_method == "auth_partner": + routers.append(auth_router) + return routers + + def _compute_is_auth_partner(self): + for rec in self: + rec.is_auth_partner = auth_router in rec._get_fastapi_routers() diff --git a/fastapi_auth_partner/pyproject.toml b/fastapi_auth_partner/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/fastapi_auth_partner/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/fastapi_auth_partner/readme/CONTRIBUTORS.md b/fastapi_auth_partner/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..6079ca504 --- /dev/null +++ b/fastapi_auth_partner/readme/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +- [Akretion](https://www.akretion.com): + - Sébastien Beau + - Florian Mounier diff --git a/fastapi_auth_partner/readme/DESCRIPTION.md b/fastapi_auth_partner/readme/DESCRIPTION.md new file mode 100644 index 000000000..8d4456e52 --- /dev/null +++ b/fastapi_auth_partner/readme/DESCRIPTION.md @@ -0,0 +1,3 @@ +This module is the FastAPI implementation of +[auth_partner](../auth_partner) it provides all the routes to manage the +authentication of partners. diff --git a/fastapi_auth_partner/readme/USAGE.md b/fastapi_auth_partner/readme/USAGE.md new file mode 100644 index 000000000..fc6bc9024 --- /dev/null +++ b/fastapi_auth_partner/readme/USAGE.md @@ -0,0 +1,55 @@ +First you have to add the auth router to your FastAPI endpoint and the +authentication dependency to your app dependencies: + +``` python +from odoo.addons.fastapi import dependencies +from odoo.addons.fastapi_auth_partner.dependencies import ( + auth_partner_authenticated_partner, +) +from odoo.addons.fastapi_auth_partner.routers.auth import auth_router + +class FastapiEndpoint(models.Model): + _inherit = "fastapi.endpoint" + + def _get_fastapi_routers(self): + if self.app == "myapp": + return [ + auth_router, + ] + return super()._get_fastapi_routers() + + def _get_app_dependencies_overrides(self): + res = super()._get_app_dependencies_overrides() + if self.app == "myapp": + res.update( + { + dependencies.authenticated_partner_impl: auth_partner_authenticated_partner, + } + ) + return res +``` + +Next you can manage your authenticable partners and directories in the +Odoo interface: + +FastAPI \> Authentication \> Partner + +and + +FastAPI \> Authentication \> Directory + +Next you must set the directory used for the authentication in the +FastAPI endpoint: + +FastAPI \> FastAPI Endpoint \> myapp \> Directory + +Then you can use the auth router to authenticate your requests: + +- POST /auth/register to register a partner +- POST /auth/login to authenticate a partner +- POST /auth/logout to unauthenticate a partner +- POST /auth/validate_email to validate a partner email +- POST /auth/request_reset_password to request a password reset +- POST /auth/set_password to set a new password +- GET /auth/profile to get the partner profile +- GET /auth/impersonate to impersonate a partner diff --git a/fastapi_auth_partner/routers/__init__.py b/fastapi_auth_partner/routers/__init__.py new file mode 100644 index 000000000..582cb2cd7 --- /dev/null +++ b/fastapi_auth_partner/routers/__init__.py @@ -0,0 +1 @@ +from .auth import auth_router diff --git a/fastapi_auth_partner/routers/auth.py b/fastapi_auth_partner/routers/auth.py new file mode 100644 index 000000000..8ec37845f --- /dev/null +++ b/fastapi_auth_partner/routers/auth.py @@ -0,0 +1,257 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from datetime import datetime, timedelta, timezone +from typing import Annotated + +from itsdangerous import URLSafeTimedSerializer + +from odoo import fields, models, tools +from odoo.api import Environment +from odoo.exceptions import ValidationError + +from odoo.addons.base.models.res_partner import Partner +from odoo.addons.fastapi.dependencies import fastapi_endpoint, odoo_env +from odoo.addons.fastapi.models import FastapiEndpoint + +from fastapi import APIRouter, Depends, Request, Response +from fastapi.responses import RedirectResponse + +from ..dependencies import auth_partner_authenticated_partner +from ..schemas import ( + AuthForgetPasswordInput, + AuthLoginInput, + AuthPartnerResponse, + AuthRegisterInput, + AuthSetPasswordInput, + AuthValidateEmailInput, +) + +COOKIE_AUTH_NAME = "fastapi_auth_partner" + +auth_router = APIRouter(tags=["auth"]) + + +@auth_router.post("/auth/register", status_code=201) +def register( + data: AuthRegisterInput, + env: Annotated[Environment, Depends(odoo_env)], + endpoint: Annotated[FastapiEndpoint, Depends(fastapi_endpoint)], + request: Request, + response: Response, +) -> AuthPartnerResponse: + helper = env["fastapi.auth.service"].new({"endpoint_id": endpoint}) + auth_partner = helper._signup(data) + helper._set_auth_cookie(auth_partner, request, response) + return AuthPartnerResponse.from_auth_partner(auth_partner) + + +@auth_router.post("/auth/login") +def login( + data: AuthLoginInput, + env: Annotated[Environment, Depends(odoo_env)], + endpoint: Annotated[FastapiEndpoint, Depends(fastapi_endpoint)], + request: Request, + response: Response, +) -> AuthPartnerResponse: + helper = env["fastapi.auth.service"].new({"endpoint_id": endpoint}) + auth_partner = helper._login(data) + helper._set_auth_cookie(auth_partner, request, response) + return AuthPartnerResponse.from_auth_partner(auth_partner) + + +@auth_router.post("/auth/logout", status_code=205) +def logout( + env: Annotated[Environment, Depends(odoo_env)], + endpoint: Annotated[FastapiEndpoint, Depends(fastapi_endpoint)], + response: Response, +): + helper = env["fastapi.auth.service"].new({"endpoint_id": endpoint}) + helper._logout() + helper._clear_auth_cookie(response) + return {} + + +@auth_router.post("/auth/validate_email") +def validate_email( + data: AuthValidateEmailInput, + env: Annotated[Environment, Depends(odoo_env)], + endpoint: Annotated[FastapiEndpoint, Depends(fastapi_endpoint)], +): + helper = env["fastapi.auth.service"].new({"endpoint_id": endpoint}) + helper._validate_email(data) + return {} + + +@auth_router.post("/auth/request_reset_password") +def request_reset_password( + data: AuthForgetPasswordInput, + env: Annotated[Environment, Depends(odoo_env)], + endpoint: Annotated[FastapiEndpoint, Depends(fastapi_endpoint)], +): + helper = env["fastapi.auth.service"].new({"endpoint_id": endpoint}) + helper._request_reset_password(data) + return {} + + +@auth_router.post("/auth/set_password") +def set_password( + data: AuthSetPasswordInput, + env: Annotated[Environment, Depends(odoo_env)], + endpoint: Annotated[FastapiEndpoint, Depends(fastapi_endpoint)], + request: Request, + response: Response, +) -> AuthPartnerResponse: + helper = env["fastapi.auth.service"].new({"endpoint_id": endpoint}) + auth_partner = helper._set_password(data) + helper._set_auth_cookie(auth_partner, request, response) + return AuthPartnerResponse.from_auth_partner(auth_partner) + + +@auth_router.get("/auth/profile") +def profile( + env: Annotated[Environment, Depends(odoo_env)], + endpoint: Annotated[FastapiEndpoint, Depends(fastapi_endpoint)], + partner: Annotated[Partner, Depends(auth_partner_authenticated_partner)], +) -> AuthPartnerResponse: + helper = env["fastapi.auth.service"].new({"endpoint_id": endpoint}) + auth_partner = helper._get_auth_from_partner(partner) + return AuthPartnerResponse.from_auth_partner(auth_partner) + + +@auth_router.get("/auth/impersonate/{token}") +def impersonate( + token: str, + env: Annotated[Environment, Depends(odoo_env)], + endpoint: Annotated[FastapiEndpoint, Depends(fastapi_endpoint)], + request: Request, +) -> RedirectResponse: + helper = env["fastapi.auth.service"].new({"endpoint_id": endpoint}) + auth_partner = helper._impersonate(token) + base = ( + endpoint.public_url + or endpoint.public_api_url + or ( + env["ir.config_parameter"].sudo().get_param("web.base.url") + + endpoint.root_path + ) + ) + response = RedirectResponse(url=base) + helper._set_auth_cookie(auth_partner, request, response) + return response + + +class AuthService(models.AbstractModel): + _name = "fastapi.auth.service" + _description = "Fastapi Auth Service" + + endpoint_id = fields.Many2one("fastapi.endpoint", required=True) + directory_id = fields.Many2one("auth.directory") + + def new(self, vals, **kwargs): + # As we are in an abstract model, we need to bypass cache since it's + # based on the record id (and there is none) + endpoint = vals.pop("endpoint_id") + + rec = super().new(vals, **kwargs) + # Can't have computed / related field in AbstractModel + rec.endpoint_id = endpoint + rec.directory_id = rec.endpoint_id.directory_id + # Auto add endpoint context for mail context + return rec.with_context(_fastapi_endpoint_id=endpoint.id) + + def _get_auth_from_partner(self, partner): + return partner._get_auth_partner_for_directory(self.directory_id) + + def _signup(self, data): + auth_partner = ( + self.env["auth.partner"] + .sudo() + ._signup(self.directory_id, **data.model_dump()) + ) + return auth_partner + + def _login(self, data): + return ( + self.env["auth.partner"] + .sudo() + ._login(self.directory_id, **data.model_dump()) + ) + + def _impersonate(self, token): + return self.env["auth.partner"].sudo()._impersonating(self.directory_id, token) + + def _logout(self): + pass + + def _set_password(self, data): + return ( + self.env["auth.partner"] + .sudo() + ._set_password(self.directory_id, data.token, data.password) + ) + + def _request_reset_password(self, data): + # There can be only one auth_partner per login per directory + auth_partner = ( + self.env["auth.partner"] + .sudo() + .search( + [ + ("directory_id", "=", self.directory_id.id), + ("login", "=", data.login.lower()), + ] + ) + ) + + if not auth_partner: + # do not leak information, no partner no mail sent + return + + return auth_partner.sudo()._request_reset_password() + + def _validate_email(self, data): + return ( + self.env["auth.partner"] + .sudo() + ._validate_email(self.directory_id, data.token) + ) + + def _prepare_cookie_payload(self, partner): + # use short key to reduce cookie size + return { + "did": self.directory_id.id, + "pid": partner.id, + } + + def _prepare_cookie(self, partner): + secret = self.directory_id.cookie_secret_key or self.directory_id.secret_key + if not secret: + raise ValidationError(self.env._("No cookie secret key defined")) + payload = self._prepare_cookie_payload(partner) + value = URLSafeTimedSerializer(secret).dumps(payload) + exp = ( + datetime.now(timezone.utc) + + timedelta(minutes=self.directory_id.cookie_duration) + ).timestamp() + vals = { + "value": value, + "expires": exp, + "httponly": True, + "secure": True, + "samesite": "strict", + } + if tools.config.get("test_enable"): + # do not force https for test + vals["secure"] = False + return vals + + def _set_auth_cookie(self, auth_partner, request, response): + response.set_cookie( + COOKIE_AUTH_NAME, **self.sudo()._prepare_cookie(auth_partner.partner_id) + ) + + def _clear_auth_cookie(self, response): + response.set_cookie(COOKIE_AUTH_NAME, max_age=0) diff --git a/fastapi_auth_partner/schemas.py b/fastapi_auth_partner/schemas.py new file mode 100644 index 000000000..27bec5f05 --- /dev/null +++ b/fastapi_auth_partner/schemas.py @@ -0,0 +1,40 @@ +# Copyright 2024 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from extendable_pydantic import StrictExtendableBaseModel + + +class AuthLoginInput(StrictExtendableBaseModel): + login: str + password: str + + +class AuthRegisterInput(StrictExtendableBaseModel): + name: str + login: str + password: str + + +class AuthForgetPasswordInput(StrictExtendableBaseModel): + login: str + + +class AuthSetPasswordInput(StrictExtendableBaseModel): + token: str + password: str + + +class AuthValidateEmailInput(StrictExtendableBaseModel): + token: str + + +class AuthPartnerResponse(StrictExtendableBaseModel): + login: str + mail_verified: bool + + @classmethod + def from_auth_partner(cls, odoo_rec): + return cls.model_construct( + login=odoo_rec.login, mail_verified=odoo_rec.mail_verified + ) diff --git a/fastapi_auth_partner/security/ir.model.access.csv b/fastapi_auth_partner/security/ir.model.access.csv new file mode 100644 index 000000000..b52ae0751 --- /dev/null +++ b/fastapi_auth_partner/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +api_access_fastapi_wizard_auth_partner_impersonate,fastapi_wizard_auth_partner_impersonate,model_wizard_auth_partner_impersonate,auth_partner.group_auth_partner_manager,1,1,1,1 diff --git a/fastapi_auth_partner/security/ir_rule.xml b/fastapi_auth_partner/security/ir_rule.xml new file mode 100644 index 000000000..3d599bd0f --- /dev/null +++ b/fastapi_auth_partner/security/ir_rule.xml @@ -0,0 +1,26 @@ + + + + Auth API (res_partner) + + + [('id','=', authenticated_partner_id)] + + + + + + + + Auth API (auth_partner) + + + [('partner_id','=', authenticated_partner_id)] + + + + + + diff --git a/fastapi_auth_partner/security/res_group.xml b/fastapi_auth_partner/security/res_group.xml new file mode 100644 index 000000000..e1879c2f3 --- /dev/null +++ b/fastapi_auth_partner/security/res_group.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/fastapi_auth_partner/static/description/icon.png b/fastapi_auth_partner/static/description/icon.png new file mode 100644 index 000000000..1dcc49c24 Binary files /dev/null and b/fastapi_auth_partner/static/description/icon.png differ diff --git a/fastapi_auth_partner/static/description/index.html b/fastapi_auth_partner/static/description/index.html new file mode 100644 index 000000000..4ecf90032 --- /dev/null +++ b/fastapi_auth_partner/static/description/index.html @@ -0,0 +1,481 @@ + + + + + +Fastapi Auth Partner + + + +
+

Fastapi Auth Partner

+ + +

Beta License: AGPL-3 OCA/rest-framework Translate me on Weblate Try me on Runboat

+

This module is the FastAPI implementation of +auth_partner it provides all the routes to manage +the authentication of partners.

+

Table of contents

+ +
+

Usage

+

First you have to add the auth router to your FastAPI endpoint and the +authentication dependency to your app dependencies:

+
+from odoo.addons.fastapi import dependencies
+from odoo.addons.fastapi_auth_partner.dependencies import (
+  auth_partner_authenticated_partner,
+)
+from odoo.addons.fastapi_auth_partner.routers.auth import auth_router
+
+class FastapiEndpoint(models.Model):
+    _inherit = "fastapi.endpoint"
+
+    def _get_fastapi_routers(self):
+      if self.app == "myapp":
+          return [
+              auth_router,
+          ]
+      return super()._get_fastapi_routers()
+
+    def _get_app_dependencies_overrides(self):
+        res = super()._get_app_dependencies_overrides()
+        if self.app == "myapp":
+            res.update(
+                {
+                    dependencies.authenticated_partner_impl: auth_partner_authenticated_partner,
+                }
+            )
+        return res
+
+

Next you can manage your authenticable partners and directories in the +Odoo interface:

+

FastAPI > Authentication > Partner

+

and

+

FastAPI > Authentication > Directory

+

Next you must set the directory used for the authentication in the +FastAPI endpoint:

+

FastAPI > FastAPI Endpoint > myapp > Directory

+

Then you can use the auth router to authenticate your requests:

+
    +
  • POST /auth/register to register a partner
  • +
  • POST /auth/login to authenticate a partner
  • +
  • POST /auth/logout to unauthenticate a partner
  • +
  • POST /auth/validate_email to validate a partner email
  • +
  • POST /auth/request_reset_password to request a password reset
  • +
  • POST /auth/set_password to set a new password
  • +
  • GET /auth/profile to get the partner profile
  • +
  • GET /auth/impersonate to impersonate a partner
  • +
+
+
+

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
  • +
+
+
+

Contributors

+
    +
  • Akretion:
      +
    • Sébastien Beau
    • +
    • Florian Mounier
    • +
    +
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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.

+

This module is part of the OCA/rest-framework project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/fastapi_auth_partner/tests/__init__.py b/fastapi_auth_partner/tests/__init__.py new file mode 100644 index 000000000..021c23763 --- /dev/null +++ b/fastapi_auth_partner/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_auth +from . import test_fastapi_auth_partner_demo diff --git a/fastapi_auth_partner/tests/test_auth.py b/fastapi_auth_partner/tests/test_auth.py new file mode 100644 index 000000000..04f24ccf5 --- /dev/null +++ b/fastapi_auth_partner/tests/test_auth.py @@ -0,0 +1,243 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import json +from contextlib import contextmanager +from functools import partial + +from requests import Response + +from odoo.tests.common import tagged +from odoo.tools import mute_logger + +from odoo.addons.auth_partner.tests.common import CommonTestAuthPartner +from odoo.addons.extendable_fastapi.tests.common import FastAPITransactionCase +from odoo.addons.fastapi.dependencies import fastapi_endpoint + +from fastapi import status + +from ..routers.auth import auth_router + + +class CommonTestAuth(FastAPITransactionCase): + @contextmanager + def _create_test_client(self, **kwargs): + self.env.invalidate_all() + with mute_logger("httpx"): + with super()._create_test_client(**kwargs) as test_client: + yield test_client + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.demo_app = cls.env.ref("fastapi_auth_partner.fastapi_endpoint_demo") + cls.env = cls.env(context=dict(cls.env.context, queue_job__no_delay=True)) + cls.default_fastapi_router = auth_router + cls.default_fastapi_app = cls.demo_app._get_app() + cls.default_fastapi_dependency_overrides = { + fastapi_endpoint: partial(lambda a: a, cls.demo_app) + } + cls.default_fastapi_odoo_env = cls.env + cls.default_fastapi_running_user = cls.demo_app.user_id + + def _register_partner(self): + with self._create_test_client() as test_client, self.new_mails() as new_mails: + response: Response = test_client.post( + "/auth/register", + content=json.dumps( + { + "name": "Loriot", + "login": "loriot@example.org", + "password": "supersecret", + } + ), + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.text) + return response, new_mails + + def _login(self, test_client, password="supersecret"): + response: Response = test_client.post( + "/auth/login", + content=json.dumps( + { + "login": "loriot@example.org", + "password": password, + } + ), + ) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.text) + return response + + +@tagged("post_install", "-at_install") +class TestFastapiAuthPartner(CommonTestAuth, CommonTestAuthPartner): + def test_register(self): + response, new_mails = self._register_partner() + self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.text) + self.assertEqual( + response.json(), {"login": "loriot@example.org", "mail_verified": False} + ) + self.assertEqual(len(new_mails), 1) + self.assertIn( + "please click on the following link to verify your email", + str(new_mails.body), + ) + + def test_login(self): + self._register_partner() + with self._create_test_client() as test_client: + response = self._login(test_client) + self.assertEqual( + response.json(), {"login": "loriot@example.org", "mail_verified": False} + ) + + def test_logout(self): + self._register_partner() + with self._create_test_client() as test_client: + response: Response = test_client.post("/auth/logout") + self.assertEqual( + response.status_code, status.HTTP_205_RESET_CONTENT, response.text + ) + + def test_request_reset_password(self): + self._register_partner() + with self._create_test_client() as test_client, self.new_mails() as new_mails: + response: Response = test_client.post( + "/auth/request_reset_password", + content=json.dumps({"login": "loriot@example.org"}), + ) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.text) + self.assertFalse( + self.env["auth.partner"] + .search([("login", "=", "loriot@example.org")]) + .mail_verified, + ) + self.assertEqual(len(new_mails), 1) + self.assertIn( + "Click on the following link to reset your password", + str(new_mails.body), + ) + token = str(new_mails.body).split("token=")[1].split('">')[0] + response: Response = test_client.post( + "/auth/set_password", + content=json.dumps( + { + "password": "megasecret", + "token": token, + } + ), + ) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.text) + + self.assertTrue( + self.env["auth.partner"] + .search([("login", "=", "loriot@example.org")]) + .mail_verified, + ) + response = self._login(test_client, password="megasecret") + self.assertEqual( + response.json(), {"login": "loriot@example.org", "mail_verified": True} + ) + + def test_validate_email(self): + self._register_partner() + mail = self.env["mail.mail"].search([], limit=1, order="id desc") + self.assertIn( + "please click on the following link to verify your email", str(mail.body) + ) + self.assertFalse( + self.env["auth.partner"] + .search([("login", "=", "loriot@example.org")]) + .mail_verified, + ) + token = str(mail.body).split("token=")[1].split('">')[0] + with self._create_test_client() as test_client: + response: Response = test_client.post( + "/auth/validate_email", + content=json.dumps({"token": token}), + ) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.text) + + self.assertTrue( + self.env["auth.partner"] + .search([("login", "=", "loriot@example.org")]) + .mail_verified, + ) + + def test_impersonate(self): + self.demo_app.public_url = self.demo_app.public_api_url = False + self._register_partner() + auth_partner = self.env["auth.partner"].search( + [("login", "=", "loriot@example.org")] + ) + self.assertEqual(len(auth_partner), 1) + action = auth_partner.with_user(self.env.ref("base.user_admin")).impersonate() + url = action["url"].split("fastapi_auth_partner_demo", 1)[1] + + with self._create_test_client() as test_client: + response: Response = test_client.get(url, follow_redirects=False) + self.assertEqual(response.status_code, status.HTTP_307_TEMPORARY_REDIRECT) + self.assertTrue( + response.headers["location"].endswith("/fastapi_auth_partner_demo") + ) + self.assertIn("fastapi_auth_partner", response.cookies) + + def test_impersonate_api_url(self): + self._register_partner() + auth_partner = self.env["auth.partner"].search( + [("login", "=", "loriot@example.org")] + ) + self.assertEqual(len(auth_partner), 1) + action = auth_partner.with_user(self.env.ref("base.user_admin")).impersonate() + self.assertTrue( + action["url"].startswith("https://api.example.com/auth/impersonate/") + ) + action["url"].split("auth/impersonate/", 1)[1] + + def test_wizard_auth_partner_impersonate(self): + self._register_partner() + action = ( + self.env["wizard.auth.partner.impersonate"] + .create( + { + "auth_partner_id": self.env["auth.partner"] + .search([("login", "=", "loriot@example.org")]) + .id, + "fastapi_endpoint_id": self.demo_app.id, + } + ) + .with_user(self.env.ref("base.user_admin")) + .action_impersonate() + ) + self.assertTrue( + action["url"].startswith("https://api.example.com/auth/impersonate/") + ) + + def test_wizard_auth_partner_reset_password(self): + self._register_partner() + + template = self.env.ref("auth_partner.email_reset_password") + template.body_html = template.body_html.replace( + "https://example.org/", "{{ object.env.context['public_url'] }}" + ) + with self.new_mails() as new_mails: + self.env["wizard.auth.partner.reset.password"].create( + { + "delay": "2-days", + "template_id": template.id, + "fastapi_endpoint_id": self.demo_app.id, + } + ).with_context( + active_ids=self.env["auth.partner"] + .search([("login", "=", "loriot@example.org")]) + .ids + ).action_reset_password() + + self.assertEqual(len(new_mails), 1) + self.assertIn( + "Click on the following link to reset your password", str(new_mails.body) + ) + self.assertIn( + "https://www.example.com/password/reset?token=", str(new_mails.body) + ) diff --git a/fastapi_auth_partner/tests/test_fastapi_auth_partner_demo.py b/fastapi_auth_partner/tests/test_fastapi_auth_partner_demo.py new file mode 100644 index 000000000..81630c74c --- /dev/null +++ b/fastapi_auth_partner/tests/test_fastapi_auth_partner_demo.py @@ -0,0 +1,89 @@ +# Copyright 2023 ACSONE SA/NV +# Copyright 2023 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json +from typing import Annotated + +from odoo import tests +from odoo.tools import mute_logger + +from odoo.addons.base.models.res_partner import Partner +from odoo.addons.fastapi_auth_partner.dependencies import AuthPartner +from odoo.addons.fastapi_auth_partner.routers.auth import auth_router +from odoo.addons.fastapi_auth_partner.schemas import AuthPartnerResponse + +from fastapi import Depends, status + + +@auth_router.get("/auth/whoami-public-or-partner") +def whoami_public_or_partner( + partner: Annotated[ + Partner, + Depends(AuthPartner(allow_unauthenticated=True)), + ], +) -> AuthPartnerResponse: + if partner: + return AuthPartnerResponse.from_auth_partner(partner.auth_partner_ids) + return AuthPartnerResponse(login="no-one", mail_verified=False) + + +@tests.tagged("post_install", "-at_install") +class TestEndToEnd(tests.HttpCase): + def setUp(self): + super().setUp() + endpoint = self.env.ref("fastapi_auth_partner.fastapi_endpoint_demo") + endpoint._handle_registry_sync() + + self.fastapi_demo_app = self.env.ref("fastapi.fastapi_endpoint_demo") + self.fastapi_demo_app._handle_registry_sync() + + def _register_partner(self): + return self.url_open( + "/fastapi_auth_partner_demo/auth/register", + timeout=1000, + data=json.dumps( + { + "name": "Loriot", + "login": "loriot@example.org", + "password": "supersecret", + } + ), + ) + + def test_register(self): + response = self._register_partner() + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual( + response.json(), {"login": "loriot@example.org", "mail_verified": False} + ) + self.assertIn("fastapi_auth_partner", response.cookies) + + def test_profile(self): + self._register_partner() + resp = self.url_open("/fastapi_auth_partner_demo/auth/profile") + resp.raise_for_status() + data = resp.json() + self.assertEqual( + data, + {"login": "loriot@example.org", "mail_verified": False}, + ) + + @mute_logger("odoo.http", "odoo.addons.base.models.assetsbundle") + def test_profile_forbidden(self): + """A end-to-end test with negative authentication.""" + resp = self.url_open("/fastapi_auth_partner_demo/auth/profile") + self.assertEqual(resp.status_code, 401) + + def test_public(self): + """A end-to-end test for anonymous/public access.""" + resp = self.url_open("/fastapi_auth_partner_demo/auth/whoami-public-or-partner") + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json(), {"login": "no-one", "mail_verified": False}) + + self._register_partner() + resp = self.url_open("/fastapi_auth_partner_demo/auth/whoami-public-or-partner") + self.assertEqual( + resp.json(), {"login": "loriot@example.org", "mail_verified": False} + ) diff --git a/fastapi_auth_partner/views/auth_directory_view.xml b/fastapi_auth_partner/views/auth_directory_view.xml new file mode 100644 index 000000000..fd115531c --- /dev/null +++ b/fastapi_auth_partner/views/auth_directory_view.xml @@ -0,0 +1,29 @@ + + + + auth.directory + + +
+
+ + + + + + +
+ + +
diff --git a/fastapi_auth_partner/views/auth_partner_view.xml b/fastapi_auth_partner/views/auth_partner_view.xml new file mode 100644 index 000000000..0417e1f32 --- /dev/null +++ b/fastapi_auth_partner/views/auth_partner_view.xml @@ -0,0 +1,31 @@ + + + + auth.partner + + + + + + + + + diff --git a/fastapi_auth_partner/views/fastapi_endpoint_view.xml b/fastapi_auth_partner/views/fastapi_endpoint_view.xml new file mode 100644 index 000000000..c7c3f66ac --- /dev/null +++ b/fastapi_auth_partner/views/fastapi_endpoint_view.xml @@ -0,0 +1,22 @@ + + + + fastapi.endpoint + + + + + + + + + + + + + + diff --git a/fastapi_auth_partner/wizards/__init__.py b/fastapi_auth_partner/wizards/__init__.py new file mode 100644 index 000000000..adc3f5233 --- /dev/null +++ b/fastapi_auth_partner/wizards/__init__.py @@ -0,0 +1,2 @@ +from . import wizard_auth_partner_impersonate +from . import wizard_auth_partner_reset_password diff --git a/fastapi_auth_partner/wizards/wizard_auth_partner_impersonate.py b/fastapi_auth_partner/wizards/wizard_auth_partner_impersonate.py new file mode 100644 index 000000000..8d04cef3c --- /dev/null +++ b/fastapi_auth_partner/wizards/wizard_auth_partner_impersonate.py @@ -0,0 +1,29 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo import fields, models + + +class WizardAuthPartnerImpersonate(models.TransientModel): + _name = "wizard.auth.partner.impersonate" + _description = "Wizard Partner Auth Impersonate" + + auth_partner_id = fields.Many2one( + "auth.partner", + required=True, + ) + auth_directory_id = fields.Many2one( + "auth.directory", + related="auth_partner_id.directory_id", + ) + fastapi_endpoint_id = fields.Many2one( + "fastapi.endpoint", + required=True, + ) + + def action_impersonate(self): + return self.auth_partner_id.with_context( + fastapi_endpoint_id=self.fastapi_endpoint_id.id + ).impersonate() diff --git a/fastapi_auth_partner/wizards/wizard_auth_partner_impersonate_view.xml b/fastapi_auth_partner/wizards/wizard_auth_partner_impersonate_view.xml new file mode 100644 index 000000000..bfe5201ec --- /dev/null +++ b/fastapi_auth_partner/wizards/wizard_auth_partner_impersonate_view.xml @@ -0,0 +1,40 @@ + + + + wizard.auth.partner.impersonate + +
+ Please choose an endpoint: + + + + +
+
+ +
+
+
+ + + Impersonate + wizard.auth.partner.impersonate + ir.actions.act_window + form + new + + +
diff --git a/fastapi_auth_partner/wizards/wizard_auth_partner_reset_password.py b/fastapi_auth_partner/wizards/wizard_auth_partner_reset_password.py new file mode 100644 index 000000000..5b3eeecd2 --- /dev/null +++ b/fastapi_auth_partner/wizards/wizard_auth_partner_reset_password.py @@ -0,0 +1,18 @@ +# Copyright 2024 Akretion (https://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class WizardAuthPartnerResetPassword(models.TransientModel): + _inherit = "wizard.auth.partner.reset.password" + + fastapi_endpoint_id = fields.Many2one( + "fastapi.endpoint", + ) + + def action_reset_password(self): + if self.fastapi_endpoint_id: + self = self.with_context(_fastapi_endpoint_id=self.fastapi_endpoint_id.id) + return super().action_reset_password() diff --git a/fastapi_auth_partner/wizards/wizard_auth_partner_reset_password_view.xml b/fastapi_auth_partner/wizards/wizard_auth_partner_reset_password_view.xml new file mode 100644 index 000000000..49e2edf1d --- /dev/null +++ b/fastapi_auth_partner/wizards/wizard_auth_partner_reset_password_view.xml @@ -0,0 +1,15 @@ + + + + wizard.auth.partner.reset.password + + + + + + + + diff --git a/requirements.txt b/requirements.txt index 109483dfa..7fae249f8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,7 @@ contextvars cryptography extendable>=0.0.4 fastapi>=0.110.0 +itsdangerous parse-accept-language pydantic>=2.0.0 pyjwt diff --git a/test-requirements.txt b/test-requirements.txt index cf108f84e..f280b3e59 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,2 +1,4 @@ +odoo-addon-auth-partner @ git+https://github.com/OCA/rest-framework.git@refs/pull/580/head#subdirectory=auth_partner +odoo-addon-extendable-fastapi @ git+https://github.com/OCA/rest-framework.git@refs/pull/540/head#subdirectory=extendable_fastapi odoo_test_helper httpx