Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[IMP] fastapi_auth_partner: Add impersonations #6

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions fastapi_auth_partner/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 21 additions & 0 deletions fastapi_auth_partner/models/fastapi_auth_directory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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(
[
Expand Down
105 changes: 105 additions & 0 deletions fastapi_auth_partner/models/fastapi_auth_partner.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@

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

# please read passlib great documentation
# 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
Expand All @@ -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=(
Expand Down Expand Up @@ -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

Expand Down
13 changes: 11 additions & 2 deletions fastapi_auth_partner/models/fastapi_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
25 changes: 25 additions & 0 deletions fastapi_auth_partner/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions fastapi_auth_partner/security/ir.model.access.csv
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions fastapi_auth_partner/views/fastapi_auth_directory_view.xml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@
<field name="cookie_duration" />
<field name="set_password_token_duration" />
</group>
<group
name="impersonate"
groups="fastapi_auth_partner.group_partner_auth_manager"
>
<field
name="impersonating_user_ids"
widget="many2many_tags"
/>
<field name="impersonating_token_duration" />
</group>
</group>
</sheet>
</form>
Expand Down
18 changes: 17 additions & 1 deletion fastapi_auth_partner/views/fastapi_auth_partner_view.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,26 @@
<field name="model">fastapi.auth.partner</field>
<field name="arch" type="xml">
<form string="Auth Partner">
<header />
<header>
<button
name="local_impersonate"
type="object"
string="Local Impersonate"
class="btn-secondary"
groups="base.group_no_one"
/>
<button
name="impersonate"
type="object"
string="Impersonate"
class="btn-info"
attrs="{'invisible': [('user_can_impersonate', '=', False)]}"
/>
</header>
<sheet>
<div class="oe_title">
<h1>
<field name="user_can_impersonate" invisible="1" />
<field name="partner_id" />
</h1>
</div>
Expand Down
26 changes: 12 additions & 14 deletions fastapi_auth_partner/views/fastapi_endpoint_view.xml
Original file line number Diff line number Diff line change
@@ -1,21 +1,19 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>

<record id="fastapi_endpoint_view_form" model="ir.ui.view">
<field name="model">fastapi.endpoint</field>
<field name="inherit_id" ref="fastapi.fastapi_endpoint_form_view" />
<field name="arch" type="xml">
<field name="demo_auth_method" position="after">
<field
<record id="fastapi_endpoint_view_form" model="ir.ui.view">
<field name="model">fastapi.endpoint</field>
<field name="inherit_id" ref="fastapi.fastapi_endpoint_form_view" />
<field name="arch" type="xml">
<field name="demo_auth_method" position="after">
<field name="is_partner_auth" invisible="1" />
<field
name="directory_id"
attrs="{
'invisible': [('demo_auth_method', '!=', 'auth_partner')],
'required': [('demo_auth_method', '=', 'auth_partner')]
}"
'invisible': [('is_partner_auth', '=', False)],
'required': [('is_partner_auth', '=', True)]
}"
/>
</field>
</field>
</field>
</record>


</record>
</odoo>
1 change: 1 addition & 0 deletions fastapi_auth_partner/wizards/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from . import wizard_partner_auth_reset_password
from . import wizard_partner_auth_impersonate
29 changes: 29 additions & 0 deletions fastapi_auth_partner/wizards/wizard_partner_auth_impersonate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Copyright 2024 Akretion (http://www.akretion.com).
# @author Florian Mounier <[email protected]>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).


from odoo import fields, models


class WizardPartnerAuthImpersonate(models.TransientModel):
_name = "wizard.partner.auth.impersonate"
_description = "Wizard Partner Auth Impersonate"

fastapi_auth_partner_id = fields.Many2one(
"fastapi.auth.partner",
required=True,
)
fastapi_auth_directory_id = fields.Many2one(
"fastapi.auth.directory",
related="fastapi_auth_partner_id.directory_id",
)
fastapi_endpoint_id = fields.Many2one(
"fastapi.endpoint",
required=True,
)

def action_impersonate(self):
return self.fastapi_auth_partner_id.with_context(
fastapi_endpoint_id=self.fastapi_endpoint_id.id
).impersonate()
Loading
Loading