Skip to content

Commit

Permalink
Merge pull request #6 from akretion/16.0-add-fastapi-auth-impersonate
Browse files Browse the repository at this point in the history
[IMP] fastapi_auth_partner: Add impersonations
  • Loading branch information
sebastienbeau authored Jun 27, 2024
2 parents 9ce4f93 + 4b42388 commit 7392e82
Show file tree
Hide file tree
Showing 13 changed files with 274 additions and 18 deletions.
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(

Check warning on line 58 in fastapi_auth_partner/models/fastapi_auth_directory.py

View check run for this annotation

Codecov / codecov/patch

fastapi_auth_partner/models/fastapi_auth_directory.py#L58

Added line #L58 was not covered by tests
[
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()

Check warning on line 144 in fastapi_auth_partner/models/fastapi_auth_partner.py

View check run for this annotation

Codecov / codecov/patch

fastapi_auth_partner/models/fastapi_auth_partner.py#L144

Added line #L144 was not covered by tests
return self.browse(_id)

def local_impersonate(self):
"""Local impersonate for dev mode"""
self.ensure_one()

Check warning on line 149 in fastapi_auth_partner/models/fastapi_auth_partner.py

View check run for this annotation

Codecov / codecov/patch

fastapi_auth_partner/models/fastapi_auth_partner.py#L149

Added line #L149 was not covered by tests
if not self.env.user._is_admin():
raise AccessDenied(_("Only admin can impersonate locally"))

Check warning on line 151 in fastapi_auth_partner/models/fastapi_auth_partner.py

View check run for this annotation

Codecov / codecov/patch

fastapi_auth_partner/models/fastapi_auth_partner.py#L151

Added line #L151 was not covered by tests

if not hasattr(request, "future_response"):
raise UserError(

Check warning on line 154 in fastapi_auth_partner/models/fastapi_auth_partner.py

View check run for this annotation

Codecov / codecov/patch

fastapi_auth_partner/models/fastapi_auth_partner.py#L154

Added line #L154 was not covered by tests
_("Please install base_future_response for local impersonate to work")
)
self._set_auth_cookie(request.future_response)

Check warning on line 157 in fastapi_auth_partner/models/fastapi_auth_partner.py

View check run for this annotation

Codecov / codecov/patch

fastapi_auth_partner/models/fastapi_auth_partner.py#L157

Added line #L157 was not covered by tests
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()

Check warning on line 177 in fastapi_auth_partner/models/fastapi_auth_partner.py

View check run for this annotation

Codecov / codecov/patch

fastapi_auth_partner/models/fastapi_auth_partner.py#L177

Added line #L177 was not covered by tests
if self.env.user not in self.impersonating_user_ids:
raise AccessDenied(_("You are not allowed to impersonate this user"))

Check warning on line 179 in fastapi_auth_partner/models/fastapi_auth_partner.py

View check run for this annotation

Codecov / codecov/patch

fastapi_auth_partner/models/fastapi_auth_partner.py#L179

Added line #L179 was not covered by tests

endpoint_id = self.env.context.get("fastapi_endpoint_id")

Check warning on line 181 in fastapi_auth_partner/models/fastapi_auth_partner.py

View check run for this annotation

Codecov / codecov/patch

fastapi_auth_partner/models/fastapi_auth_partner.py#L181

Added line #L181 was not covered by tests
if endpoint_id:
endpoint = self.env["fastapi.endpoint"].browse(endpoint_id)

Check warning on line 183 in fastapi_auth_partner/models/fastapi_auth_partner.py

View check run for this annotation

Codecov / codecov/patch

fastapi_auth_partner/models/fastapi_auth_partner.py#L183

Added line #L183 was not covered by tests
if not endpoint:
return

Check warning on line 185 in fastapi_auth_partner/models/fastapi_auth_partner.py

View check run for this annotation

Codecov / codecov/patch

fastapi_auth_partner/models/fastapi_auth_partner.py#L185

Added line #L185 was not covered by tests
else:
endpoints = self.directory_id.fastapi_endpoint_ids

Check warning on line 187 in fastapi_auth_partner/models/fastapi_auth_partner.py

View check run for this annotation

Codecov / codecov/patch

fastapi_auth_partner/models/fastapi_auth_partner.py#L187

Added line #L187 was not covered by tests
if len(endpoints) == 1:
endpoint = endpoints

Check warning on line 189 in fastapi_auth_partner/models/fastapi_auth_partner.py

View check run for this annotation

Codecov / codecov/patch

fastapi_auth_partner/models/fastapi_auth_partner.py#L189

Added line #L189 was not covered by tests
else:
wizard = self.env["ir.actions.act_window"]._for_xml_id(

Check warning on line 191 in fastapi_auth_partner/models/fastapi_auth_partner.py

View check run for this annotation

Codecov / codecov/patch

fastapi_auth_partner/models/fastapi_auth_partner.py#L191

Added line #L191 was not covered by tests
"fastapi_auth_partner.fastapi_auth_partner_action_impersonate"
)
wizard["context"] = {"default_fastapi_auth_partner_id": self.id}
return wizard

Check warning on line 195 in fastapi_auth_partner/models/fastapi_auth_partner.py

View check run for this annotation

Codecov / codecov/patch

fastapi_auth_partner/models/fastapi_auth_partner.py#L194-L195

Added lines #L194 - L195 were not covered by tests

base = endpoint.public_url or (

Check warning on line 197 in fastapi_auth_partner/models/fastapi_auth_partner.py

View check run for this annotation

Codecov / codecov/patch

fastapi_auth_partner/models/fastapi_auth_partner.py#L197

Added line #L197 was not covered by tests
self.env["ir.config_parameter"].sudo().get_param("web.base.url")
+ endpoint.root_path
)

token = random_token()
expiration = datetime.now() + timedelta(

Check warning on line 203 in fastapi_auth_partner/models/fastapi_auth_partner.py

View check run for this annotation

Codecov / codecov/patch

fastapi_auth_partner/models/fastapi_auth_partner.py#L202-L203

Added lines #L202 - L203 were not covered by tests
minutes=self.directory_id.impersonating_token_duration
)
self.write(

Check warning on line 206 in fastapi_auth_partner/models/fastapi_auth_partner.py

View check run for this annotation

Codecov / codecov/patch

fastapi_auth_partner/models/fastapi_auth_partner.py#L206

Added line #L206 was not covered by tests
{
"token_impersonating_encrypted": self._encrypt_token(token),
"token_impersonating_expiration": expiration,
}
)
url = f"{base}/auth/impersonate/{self.id}/{token}"
return {

Check warning on line 213 in fastapi_auth_partner/models/fastapi_auth_partner.py

View check run for this annotation

Codecov / codecov/patch

fastapi_auth_partner/models/fastapi_auth_partner.py#L212-L213

Added lines #L212 - L213 were not covered by tests
"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

Check warning on line 222 in fastapi_auth_partner/models/fastapi_auth_partner.py

View check run for this annotation

Codecov / codecov/patch

fastapi_auth_partner/models/fastapi_auth_partner.py#L222

Added line #L222 was not covered by tests

def impersonating(self, directory, fastapi_partner_id, token):
hashed_token = self._encrypt_token(token)
partner_auth = self.search(

Check warning on line 226 in fastapi_auth_partner/models/fastapi_auth_partner.py

View check run for this annotation

Codecov / codecov/patch

fastapi_auth_partner/models/fastapi_auth_partner.py#L225-L226

Added lines #L225 - L226 were not covered by tests
[
("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

Check warning on line 237 in fastapi_auth_partner/models/fastapi_auth_partner.py

View check run for this annotation

Codecov / codecov/patch

fastapi_auth_partner/models/fastapi_auth_partner.py#L237

Added line #L237 was not covered by tests
else:
raise UserError(_("The token is not valid, please request a new one"))

Check warning on line 239 in fastapi_auth_partner/models/fastapi_auth_partner.py

View check run for this annotation

Codecov / codecov/patch

fastapi_auth_partner/models/fastapi_auth_partner.py#L239

Added line #L239 was not covered by tests

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()

Check warning on line 41 in fastapi_auth_partner/models/fastapi_endpoint.py

View check run for this annotation

Codecov / codecov/patch

fastapi_auth_partner/models/fastapi_endpoint.py#L41

Added line #L41 was not covered by tests
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 = (

Check warning on line 118 in fastapi_auth_partner/routers/auth.py

View check run for this annotation

Codecov / codecov/patch

fastapi_auth_partner/routers/auth.py#L118

Added line #L118 was not covered by tests
env["fastapi.auth.service"]
.sudo()
._impersonate(endpoint.directory_id, fastapi_partner_id, token)
)
response = RedirectResponse(url="/")
partner_auth._set_auth_cookie(response)
return response

Check warning on line 125 in fastapi_auth_partner/routers/auth.py

View check run for this annotation

Codecov / codecov/patch

fastapi_auth_partner/routers/auth.py#L123-L125

Added lines #L123 - L125 were not covered by tests


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"))

Check warning on line 162 in fastapi_auth_partner/routers/auth.py

View check run for this annotation

Codecov / codecov/patch

fastapi_auth_partner/routers/auth.py#L162

Added line #L162 was not covered by tests

def _impersonate(self, directory, fastapi_partner_id, token):
return (

Check warning on line 165 in fastapi_auth_partner/routers/auth.py

View check run for this annotation

Codecov / codecov/patch

fastapi_auth_partner/routers/auth.py#L165

Added line #L165 was not covered by tests
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(

Check warning on line 27 in fastapi_auth_partner/wizards/wizard_partner_auth_impersonate.py

View check run for this annotation

Codecov / codecov/patch

fastapi_auth_partner/wizards/wizard_partner_auth_impersonate.py#L27

Added line #L27 was not covered by tests
fastapi_endpoint_id=self.fastapi_endpoint_id.id
).impersonate()
Loading

0 comments on commit 7392e82

Please sign in to comment.