From 4b42388176aad74aa64891ebe8c142f7c8f94aa8 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Tue, 18 Jun 2024 12:52:14 +0200 Subject: [PATCH] [IMP] fastapi_auth_partner: Add impersonations --- fastapi_auth_partner/__manifest__.py | 1 + .../models/fastapi_auth_directory.py | 21 ++++ .../models/fastapi_auth_partner.py | 105 ++++++++++++++++++ .../models/fastapi_endpoint.py | 13 ++- fastapi_auth_partner/routers/auth.py | 25 +++++ .../security/ir.model.access.csv | 1 + .../views/fastapi_auth_directory_view.xml | 10 ++ .../views/fastapi_auth_partner_view.xml | 18 ++- .../views/fastapi_endpoint_view.xml | 26 ++--- fastapi_auth_partner/wizards/__init__.py | 1 + .../wizard_partner_auth_impersonate.py | 29 +++++ .../wizard_partner_auth_impersonate_view.xml | 40 +++++++ .../wizard_partner_auth_reset_password.py | 2 +- 13 files changed, 274 insertions(+), 18 deletions(-) create mode 100644 fastapi_auth_partner/wizards/wizard_partner_auth_impersonate.py create mode 100644 fastapi_auth_partner/wizards/wizard_partner_auth_impersonate_view.xml diff --git a/fastapi_auth_partner/__manifest__.py b/fastapi_auth_partner/__manifest__.py index 7be7cc7f7..11146f290 100644 --- a/fastapi_auth_partner/__manifest__.py +++ b/fastapi_auth_partner/__manifest__.py @@ -24,6 +24,7 @@ "views/fastapi_auth_directory_view.xml", "views/res_partner_view.xml", "wizards/wizard_partner_auth_reset_password_view.xml", + "wizards/wizard_partner_auth_impersonate_view.xml", ], "demo": [ "demo/fastapi_auth_directory_demo.xml", diff --git a/fastapi_auth_partner/models/fastapi_auth_directory.py b/fastapi_auth_partner/models/fastapi_auth_directory.py index a6151d19e..d7110b93a 100644 --- a/fastapi_auth_partner/models/fastapi_auth_directory.py +++ b/fastapi_auth_partner/models/fastapi_auth_directory.py @@ -14,6 +14,9 @@ class FastApiAuthDirectory(models.Model): set_password_token_duration = fields.Integer( default=1440, help="In minute, default 1440 minutes => 24h", required=True ) + impersonating_token_duration = fields.Integer( + default=1, help="In minute, default 1 minute", required=True + ) request_reset_password_template_id = fields.Many2one( "mail.template", "Mail Template Forget Password", required=True ) @@ -33,6 +36,24 @@ class FastApiAuthDirectory(models.Model): ) count_partner = fields.Integer(compute="_compute_count_partner") + fastapi_endpoint_ids = fields.One2many( + "fastapi.endpoint", + "directory_id", + string="FastAPI Endpoints", + ) + impersonating_user_ids = fields.Many2many( + "res.users", + "fastapi_auth_directory_impersonating_user_rel", + "directory_id", + "user_id", + string="Impersonating Users", + help="These odoo users can impersonate any partner of this directory", + default=lambda self: ( + self.env.ref("base.user_root") | self.env.ref("base.user_admin") + ).ids, + groups="fastapi_auth_partner.group_partner_auth_manager", + ) + def _compute_count_partner(self): data = self.env["fastapi.auth.partner"].read_group( [ diff --git a/fastapi_auth_partner/models/fastapi_auth_partner.py b/fastapi_auth_partner/models/fastapi_auth_partner.py index 1eed35b3d..6f4c51ee2 100644 --- a/fastapi_auth_partner/models/fastapi_auth_partner.py +++ b/fastapi_auth_partner/models/fastapi_auth_partner.py @@ -8,6 +8,7 @@ from odoo import _, api, fields, models, tools from odoo.exceptions import AccessDenied, UserError, ValidationError +from odoo.http import request from odoo.addons.auth_signup.models.res_partner import random_token @@ -15,6 +16,7 @@ # https://passlib.readthedocs.io # https://passlib.readthedocs.io/en/stable/narr/quickstart.html#choosing-a-hash # be carefull odoo requirements use an old version of passlib +# TODO: replace with a JWT token DEFAULT_CRYPT_CONTEXT = passlib.context.CryptContext(["pbkdf2_sha512"]) DEFAULT_CRYPT_CONTEXT_TOKEN = passlib.context.CryptContext( ["pbkdf2_sha512"], pbkdf2_sha512__salt_size=0 @@ -38,11 +40,20 @@ class FastApiAuthPartner(models.Model): directory_id = fields.Many2one( "fastapi.auth.directory", "Directory", required=True, index=True ) + user_can_impersonate = fields.Boolean( + compute="_compute_user_can_impersonate", + help="Technical field to check if the user can impersonate", + ) + impersonating_user_ids = fields.Many2many( + related="directory_id.impersonating_user_ids", + ) login = fields.Char(compute="_compute_login", store=True, required=True, index=True) password = fields.Char(compute="_compute_password", inverse="_inverse_password") encrypted_password = fields.Char(index=True) token_set_password_encrypted = fields.Char() token_expiration = fields.Datetime() + token_impersonating_encrypted = fields.Char() + token_impersonating_expiration = fields.Datetime() nbr_pending_reset_sent = fields.Integer( index=True, help=( @@ -133,6 +144,100 @@ def log_in(self, directory, login, password): raise AccessDenied() return self.browse(_id) + def local_impersonate(self): + """Local impersonate for dev mode""" + self.ensure_one() + if not self.env.user._is_admin(): + raise AccessDenied(_("Only admin can impersonate locally")) + + if not hasattr(request, "future_response"): + raise UserError( + _("Please install base_future_response for local impersonate to work") + ) + self._set_auth_cookie(request.future_response) + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Impersonation successful"), + "message": _("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 impersonate(self): + self.ensure_one() + if self.env.user not in self.impersonating_user_ids: + raise AccessDenied(_("You are not allowed to impersonate this user")) + + endpoint_id = self.env.context.get("fastapi_endpoint_id") + if endpoint_id: + endpoint = self.env["fastapi.endpoint"].browse(endpoint_id) + if not endpoint: + return + else: + 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.fastapi_auth_partner_action_impersonate" + ) + wizard["context"] = {"default_fastapi_auth_partner_id": self.id} + return wizard + + base = endpoint.public_url or ( + self.env["ir.config_parameter"].sudo().get_param("web.base.url") + + endpoint.root_path + ) + + token = random_token() + expiration = datetime.now() + timedelta( + minutes=self.directory_id.impersonating_token_duration + ) + self.write( + { + "token_impersonating_encrypted": self._encrypt_token(token), + "token_impersonating_expiration": expiration, + } + ) + url = f"{base}/auth/impersonate/{self.id}/{token}" + return { + "type": "ir.actions.act_url", + "url": url, + "target": "self", + } + + @api.depends_context("uid") + def _compute_user_can_impersonate(self): + for record in self: + record.user_can_impersonate = self.env.user in record.impersonating_user_ids + + def impersonating(self, directory, fastapi_partner_id, token): + hashed_token = self._encrypt_token(token) + partner_auth = self.search( + [ + ("id", "=", fastapi_partner_id), + ("token_impersonating_encrypted", "=", hashed_token), + ("directory_id", "=", directory.id), + ] + ) + if ( + partner_auth + and partner_auth.token_impersonating_expiration > datetime.now() + ): + return partner_auth + else: + raise UserError(_("The token is not valid, please request a new one")) + def _get_template_request_reset_password(self, directory): return directory.request_reset_password_template_id diff --git a/fastapi_auth_partner/models/fastapi_endpoint.py b/fastapi_auth_partner/models/fastapi_endpoint.py index 2a0818778..20ec6f270 100644 --- a/fastapi_auth_partner/models/fastapi_endpoint.py +++ b/fastapi_auth_partner/models/fastapi_endpoint.py @@ -21,12 +21,21 @@ class FastapiEndpoint(models.Model): selection_add=[ ("auth_partner", "Partner Auth"), ], - string="Authenciation method", + string="Authentication method", ) directory_id = fields.Many2one("fastapi.auth.directory") + is_partner_auth = fields.Boolean( + compute="_compute_is_partner_auth", + help="Technical field to know if the auth method is partner", + ) + def _get_fastapi_routers(self) -> List[APIRouter]: routers = super()._get_fastapi_routers() - if self.app == "demo": + if self.app == "demo" and self.demo_auth_method == "auth_partner": routers.append(auth_router) return routers + + def _compute_is_partner_auth(self): + for rec in self: + rec.is_partner_auth = auth_router in rec._get_fastapi_routers() diff --git a/fastapi_auth_partner/routers/auth.py b/fastapi_auth_partner/routers/auth.py index 74a0601bb..6e0976906 100644 --- a/fastapi_auth_partner/routers/auth.py +++ b/fastapi_auth_partner/routers/auth.py @@ -18,6 +18,7 @@ from odoo.addons.fastapi.models import FastapiEndpoint from fastapi import APIRouter, Depends, Response +from fastapi.responses import RedirectResponse from ..dependencies import auth_partner_authenticated_partner from ..models.fastapi_auth_partner import COOKIE_AUTH_NAME @@ -107,6 +108,23 @@ def profile( return AuthPartnerResponse.from_auth_partner(partner_auth) +@auth_router.get("/auth/impersonate/{fastapi_partner_id}/{token}") +def impersonate( + fastapi_partner_id: int, + token: str, + env: Annotated[Environment, Depends(odoo_env)], + endpoint: Annotated[FastapiEndpoint, Depends(fastapi_endpoint)], +) -> RedirectResponse: + partner_auth = ( + env["fastapi.auth.service"] + .sudo() + ._impersonate(endpoint.directory_id, fastapi_partner_id, token) + ) + response = RedirectResponse(url="/") + partner_auth._set_auth_cookie(response) + return response + + class AuthService(models.AbstractModel): _name = "fastapi.auth.service" _description = "Fastapi Auth Service" @@ -143,6 +161,13 @@ def _login(self, directory, data): else: raise AccessError(_("Invalid Login or Password")) + def _impersonate(self, directory, fastapi_partner_id, token): + return ( + self.env["fastapi.auth.partner"] + .sudo() + .impersonating(directory, fastapi_partner_id, token) + ) + def _logout(self, directory, response): response.set_cookie(COOKIE_AUTH_NAME, max_age=0) diff --git a/fastapi_auth_partner/security/ir.model.access.csv b/fastapi_auth_partner/security/ir.model.access.csv index eeadf5f68..88a042b5e 100644 --- a/fastapi_auth_partner/security/ir.model.access.csv +++ b/fastapi_auth_partner/security/ir.model.access.csv @@ -5,3 +5,4 @@ access_fastapi_auth_partner,fastapi_auth_partner_manager,model_fastapi_auth_part api_access_fastapi_auth_partner,fastapi_auth_partner_api,model_fastapi_auth_partner,group_partner_auth_api,1,1,0,0 api_access_fastapi_res_partner,fastapi_res_partner_api,base.model_res_partner,group_partner_auth_api,1,0,0,0 api_access_fastapi_wizard_partner_auth_reset_password,fastapi_wizard_partner_auth_reset_password,model_wizard_partner_auth_reset_password,group_partner_auth_manager,1,1,1,1 +api_access_fastapi_wizard_partner_auth_impersonate,fastapi_wizard_partner_auth_impersonate,model_wizard_partner_auth_impersonate,group_partner_auth_manager,1,1,1,1 diff --git a/fastapi_auth_partner/views/fastapi_auth_directory_view.xml b/fastapi_auth_partner/views/fastapi_auth_directory_view.xml index 3a60376fc..d7589cbe6 100644 --- a/fastapi_auth_partner/views/fastapi_auth_directory_view.xml +++ b/fastapi_auth_partner/views/fastapi_auth_directory_view.xml @@ -42,6 +42,16 @@ + + + + diff --git a/fastapi_auth_partner/views/fastapi_auth_partner_view.xml b/fastapi_auth_partner/views/fastapi_auth_partner_view.xml index f33513b05..a43175c00 100644 --- a/fastapi_auth_partner/views/fastapi_auth_partner_view.xml +++ b/fastapi_auth_partner/views/fastapi_auth_partner_view.xml @@ -19,10 +19,26 @@ fastapi.auth.partner
-
+
+

+

diff --git a/fastapi_auth_partner/views/fastapi_endpoint_view.xml b/fastapi_auth_partner/views/fastapi_endpoint_view.xml index 8c61c179e..9ad7650b5 100644 --- a/fastapi_auth_partner/views/fastapi_endpoint_view.xml +++ b/fastapi_auth_partner/views/fastapi_endpoint_view.xml @@ -1,21 +1,19 @@ - - - fastapi.endpoint - - - - + fastapi.endpoint + + + + + + + + diff --git a/fastapi_auth_partner/wizards/wizard_partner_auth_reset_password.py b/fastapi_auth_partner/wizards/wizard_partner_auth_reset_password.py index 923c2f5e4..7643fedde 100644 --- a/fastapi_auth_partner/wizards/wizard_partner_auth_reset_password.py +++ b/fastapi_auth_partner/wizards/wizard_partner_auth_reset_password.py @@ -6,7 +6,7 @@ from odoo import api, fields, models -class WizardPartnerAuthResetPassword(models.Model): +class WizardPartnerAuthResetPassword(models.TransientModel): _name = "wizard.partner.auth.reset.password" _description = "Wizard Partner Auth Reset Password"