diff --git a/auth_partner/README.rst b/auth_partner/README.rst new file mode 100644 index 000000000..ca979ef04 --- /dev/null +++ b/auth_partner/README.rst @@ -0,0 +1,102 @@ +============ +Partner Auth +============ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:c9e735f01c49bc7974e3b9354b6157e19c7486a71626ad8eef81104b628d476b + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/16.0/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-16-0/rest-framework-16-0-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=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module adds to the partners the ability to authenticate through directories. + +This module does not implement any routing, it only provides the basic mechanisms in a directory for: + + - Registering a partner and sending an welcome email (to validate email address): `_signup` + - Authenticating a partner: `_login` + - Validating a partner email using a token: `_validate_email` + - Impersonating: `_impersonate`, `_impersonating` + - Resetting the password with a unique token sent by mail: `_request_reset_password`, `_set_password` + - Sending an invite mail when registering a partner from odoo interface for the partner to enter a password: `_send_invite`, `_set_password` + +For a routing implementation, see the `fastapi_auth_partner <../fastapi_auth_partner>`_ module. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +This module isn't meant to be used standalone but you can still see the directories and authenticable partners in: + +Settings > Technical > Partner Authentication > Partner + +and + +Settings > Technical > Partner Authentication > Directory + + +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/auth_partner/__init__.py b/auth_partner/__init__.py new file mode 100644 index 000000000..aee8895e7 --- /dev/null +++ b/auth_partner/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizards diff --git a/auth_partner/__manifest__.py b/auth_partner/__manifest__.py new file mode 100644 index 000000000..5adf960d4 --- /dev/null +++ b/auth_partner/__manifest__.py @@ -0,0 +1,36 @@ +# 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": "Partner Auth", + "summary": "Implements the base features for a authenticable partner", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "Akretion,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/rest-framework", + "depends": [ + "auth_signup", + "mail", + "queue_job", + ], + "data": [ + "security/res_group.xml", + "security/ir.model.access.csv", + "security/ir_rule.xml", + "data/email_data.xml", + "views/auth_partner_view.xml", + "views/auth_directory_view.xml", + "views/res_partner_view.xml", + "wizards/wizard_auth_partner_reset_password_view.xml", + ], + "demo": [ + "demo/res_partner_demo.xml", + "demo/auth_directory_demo.xml", + "demo/auth_partner_demo.xml", + ], + "external_dependencies": { + "python": ["itsdangerous", "pyjwt"], + }, +} diff --git a/auth_partner/data/email_data.xml b/auth_partner/data/email_data.xml new file mode 100644 index 000000000..92193d06d --- /dev/null +++ b/auth_partner/data/email_data.xml @@ -0,0 +1,67 @@ + + + + Auth Directory: Reset Password + noreply@example.org + Reset Password + {{object.partner_id.id}} + + + ${object.partner_id.lang} + +
+ Hi + Click on the following link to reset your password + Reset Password +
+
+
+ + + Auth Directory: Set Password + noreply@example.org + Welcome + {{object.partner_id.id}} + + + {{object.partner_id.lang}} + +
+ Hi + Welcome, your account have been created + Click on the following link to set your password + Set Password +
+
+
+ + + Auth Directory: Validate Email + noreply@example.org + Welcome + {{object.partner_id.id}} + + + {{object.partner_id.lang}} + +
+ Hi + Welcome to the site, please click on the following link to verify your email + Validate Email +
+
+
+ +
diff --git a/auth_partner/demo/auth_directory_demo.xml b/auth_partner/demo/auth_directory_demo.xml new file mode 100644 index 000000000..81708b69a --- /dev/null +++ b/auth_partner/demo/auth_directory_demo.xml @@ -0,0 +1,9 @@ + + + + Demo Auth Directory + + + + + diff --git a/auth_partner/demo/auth_partner_demo.xml b/auth_partner/demo/auth_partner_demo.xml new file mode 100644 index 000000000..93dda262c --- /dev/null +++ b/auth_partner/demo/auth_partner_demo.xml @@ -0,0 +1,8 @@ + + + + + + Super-secret$1 + + diff --git a/auth_partner/demo/res_partner_demo.xml b/auth_partner/demo/res_partner_demo.xml new file mode 100644 index 000000000..43a063524 --- /dev/null +++ b/auth_partner/demo/res_partner_demo.xml @@ -0,0 +1,7 @@ + + + + Demo auth partner + partner-auth@example.org + + diff --git a/auth_partner/models/__init__.py b/auth_partner/models/__init__.py new file mode 100644 index 000000000..6259e6d10 --- /dev/null +++ b/auth_partner/models/__init__.py @@ -0,0 +1,3 @@ +from . import auth_directory +from . import auth_partner +from . import res_partner diff --git a/auth_partner/models/auth_directory.py b/auth_partner/models/auth_directory.py new file mode 100644 index 000000000..02ded6ff4 --- /dev/null +++ b/auth_partner/models/auth_directory.py @@ -0,0 +1,202 @@ +# 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, timezone +from secrets import token_urlsafe + +import jwt + +from odoo import _, fields, models +from odoo.exceptions import UserError + +from odoo.addons.queue_job.delay import chain + + +class AuthDirectory(models.Model): + _name = "auth.directory" + _description = "Auth Directory" + + name = fields.Char(required=True) + auth_partner_ids = fields.One2many("auth.partner", "directory_id", "Auth Partners") + set_password_token_duration = fields.Integer( + default=1440, help="In minute, default 1440 minutes => 24h", required=True + ) + impersonating_token_duration = fields.Integer( + default=60, help="In seconds, default 60 seconds", required=True + ) + reset_password_template_id = fields.Many2one( + "mail.template", + "Mail Template Forget Password", + required=True, + default=lambda self: self.env.ref( + "auth_partner.email_reset_password", + raise_if_not_found=False, + ), + ) + set_password_template_id = fields.Many2one( + "mail.template", + "Mail Template New Password", + required=True, + default=lambda self: self.env.ref( + "auth_partner.email_set_password", + raise_if_not_found=False, + ), + ) + validate_email_template_id = fields.Many2one( + "mail.template", + "Mail Template Validate Email", + required=True, + default=lambda self: self.env.ref( + "auth_partner.email_validate_email", + raise_if_not_found=False, + ), + ) + secret_key = fields.Char( + groups="base.group_system", + required=True, + default=lambda self: self._generate_default_secret_key(), + ) + count_partner = fields.Integer(compute="_compute_count_partner") + + impersonating_user_ids = fields.Many2many( + "res.users", + "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="auth_partner.group_auth_partner_manager", + ) + force_verified_email = fields.Boolean( + help="If checked, email must be verified to be able to log in" + ) + + def _generate_default_secret_key(self): + # generate random ~64 chars secret key + return token_urlsafe(64) + + def action_regenerate_secret_key(self): + self.ensure_one() + self.secret_key = self._generate_default_secret_key() + + def _compute_count_partner(self): + data = self.env["auth.partner"].read_group( + [ + ("directory_id", "in", self.ids), + ], + ["directory_id"], + groupby=["directory_id"], + lazy=False, + ) + res = {item["directory_id"][0]: item["__count"] for item in data} + + for record in self: + record.count_partner = res.get(record.id, 0) + + def _get_template(self, type_or_template): + if isinstance(type_or_template, str): + return getattr(self, type_or_template + "_template_id", None) + return type_or_template + + def _prepare_mail_context(self, context): + return context or {} + + def _send_mail_background( + self, type_or_template, auth_partner, callback_job=None, **context + ): + """Send an email asynchronously to the auth_partner using the template defined in the directory""" + self.ensure_one() + auth_partner.ensure_one() + # Load context synchronously + context = self._prepare_mail_context(context) + + job = self.delayable()._send_mail_impl( + type_or_template, auth_partner, **context + ) + if callback_job: + job = chain(job, callback_job) + return job.delay() + + def _send_mail(self, type_or_template, auth_partner, **context): + """Send an email to the auth_partner using the template defined in the directory""" + self.ensure_one() + auth_partner.ensure_one() + context = self._prepare_mail_context(context) + + self._send_mail_impl(type_or_template, auth_partner, **context) + + def _send_mail_impl(self, type_or_template, auth_partner, **context): + template = self.sudo()._get_template(type_or_template) + if not template: + raise UserError( + _("No email template defined for %s in %s", type_or_template, self.name) + ) + template.sudo().with_context(**context).send_mail( + auth_partner.id, force_send=True, raise_exception=True + ) + + return f"Mail {template.name} sent to {auth_partner.login}" + + def _generate_token(self, action, auth_partner, expiration_delta, key_salt=""): + return jwt.encode( + { + "exp": datetime.now(tz=timezone.utc) + expiration_delta, + "aud": str(self.id), + "action": action, + "ap": auth_partner.id, + }, + self.secret_key + key_salt, + algorithm="HS256", + ) + + def _decode_token( + self, + token, + action, + key_salt=None, + ): + # We need to sudo here as secret_key is a protected field + self = self.sudo() + key = self.secret_key + if key_salt: + try: + object = jwt.decode( + token, algorithms=["HS256"], options={"verify_signature": False} + ) + except jwt.PyJWTError as e: + raise UserError(_("Invalid Token")) from e + probable_auth_partner = self.env["auth.partner"].browse(object["ap"]) + if not probable_auth_partner: + raise UserError(_("Invalid Token")) + key += key_salt(probable_auth_partner) + + try: + object = jwt.decode( + token, + key, + audience=str(self.id), + options={"require": ["exp", "aud", "ap", "action"]}, + algorithms=["HS256"], + ) + except jwt.PyJWTError as e: + raise UserError(_("Invalid Token")) from e + + auth_partner = self.env["auth.partner"].browse(object["ap"]) + + if ( + object["action"] != action + or not auth_partner + or auth_partner.directory_id != self + ): + raise UserError(_("Invalid token")) + + return auth_partner + + @property + def _server_env_fields(self): + return {"secret_key": {}} diff --git a/auth_partner/models/auth_partner.py b/auth_partner/models/auth_partner.py new file mode 100644 index 000000000..c23abcde7 --- /dev/null +++ b/auth_partner/models/auth_partner.py @@ -0,0 +1,311 @@ +# 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). + +import logging +from datetime import timedelta + +import passlib + +from odoo import _, api, fields, models +from odoo.exceptions import AccessDenied + +# 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 +DEFAULT_CRYPT_CONTEXT = passlib.context.CryptContext(["pbkdf2_sha512"]) + +_logger = logging.getLogger(__name__) + + +class AuthPartner(models.Model): + _name = "auth.partner" + _description = "Auth Partner" + _rec_name = "login" + + partner_id = fields.Many2one( + "res.partner", "Partner", required=True, ondelete="cascade", index=True + ) + directory_id = fields.Many2one( + "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) + nbr_pending_reset_sent = fields.Integer( + index=True, + help=( + "Number of pending reset sent from your customer." + "This field is usefull when after a migration from an other system " + "you ask all you customer to reset their password and you send" + "different mail depending on the number of reminder" + ), + ) + date_last_request_reset_pwd = fields.Datetime( + help="Date of the last password reset request" + ) + date_last_sucessfull_reset_pwd = fields.Datetime( + help="Date of the last sucessfull password reset" + ) + date_last_impersonation = fields.Datetime( + help="Date of the last sucessfull impersonation" + ) + + mail_verified = fields.Boolean( + help="This field is set to True when the user has clicked on the link sent by email" + ) + + _sql_constraints = [ + ( + "directory_login_uniq", + "unique (directory_id, login)", + "Login must be uniq per directory !", + ), + ] + + # hack to solve sql constraint + def _add_login_for_create(self, data): + partner = self.env["res.partner"].browse(data["partner_id"]) + data["login"] = partner.email + + @api.model_create_multi + def create(self, data_list): + for data in data_list: + self._add_login_for_create(data) + return super().create(data_list) + + @api.depends("partner_id.email") + def _compute_login(self): + for record in self: + record.login = record.partner_id.email + + def _crypt_context(self): + return DEFAULT_CRYPT_CONTEXT + + def _check_no_empty(self, login, password): + # double check by security but calling this through a service should + # already have check this + if not ( + isinstance(password, str) and password and isinstance(login, str) and login + ): + _logger.warning("Invalid login/password for sign in") + raise AccessDenied() + + def _get_hashed_password(self, directory, login): + self.flush() + self.env.cr.execute( + """ + SELECT id, COALESCE(encrypted_password, '') + FROM auth_partner + WHERE login=%s AND directory_id=%s""", + (login, directory.id), + ) + hashed = self.env.cr.fetchone() + if hashed and hashed[1]: + # ensure that we have a auth.partner and this partner have a password set + return hashed + else: + raise AccessDenied() + + def _compute_password(self): + for record in self: + record.password = "" + + def _inverse_password(self): + for record in self: + ctx = record._crypt_context() + record.encrypted_password = ctx.encrypt(record.password) + record.password = "" + + def _prepare_partner_auth_signup(self, directory, vals): + return { + "login": vals["login"].lower(), + "password": vals["password"], + "directory_id": directory.id, + } + + def _prepare_partner_signup(self, directory, vals): + return { + "name": vals["name"], + "email": vals["login"].lower(), + "auth_partner_ids": [ + (0, 0, self._prepare_partner_auth_signup(directory, vals)) + ], + } + + @api.model + def _signup(self, directory, **kwargs): + partner = self.env["res.partner"].create( + [ + self._prepare_partner_signup(directory, kwargs), + ] + ) + auth_partner = partner.auth_partner_ids + directory._send_mail_background( + "validate_email", + auth_partner, + token=auth_partner._generate_validate_email_token(), + ) + return auth_partner + + @api.model + def _login(self, directory, login, password, **kwargs): + self._check_no_empty(login, password) + login = login.lower() + try: + _id, hashed = self._get_hashed_password(directory, login) + valid, replacement = self._crypt_context().verify_and_update( + password, hashed + ) + + auth_partner = valid and self.browse(_id) + except AccessDenied: + # We do not want to leak information about the login, + # always raise the same exception + auth_partner = None + + if not auth_partner: + raise AccessDenied(_("Invalid Login or Password")) + + if directory.sudo().force_verified_email and not auth_partner.mail_verified: + raise AccessDenied( + _( + "Email address not validated. Validate your email address by " + "clicking on the link in the email sent to you or request a new " + "password. " + ) + ) + + if replacement is not None: + auth_partner.encrypted_password = replacement + + return auth_partner + + @api.model + def _validate_email(self, directory, token): + auth_partner = directory._decode_token(token, "validate_email") + auth_partner.write({"mail_verified": True}) + return auth_partner + + def _get_impersonate_url(self, token, **kwargs): + # You should override this method according to the impersonation url + # your framework is using + + base = self.env["ir.config_parameter"].sudo().get_param("web.base.url") + url = f"{base}/auth/impersonate/{token}" + return url + + def _get_impersonate_action(self, token, **kwargs): + return { + "type": "ir.actions.act_url", + "url": self._get_impersonate_url(token, **kwargs), + "target": "new", + } + + 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")) + + token = self._generate_impersonating_token() + return self._get_impersonate_action(token) + + @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 + + @api.model + def _impersonating(self, directory, token): + partner_auth = directory._decode_token( + token, + "impersonating", + key_salt=lambda auth_partner: ( + auth_partner.date_last_impersonation.isoformat() + if auth_partner.date_last_impersonation + else "never" + ), + ) + partner_auth.date_last_impersonation = fields.Datetime.now() + return partner_auth + + def _on_reset_password_sent(self): + self.ensure_one() + self.date_last_request_reset_pwd = fields.Datetime.now() + self.date_last_sucessfull_reset_pwd = None + self.nbr_pending_reset_sent += 1 + + def _send_invite(self): + self.ensure_one() + self.directory_id._send_mail_background( + "set_password", + self, + callback_job=self.delayable()._on_reset_password_sent(), + token=self._generate_set_password_token(), + ) + + def send_invite(self): + for rec in self: + rec._send_invite() + + def _request_reset_password(self): + return self.directory_id._send_mail_background( + "reset_password", + self, + callback_job=self.delayable()._on_reset_password_sent(), + token=self._generate_set_password_token(), + ) + + def _set_password(self, directory, token, password): + auth_partner = directory._decode_token( + token, + "set_password", + key_salt=lambda auth_partner: auth_partner.encrypted_password or "empty", + ) + auth_partner.write( + { + "password": password, + "mail_verified": True, + } + ) + auth_partner.date_last_sucessfull_reset_pwd = fields.Datetime.now() + auth_partner.nbr_pending_reset_sent = 0 + return auth_partner + + def _generate_set_password_token(self, expiration_delta=None): + return self.directory_id._generate_token( + "set_password", + self, + expiration_delta + or timedelta(minutes=self.directory_id.set_password_token_duration), + key_salt=self.encrypted_password or "empty", + ) + + def _generate_validate_email_token(self): + return self.directory_id._generate_token( + # 30 days seem to be a good value, no need for configuration + "validate_email", + self, + timedelta(days=30), + ) + + def _generate_impersonating_token(self): + return self.directory_id._generate_token( + "impersonating", + self, + timedelta(minutes=self.directory_id.impersonating_token_duration), + key_salt=( + self.date_last_impersonation.isoformat() + if self.date_last_impersonation + else "never" + ), + ) diff --git a/auth_partner/models/res_partner.py b/auth_partner/models/res_partner.py new file mode 100644 index 000000000..74274fea5 --- /dev/null +++ b/auth_partner/models/res_partner.py @@ -0,0 +1,36 @@ +# 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 ResPartner(models.Model): + _inherit = "res.partner" + + # guest logic should be moved in an dependency module + guest = fields.Boolean() + auth_partner_ids = fields.One2many("auth.partner", "partner_id", "Partner Auth") + auth_partner_count = fields.Integer( + compute="_compute_auth_partner_count", compute_sudo=True + ) + + def _compute_auth_partner_count(self): + data = self.env["auth.partner"].read_group( + [ + ("partner_id", "in", self.ids), + ], + ["partner_id"], + groupby=["partner_id"], + lazy=False, + ) + res = {item["partner_id"][0]: item["__count"] for item in data} + + for record in self: + record.auth_partner_count = res.get(record.id, 0) + + def _get_auth_partner_for_directory(self, directory): + return self.sudo().auth_partner_ids.filtered( + lambda r: r.directory_id == directory + ) diff --git a/auth_partner/readme/CONTRIBUTORS.rst b/auth_partner/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..bae3cc9a1 --- /dev/null +++ b/auth_partner/readme/CONTRIBUTORS.rst @@ -0,0 +1,4 @@ +* `Akretion `_: + + * Sébastien Beau + * Florian Mounier diff --git a/auth_partner/readme/DESCRIPTION.rst b/auth_partner/readme/DESCRIPTION.rst new file mode 100644 index 000000000..2a63b69ea --- /dev/null +++ b/auth_partner/readme/DESCRIPTION.rst @@ -0,0 +1,12 @@ +This module adds to the partners the ability to authenticate through directories. + +This module does not implement any routing, it only provides the basic mechanisms in a directory for: + + - Registering a partner and sending an welcome email (to validate email address): `_signup` + - Authenticating a partner: `_login` + - Validating a partner email using a token: `_validate_email` + - Impersonating: `_impersonate`, `_impersonating` + - Resetting the password with a unique token sent by mail: `_request_reset_password`, `_set_password` + - Sending an invite mail when registering a partner from odoo interface for the partner to enter a password: `_send_invite`, `_set_password` + +For a routing implementation, see the `fastapi_auth_partner <../fastapi_auth_partner>`_ module. diff --git a/auth_partner/readme/USAGE.rst b/auth_partner/readme/USAGE.rst new file mode 100644 index 000000000..39cb46f62 --- /dev/null +++ b/auth_partner/readme/USAGE.rst @@ -0,0 +1,8 @@ +This module isn't meant to be used standalone but you can still see the directories and authenticable partners in: + +Settings > Technical > Partner Authentication > Partner + +and + +Settings > Technical > Partner Authentication > Directory + diff --git a/auth_partner/security/ir.model.access.csv b/auth_partner/security/ir.model.access.csv new file mode 100644 index 000000000..b18349e47 --- /dev/null +++ b/auth_partner/security/ir.model.access.csv @@ -0,0 +1,7 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_auth_directory,auth_directory_system,model_auth_directory,base.group_system,1,1,1,1 +access_auth_directory_read,auth_directory_manager,model_auth_directory,group_auth_partner_manager,1,0,0,0 +access_auth_partner,auth_partner_manager,model_auth_partner,group_auth_partner_manager,1,1,1,1 +api_access_auth_partner,auth_partner_api,model_auth_partner,group_auth_partner_api,1,1,0,0 +api_access_res_partner,res_partner_api,base.model_res_partner,group_auth_partner_api,1,0,0,0 +api_access_wizard_auth_partner_reset_password,wizard_auth_partner_reset_password,model_wizard_auth_partner_reset_password,group_auth_partner_manager,1,1,1,1 diff --git a/auth_partner/security/ir_rule.xml b/auth_partner/security/ir_rule.xml new file mode 100644 index 000000000..01a9e6020 --- /dev/null +++ b/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/auth_partner/security/res_group.xml b/auth_partner/security/res_group.xml new file mode 100644 index 000000000..a912c7d2f --- /dev/null +++ b/auth_partner/security/res_group.xml @@ -0,0 +1,16 @@ + + + + API Partner Auth Manager + + + + + + API Partner Auth Access + + + diff --git a/auth_partner/static/description/index.html b/auth_partner/static/description/index.html new file mode 100644 index 000000000..0ca14933c --- /dev/null +++ b/auth_partner/static/description/index.html @@ -0,0 +1,444 @@ + + + + + +Partner Auth + + + +
+

Partner Auth

+ + +

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

+

This module adds to the partners the ability to authenticate through directories.

+

This module does not implement any routing, it only provides the basic mechanisms in a directory for:

+
+
    +
  • Registering a partner and sending an welcome email (to validate email address): _signup
  • +
  • Authenticating a partner: _login
  • +
  • Validating a partner email using a token: _validate_email
  • +
  • Impersonating: _impersonate, _impersonating
  • +
  • Resetting the password with a unique token sent by mail: _request_reset_password, _set_password
  • +
  • Sending an invite mail when registering a partner from odoo interface for the partner to enter a password: _send_invite, _set_password
  • +
+
+

For a routing implementation, see the fastapi_auth_partner module.

+

Table of contents

+ +
+

Usage

+

This module isn’t meant to be used standalone but you can still see the directories and authenticable partners in:

+

Settings > Technical > Partner Authentication > Partner

+

and

+

Settings > Technical > Partner Authentication > Directory

+
+
+

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/auth_partner/tests/__init__.py b/auth_partner/tests/__init__.py new file mode 100644 index 000000000..ee9a639f6 --- /dev/null +++ b/auth_partner/tests/__init__.py @@ -0,0 +1 @@ +from . import test_auth_partner diff --git a/auth_partner/tests/common.py b/auth_partner/tests/common.py new file mode 100644 index 000000000..6c6f777f6 --- /dev/null +++ b/auth_partner/tests/common.py @@ -0,0 +1,60 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from contextlib import contextmanager +from typing import Any + +from odoo.tests.common import TransactionCase + +from odoo.addons.mail.tests.common import MockEmail + + +class CommonTestAuthPartner(TransactionCase, MockEmail): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, queue_job__no_delay=True)) + + cls.partner = cls.env.ref("auth_partner.res_partner_auth_demo") + cls.other_partner = cls.partner.copy( + {"name": "Other Partner", "email": "other-partner-auth@example.org"} + ) + cls.auth_partner = cls.partner.auth_partner_ids + + cls.directory = cls.env.ref("auth_partner.demo_directory") + cls.directory.impersonating_user_ids = cls.env.ref("base.user_admin") + + cls.other_auth_partner = cls.env["auth.partner"].create( + { + "login": cls.other_partner.email, + "password": "Super-secret3", + "directory_id": cls.directory.id, + "partner_id": cls.other_partner.id, + } + ) + cls.other_directory = cls.directory.copy({"name": "Other Directory"}) + + @contextmanager + def new_mails(self): + mailmail = self.env["mail.mail"] + + class MailsProxy(mailmail.__class__): + __slots__ = ["_prev", "__weakref__"] + + def __init__(self): + object.__setattr__(self, "_prev", mailmail.search([])) + + def __getattribute__(self, name: str) -> Any: + mails = mailmail.search([]) - object.__getattribute__(self, "_prev") + return object.__getattribute__(mails, name) + + new_mails = MailsProxy() + with self.mock_mail_gateway(): + yield new_mails + + @contextmanager + def assert_no_new_mail(self): + with self.new_mails() as new_mails: + yield + self.assertFalse(new_mails) diff --git a/auth_partner/tests/test_auth_partner.py b/auth_partner/tests/test_auth_partner.py new file mode 100644 index 000000000..9e8bdf782 --- /dev/null +++ b/auth_partner/tests/test_auth_partner.py @@ -0,0 +1,337 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from contextlib import contextmanager +from datetime import datetime, timedelta + +from freezegun import freeze_time + +from odoo.exceptions import AccessDenied, UserError + +from .common import CommonTestAuthPartner + + +class TestAuthPartner(CommonTestAuthPartner): + @contextmanager + def assert_no_new_mail(self): + with self.new_mails() as new_mails: + yield + self.assertFalse(new_mails) + + def test_default_secret_key(self): + self.assertGreaterEqual(len(self.directory.secret_key), 64) + + def test_login_ok(self): + with self.assert_no_new_mail(): + auth_partner = self.env["auth.partner"]._login( + self.directory, + login="partner-auth@example.org", + password="Super-secret$1", + ) + self.assertTrue(auth_partner) + + def test_login_wrong_password(self): + with self.assertRaisesRegex(AccessDenied, "Invalid Login or Password"): + self.env["auth.partner"]._login( + self.directory, login="partner-auth@example.org", password="wrong" + ) + + def test_login_mail_not_verified(self): + self.directory.force_verified_email = True + with self.assertRaisesRegex(AccessDenied, "Email address not validated"): + self.env["auth.partner"]._login( + self.directory, + login="partner-auth@example.org", + password="Super-secret$1", + ) + + def test_login_wrong_login(self): + with self.assertRaisesRegex(AccessDenied, "Invalid Login or Password"): + self.env["auth.partner"]._login( + self.directory, + login="partner-auth@example.com", + password="Super-secret$1", + ) + + def test_login_wrong_directory(self): + with self.assertRaisesRegex(AccessDenied, "Invalid Login or Password"): + self.env["auth.partner"]._login( + self.other_directory, + login="partner-auth@example.com", + password="Super-secret$1", + ) + + def test_signup(self): + with self.new_mails() as new_mails: + new_auth_partner = self.env["auth.partner"]._signup( + self.directory, + name="New Partner", + login="new-partner-auth@example.org", + password="NewSecret", + ) + self.assertTrue(new_auth_partner) + # Ensure we can't read the password + self.assertNotEqual(new_auth_partner.password, "NewSecret") + + self.assertEqual(len(new_mails), 1) + self.assertEqual(new_mails.subject, "Welcome") + self.assertIn("Welcome to the site, please", new_mails.body) + + auth_partner = self.env["auth.partner"]._login( + self.directory, login="new-partner-auth@example.org", password="NewSecret" + ) + self.assertTrue(auth_partner) + self.assertEqual(auth_partner, new_auth_partner) + + def test_signup_wrong_directory(self): + new_auth_partner = self.env["auth.partner"]._signup( + self.other_directory, + name="New Partner", + login="new-partner-auth@example.org", + password="NewSecret", + ) + self.assertTrue(new_auth_partner) + + with self.assertRaisesRegex(AccessDenied, "Invalid Login or Password"): + self.env["auth.partner"]._login( + self.directory, + login="new-partner-auth@example.org", + password="NewSecret", + ) + + def test_signup_same_login_other_directory(self): + new_auth_partner = self.env["auth.partner"]._signup( + self.directory, + name="New Partner", + login="new-partner-auth@example.org", + password="NewSecret", + ) + self.assertTrue(new_auth_partner) + new_auth_partner_2 = self.env["auth.partner"]._signup( + self.other_directory, + name="New Partner", + login="new-partner-auth@example.org", + password="NewSecret2", + ) + self.assertTrue(new_auth_partner_2) + self.assertNotEqual(new_auth_partner, new_auth_partner_2) + + with self.assertRaisesRegex(AccessDenied, "Invalid Login or Password"): + self.env["auth.partner"]._login( + self.directory, + login="new-partner-auth@example.org", + password="NewSecret2", + ) + + with self.assertRaisesRegex(AccessDenied, "Invalid Login or Password"): + self.env["auth.partner"]._login( + self.other_directory, + login="new-partner-auth@example.org", + password="NewSecret", + ) + + def test_validate_email_ok(self): + self.assertFalse(self.auth_partner.mail_verified) + token = self.auth_partner._generate_validate_email_token() + self.auth_partner._validate_email(self.directory, token) + self.assertTrue(self.auth_partner.mail_verified) + + def test_validate_email_required_login(self): + self.directory.force_verified_email = True + token = self.auth_partner._generate_validate_email_token() + self.auth_partner._validate_email(self.directory, token) + with self.assert_no_new_mail(): + auth_partner = self.env["auth.partner"]._login( + self.directory, + login="partner-auth@example.org", + password="Super-secret$1", + ) + self.assertTrue(auth_partner) + + def test_validate_email_wrong_token(self): + self.assertFalse(self.auth_partner.mail_verified) + with self.assertRaisesRegex(UserError, "Invalid Token"): + self.auth_partner._validate_email(self.directory, "wrong") + self.assertFalse(self.auth_partner.mail_verified) + + def test_validate_email_token(self): + with self.new_mails() as new_mails: + new_auth_partner = self.env["auth.partner"]._signup( + self.directory, + name="New Partner", + login="new-partner-auth@example.org", + password="NewSecret", + ) + self.assertFalse(new_auth_partner.mail_verified) + token = new_mails.body.split("token=")[1].split('">')[0] + new_auth_partner._validate_email(self.directory, token) + self.assertTrue(new_auth_partner.mail_verified) + + def test_impersonate_ok(self): + action = self.auth_partner.with_user( + self.env.ref("base.user_admin") + ).impersonate() + token = action["url"].split("/")[-1] + + auth_partner = self.env["auth.partner"]._impersonating(self.directory, token) + self.assertEqual(auth_partner, self.auth_partner) + + def test_impersonate_once(self): + action = self.auth_partner.with_user( + self.env.ref("base.user_admin") + ).impersonate() + token = action["url"].split("/")[-1] + + self.env["auth.partner"]._impersonating(self.directory, token) + with self.assertRaisesRegex(UserError, "Invalid Token"): + self.env["auth.partner"]._impersonating(self.directory, token) + + def test_impersonate_wrong_directory(self): + action = self.auth_partner.with_user( + self.env.ref("base.user_admin") + ).impersonate() + token = action["url"].split("/")[-1] + + with self.assertRaisesRegex(UserError, "Invalid Token"): + self.env["auth.partner"]._impersonating(self.other_directory, token) + + def test_impersonate_wrong_user(self): + with self.assertRaisesRegex(AccessDenied, "not allowed to impersonate"): + self.auth_partner.with_user(self.env.ref("base.default_user")).impersonate() + + def test_impersonate_not_expired_token(self): + self.directory.impersonating_token_duration = 100 + action = self.auth_partner.with_user( + self.env.ref("base.user_admin") + ).impersonate() + token = action["url"].split("/")[-1] + + with freeze_time(datetime.now() + timedelta(hours=1)): + self.env["auth.partner"]._impersonating(self.directory, token) + + def test_impersonate_expired_token(self): + self.directory.impersonating_token_duration = 100 + action = self.auth_partner.with_user( + self.env.ref("base.user_admin") + ).impersonate() + token = action["url"].split("/")[-1] + + with freeze_time(datetime.now() + timedelta(hours=2)), self.assertRaisesRegex( + UserError, "Invalid Token" + ): + self.env["auth.partner"]._impersonating(self.directory, token) + + def test_set_password_ok(self): + self.auth_partner._set_password( + self.directory, + self.auth_partner._generate_set_password_token(), + "ResetSecret", + ) + auth_partner = self.env["auth.partner"]._login( + self.directory, login="partner-auth@example.org", password="ResetSecret" + ) + self.assertTrue(auth_partner) + + def test_set_password_wrong_token(self): + with self.assertRaisesRegex(UserError, "Invalid Token"): + self.auth_partner._set_password(self.directory, "wrong", "ResetSecret") + + def test_set_password_once(self): + token = self.auth_partner._generate_set_password_token() + self.auth_partner._set_password(self.directory, token, "ResetSecret") + with self.assertRaisesRegex(UserError, "Invalid Token"): + self.auth_partner._set_password(self.directory, token, "ResetSecret") + + def test_set_password_not_expired_token(self): + self.directory.set_password_token_duration = 100 + token = self.auth_partner._generate_set_password_token() + + with freeze_time(datetime.now() + timedelta(hours=1)): + self.auth_partner._set_password(self.directory, token, "ResetSecret") + + auth_partner = self.env["auth.partner"]._login( + self.directory, login="partner-auth@example.org", password="ResetSecret" + ) + self.assertTrue(auth_partner) + + def test_set_password_expired_token(self): + self.directory.set_password_token_duration = 100 + token = self.auth_partner._generate_set_password_token() + + with freeze_time(datetime.now() + timedelta(hours=2)), self.assertRaisesRegex( + UserError, "Invalid Token" + ): + self.auth_partner._set_password(self.directory, token, "ResetSecret") + + def test_reset_password_ok(self): + with self.new_mails() as new_mails: + self.auth_partner._request_reset_password() + self.assertEqual(len(new_mails), 1) + self.assertEqual(new_mails.subject, "Reset Password") + self.assertIn( + "Click on the following link to reset your password", new_mails.body + ) + + token = new_mails.body.split("token=")[1].split('">')[0] + self.auth_partner._set_password(self.directory, token, "ResetSecret") + auth_partner = self.env["auth.partner"]._login( + self.directory, login="partner-auth@example.org", password="ResetSecret" + ) + self.assertTrue(auth_partner) + + def test_reset_password_wrong_partner(self): + with self.new_mails() as new_mails: + self.auth_partner._request_reset_password() + self.assertEqual(len(new_mails), 1) + self.assertEqual(new_mails.subject, "Reset Password") + self.assertIn( + "Click on the following link to reset your password", new_mails.body + ) + + token = new_mails.body.split("token=")[1].split('">')[0] + # This should probably raise instead of reseting the auth_partner password + self.other_auth_partner._set_password(self.directory, token, "ResetSecret") + with self.assertRaisesRegex(AccessDenied, "Invalid Login or Password"): + self.env["auth.partner"]._login( + self.directory, + login="other-partner-auth@example.org", + password="ResetSecret", + ) + + def test_reset_password_once(self): + with self.new_mails() as new_mails: + self.auth_partner._request_reset_password() + self.assertEqual(len(new_mails), 1) + self.assertEqual(new_mails.subject, "Reset Password") + token = new_mails.body.split("token=")[1].split('">')[0] + self.auth_partner._set_password(self.directory, token, "ResetSecret") + + with self.assertRaisesRegex(UserError, "Invalid Token"): + self.auth_partner._set_password(self.directory, token, "ResetSecret2") + + def test_send_invite_set_password_ok(self): + with self.new_mails() as new_mails: + self.auth_partner._send_invite() + self.assertEqual(len(new_mails), 1) + self.assertEqual(new_mails.subject, "Welcome") + self.assertIn("your account have been created", new_mails.body) + token = new_mails.body.split("token=")[1].split('">')[0] + + self.auth_partner._set_password(self.directory, token, "ResetSecret") + auth_partner = self.env["auth.partner"]._login( + self.directory, login="partner-auth@example.org", password="ResetSecret" + ) + self.assertTrue(auth_partner) + + def test_send_invite_set_password_once(self): + with self.new_mails() as new_mails: + self.auth_partner._send_invite() + self.assertEqual(len(new_mails), 1) + self.assertEqual(new_mails.subject, "Welcome") + + token = new_mails.body.split("token=")[1].split('">')[0] + self.auth_partner._set_password(self.directory, token, "ResetSecret") + + with self.assertRaisesRegex(UserError, "Invalid Token"): + self.auth_partner._set_password(self.directory, token, "ResetSecret2") diff --git a/auth_partner/views/auth_directory_view.xml b/auth_partner/views/auth_directory_view.xml new file mode 100644 index 000000000..1d8c58cfd --- /dev/null +++ b/auth_partner/views/auth_directory_view.xml @@ -0,0 +1,92 @@ + + + + auth.directory + + + + + + + + + auth.directory + +
+
+
+ +
+ +
+
+

+ +

+
+ + + + + + + + + + + + + + +
+
+
+
+ + + auth.directory + + + + + + + + + Directory + ir.actions.act_window + auth.directory + tree,form + + [] + {} + + + +
diff --git a/auth_partner/views/auth_partner_view.xml b/auth_partner/views/auth_partner_view.xml new file mode 100644 index 000000000..b631349e6 --- /dev/null +++ b/auth_partner/views/auth_partner_view.xml @@ -0,0 +1,91 @@ + + + + auth.partner + + + + + + + + + + + + + + + auth.partner + +
+
+
+ +
+

+ + +

+
+ + + + + + + +
+
+
+
+ + + auth.partner + + + + + + + + + + + + + Partner + ir.actions.act_window + auth.partner + tree,form + + [] + {} + + + + +
diff --git a/auth_partner/views/res_partner_view.xml b/auth_partner/views/res_partner_view.xml new file mode 100644 index 000000000..9978e92b2 --- /dev/null +++ b/auth_partner/views/res_partner_view.xml @@ -0,0 +1,22 @@ + + + + res.partner + + +
+ +
+
+
+
diff --git a/auth_partner/wizards/__init__.py b/auth_partner/wizards/__init__.py new file mode 100644 index 000000000..1857bf3d3 --- /dev/null +++ b/auth_partner/wizards/__init__.py @@ -0,0 +1 @@ +from . import wizard_auth_partner_reset_password diff --git a/auth_partner/wizards/wizard_auth_partner_reset_password.py b/auth_partner/wizards/wizard_auth_partner_reset_password.py new file mode 100644 index 000000000..e69610877 --- /dev/null +++ b/auth_partner/wizards/wizard_auth_partner_reset_password.py @@ -0,0 +1,59 @@ +# Copyright 2024 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# @author Florian Mounier +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import datetime, timedelta + +from odoo import api, fields, models + + +class WizardAuthPartnerResetPassword(models.TransientModel): + _name = "wizard.auth.partner.reset.password" + _description = "Wizard Partner Auth Reset Password" + + delay = fields.Selection( + [ + ("manually", "Manually"), + ("6-hours", "6 Hours"), + ("2-days", "2-days"), + ("7-days", "7 Days"), + ("14-days", "14 Days"), + ], + default="6-hours", + required=True, + ) + template_id = fields.Many2one( + "mail.template", + "Mail Template", + required=True, + domain=[("model_id", "=", "auth.partner")], + ) + date_validity = fields.Datetime( + compute="_compute_date_validity", store=True, readonly=False + ) + + @api.depends("delay") + def _compute_date_validity(self): + for record in self: + if record.delay != "manually": + duration, key = record.delay.split("-") + record.date_validity = datetime.now() + timedelta( + **{key: float(duration)} + ) + + def action_reset_password(self): + expiration_delta = None + if self.delay != "manually": + duration, key = self.delay.split("-") + expiration_delta = timedelta(**{key: float(duration)}) + + for auth_partner in self.env["auth.partner"].browse( + self._context["active_ids"] + ): + auth_partner.directory_id._send_mail_background( + self.template_id, + auth_partner, + callback_job=auth_partner.delayable()._on_reset_password_sent(), + token=auth_partner._generate_set_password_token(expiration_delta), + ) diff --git a/auth_partner/wizards/wizard_auth_partner_reset_password_view.xml b/auth_partner/wizards/wizard_auth_partner_reset_password_view.xml new file mode 100644 index 000000000..f35f4ec29 --- /dev/null +++ b/auth_partner/wizards/wizard_auth_partner_reset_password_view.xml @@ -0,0 +1,42 @@ + + + + + wizard.auth.partner.reset.password + +
+ An email will be send with a token to each customer, you can specify the date until the link is valid + + + + + +
+
+ +
+
+
+ + + Send Reset Password Instruction + wizard.auth.partner.reset.password + ir.actions.act_window + form + new + + + + +
diff --git a/fastapi_auth_partner/README.rst b/fastapi_auth_partner/README.rst new file mode 100644 index 000000000..475881193 --- /dev/null +++ b/fastapi_auth_partner/README.rst @@ -0,0 +1,136 @@ +==================== +Fastapi Auth Partner +==================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:57e60d4a203cd5e613e07fe0c1f6a207bd6b77f537ed5cbbbe72a1cc2184f0de + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/16.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-16-0/rest-framework-16-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=16.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-block:: 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 == "portal": + 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..e5b486e4c --- /dev/null +++ b/fastapi_auth_partner/__manifest__.py @@ -0,0 +1,32 @@ +# 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": "16.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.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..bf017151c --- /dev/null +++ b/fastapi_auth_partner/demo/fastapi_endpoint_demo.xml @@ -0,0 +1,26 @@ + + + + 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..c8d1b50f6 --- /dev/null +++ b/fastapi_auth_partner/dependencies.py @@ -0,0 +1,74 @@ +# 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 +import sys +from typing import Any, Dict, Union + +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 + +if sys.version_info >= (3, 9): + from typing import Annotated +else: + from typing_extensions import Annotated + +_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[Union[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, 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/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..490fb43fc --- /dev/null +++ b/fastapi_auth_partner/models/auth_partner.py @@ -0,0 +1,82 @@ +# 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(_("Only admin can impersonate locally")) + + if not hasattr(request, "future_response"): + raise UserError( + _("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.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 _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..cab4f80c5 --- /dev/null +++ b/fastapi_auth_partner/models/fastapi_endpoint.py @@ -0,0 +1,55 @@ +# 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 typing import List + +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/readme/CONTRIBUTORS.rst b/fastapi_auth_partner/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..bae3cc9a1 --- /dev/null +++ b/fastapi_auth_partner/readme/CONTRIBUTORS.rst @@ -0,0 +1,4 @@ +* `Akretion `_: + + * Sébastien Beau + * Florian Mounier diff --git a/fastapi_auth_partner/readme/DESCRIPTION.rst b/fastapi_auth_partner/readme/DESCRIPTION.rst new file mode 100644 index 000000000..e2fa8ca8d --- /dev/null +++ b/fastapi_auth_partner/readme/DESCRIPTION.rst @@ -0,0 +1,2 @@ +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.rst b/fastapi_auth_partner/readme/USAGE.rst new file mode 100644 index 000000000..5cc0ba313 --- /dev/null +++ b/fastapi_auth_partner/readme/USAGE.rst @@ -0,0 +1,52 @@ +First you have to add the auth router to your FastAPI endpoint and the authentication dependency to your app dependencies: + +.. code-block:: 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 == "portal": + 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..25d9ef749 --- /dev/null +++ b/fastapi_auth_partner/routers/__init__.py @@ -0,0 +1 @@ +from . import auth diff --git a/fastapi_auth_partner/routers/auth.py b/fastapi_auth_partner/routers/auth.py new file mode 100644 index 000000000..10b7d98ea --- /dev/null +++ b/fastapi_auth_partner/routers/auth.py @@ -0,0 +1,248 @@ +# 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). + +import sys + +if sys.version_info >= (3, 9): + from typing import Annotated +else: + from typing_extensions import Annotated + +from datetime import datetime, timedelta, timezone + +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, 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)], + response: Response, +) -> AuthPartnerResponse: + helper = env["fastapi.auth.service"].new({"endpoint_id": endpoint}) + auth_partner = helper._signup(data) + helper._set_auth_cookie(auth_partner, 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)], + response: Response, +) -> AuthPartnerResponse: + helper = env["fastapi.auth.service"].new({"endpoint_id": endpoint}) + auth_partner = helper._login(data) + helper._set_auth_cookie(auth_partner, 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)], + response: Response, +) -> AuthPartnerResponse: + helper = env["fastapi.auth.service"].new({"endpoint_id": endpoint}) + auth_partner = helper._set_password(data) + helper._set_auth_cookie(auth_partner, 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)], +) -> 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, 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): + rec = super().new(vals, **kwargs) + # Can't have computed / related field in AbstractModel + rec.directory_id = rec.endpoint_id.directory_id + # Auto add endpoint context for mail context + return rec.with_context(_fastapi_endpoint_id=vals["endpoint_id"].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.dict()) + ) + return auth_partner + + def _login(self, data): + return self.env["auth.partner"].sudo()._login(self.directory_id, **data.dict()) + + 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(_("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, 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/res_group.xml b/fastapi_auth_partner/security/res_group.xml new file mode 100644 index 000000000..c7f87fb9e --- /dev/null +++ b/fastapi_auth_partner/security/res_group.xml @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/fastapi_auth_partner/static/description/index.html b/fastapi_auth_partner/static/description/index.html new file mode 100644 index 000000000..cc3a30692 --- /dev/null +++ b/fastapi_auth_partner/static/description/index.html @@ -0,0 +1,477 @@ + + + + + +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 == "portal":
+            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..57013f9d8 --- /dev/null +++ b/fastapi_auth_partner/tests/test_auth.py @@ -0,0 +1,241 @@ +# 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 + + +@tagged("post_install", "-at_install") +class TestFastapiAuthPartner(FastAPITransactionCase, CommonTestAuthPartner): + @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 + + 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..0539cef17 --- /dev/null +++ b/fastapi_auth_partner/tests/test_fastapi_auth_partner_demo.py @@ -0,0 +1,93 @@ +# 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 +import sys + +from odoo import tests + +from odoo.addons.base.models.res_partner import Partner +from odoo.addons.fastapi_auth_partner.dependencies import AuthPartner + +from fastapi import Depends, status + +if sys.version_info >= (3, 9): + from typing import Annotated +else: + from typing_extensions import Annotated + +from odoo.addons.fastapi_auth_partner.routers.auth import auth_router +from odoo.addons.fastapi_auth_partner.schemas import AuthPartnerResponse + + +@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}, + ) + + 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..e795dcaed --- /dev/null +++ b/fastapi_auth_partner/views/fastapi_endpoint_view.xml @@ -0,0 +1,23 @@ + + + + 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..e9640c5df --- /dev/null +++ b/fastapi_auth_partner/wizards/wizard_auth_partner_impersonate_view.xml @@ -0,0 +1,43 @@ + + + + + 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..80ba22000 --- /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) + super(WizardAuthPartnerResetPassword, self).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..1495e72d0 --- /dev/null +++ b/fastapi_auth_partner/wizards/wizard_auth_partner_reset_password_view.xml @@ -0,0 +1,17 @@ + + + + + wizard.auth.partner.reset.password + + + + + + + + + diff --git a/requirements.txt b/requirements.txt index 7e0b84839..1da0c3cc5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,12 +10,14 @@ extendable>=0.0.4 fastapi>=0.110.0 graphene graphql_server +itsdangerous jsondiff marshmallow marshmallow-objects>=2.0.0 parse-accept-language pydantic pydantic>=2.0.0 +pyjwt pyquerystring python-multipart typing-extensions diff --git a/setup/auth_partner/odoo/addons/auth_partner b/setup/auth_partner/odoo/addons/auth_partner new file mode 120000 index 000000000..736694d4a --- /dev/null +++ b/setup/auth_partner/odoo/addons/auth_partner @@ -0,0 +1 @@ +../../../../auth_partner \ No newline at end of file diff --git a/setup/auth_partner/setup.py b/setup/auth_partner/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/auth_partner/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/fastapi_auth_partner/odoo/addons/fastapi_auth_partner b/setup/fastapi_auth_partner/odoo/addons/fastapi_auth_partner new file mode 120000 index 000000000..481ffc2a2 --- /dev/null +++ b/setup/fastapi_auth_partner/odoo/addons/fastapi_auth_partner @@ -0,0 +1 @@ +../../../../fastapi_auth_partner \ No newline at end of file diff --git a/setup/fastapi_auth_partner/setup.py b/setup/fastapi_auth_partner/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/fastapi_auth_partner/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)