diff --git a/fastapi_captcha/README.rst b/fastapi_captcha/README.rst new file mode 100644 index 000000000..be3278219 --- /dev/null +++ b/fastapi_captcha/README.rst @@ -0,0 +1,105 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +=============== +Fastapi Captcha +=============== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:9f521219b54944652afeaf18f64a122f01e1b6646171bc5212e48cb40d0906a0 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-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/19.0/fastapi_captcha + :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-19-0/rest-framework-19-0-fastapi_captcha + :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=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module provides a simple way to protect several fastapi endpoints +routes with a captcha. + +It currently supports the following captcha providers: + +- `Google reCAPTCHA `__ +- `hCaptcha `__ +- `Altcha `__ + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Check the ``Use Captcha`` checkbox in your FastAPI endpoint to enable +captcha validation, then enter your captcha provider, secret key and an +array of route url regex. + +Every matching route will now require a valid captcha token in the +X-Captcha-Token header. + +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 +------------ + +- Florian Mounier florian.mounier@akretion.com + +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. + +.. |maintainer-paradoxxxzero| image:: https://github.com/paradoxxxzero.png?size=40px + :target: https://github.com/paradoxxxzero + :alt: paradoxxxzero + +Current `maintainer `__: + +|maintainer-paradoxxxzero| + +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_captcha/__init__.py b/fastapi_captcha/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/fastapi_captcha/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/fastapi_captcha/__manifest__.py b/fastapi_captcha/__manifest__.py new file mode 100644 index 000000000..550bf6137 --- /dev/null +++ b/fastapi_captcha/__manifest__.py @@ -0,0 +1,19 @@ +# Copyright 2025 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Fastapi Captcha", + "version": "19.0.1.0.0", + "author": "Akretion, Odoo Community Association (OCA)", + "summary": "Add a captcha to your FastAPI routes", + "category": "Tools", + "depends": ["fastapi"], + "website": "https://github.com/OCA/rest-framework", + "data": [ + "views/fastapi_endpoint_views.xml", + ], + "maintainers": ["paradoxxxzero"], + "installable": True, + "license": "AGPL-3", +} diff --git a/fastapi_captcha/captcha_middleware.py b/fastapi_captcha/captcha_middleware.py new file mode 100644 index 000000000..ed6c4ccbc --- /dev/null +++ b/fastapi_captcha/captcha_middleware.py @@ -0,0 +1,42 @@ +# Copyright 2025 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from starlette.middleware.base import BaseHTTPMiddleware + +from odoo.exceptions import AccessError + +from odoo.addons.fastapi.context import odoo_env_ctx + + +class CaptchaMiddleware(BaseHTTPMiddleware): + def __init__(self, app, endpoint_id, root_path, routes_regex=None): + super().__init__(app) + self.endpoint_id = endpoint_id + self.root_path = root_path + self.routes_regex = routes_regex + + async def dispatch(self, request, call_next): + url = request.url.path.replace(self.root_path, "", 1) + if self.routes_regex and not any( + rex.fullmatch(url) for rex in self.routes_regex + ): + return await call_next(request) + + env = odoo_env_ctx.get() + endpoint = env["fastapi.endpoint"].sudo().browse(self.endpoint_id) + token = request.headers.get("X-Captcha-Token") + if not token: + raise AccessError( + env._("Captcha token not found in headers"), + ) + try: + endpoint.validate_captcha(token) + except AccessError as e: + raise e + except OSError as e: + raise AccessError( + env._("Captcha validation failed: %s", str(e)), + ) from e + response = await call_next(request) + return response diff --git a/fastapi_captcha/i18n/fastapi_captcha.pot b/fastapi_captcha/i18n/fastapi_captcha.pot new file mode 100644 index 000000000..0cb09fb7d --- /dev/null +++ b/fastapi_captcha/i18n/fastapi_captcha.pot @@ -0,0 +1,153 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fastapi_captcha +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: fastapi_captcha +#: model:ir.model.fields.selection,name:fastapi_captcha.selection__fastapi_endpoint__captcha_type__altcha +msgid "Altcha" +msgstr "" + +#. module: fastapi_captcha +#. odoo-python +#: code:addons/fastapi_captcha/models/fastapi_endpoint.py:0 +msgid "Altcha (%(url)s) validation failed: %(error)s" +msgstr "" + +#. module: fastapi_captcha +#: model:ir.model.fields,field_description:fastapi_captcha.field_fastapi_endpoint__captcha_custom_verify_url +msgid "Captcha Custom Verify Url" +msgstr "" + +#. module: fastapi_captcha +#: model:ir.model.fields,field_description:fastapi_captcha.field_fastapi_endpoint__captcha_minimum_score +msgid "Captcha Minimum Score" +msgstr "" + +#. module: fastapi_captcha +#: model:ir.model.fields,field_description:fastapi_captcha.field_fastapi_endpoint__captcha_routes_regex +msgid "Captcha Routes Regex" +msgstr "" + +#. module: fastapi_captcha +#: model:ir.model.fields,field_description:fastapi_captcha.field_fastapi_endpoint__captcha_secret_key +msgid "Captcha Secret Key" +msgstr "" + +#. module: fastapi_captcha +#: model:ir.model.fields,field_description:fastapi_captcha.field_fastapi_endpoint__captcha_type +msgid "Captcha Type" +msgstr "" + +#. module: fastapi_captcha +#. odoo-python +#: code:addons/fastapi_captcha/captcha_middleware.py:0 +msgid "Captcha token not found in headers" +msgstr "" + +#. module: fastapi_captcha +#. odoo-python +#: code:addons/fastapi_captcha/captcha_middleware.py:0 +msgid "Captcha validation failed: %s" +msgstr "" + +#. module: fastapi_captcha +#: model:ir.model.fields,help:fastapi_captcha.field_fastapi_endpoint__captcha_custom_verify_url +msgid "Custom URL to use for the captcha verification" +msgstr "" + +#. module: fastapi_captcha +#: model:ir.model,name:fastapi_captcha.model_fastapi_endpoint +msgid "FastAPI Endpoint" +msgstr "" + +#. module: fastapi_captcha +#: model:ir.model.fields.selection,name:fastapi_captcha.selection__fastapi_endpoint__captcha_type__hcaptcha +msgid "Hcaptcha" +msgstr "" + +#. module: fastapi_captcha +#. odoo-python +#: code:addons/fastapi_captcha/models/fastapi_endpoint.py:0 +msgid "Hcaptcha validation failed: %s" +msgstr "" + +#. module: fastapi_captcha +#. odoo-python +#: code:addons/fastapi_captcha/models/fastapi_endpoint.py:0 +msgid "" +"Hcaptcha validation failed: score %(score)s < %(min_score)s (%(reason)s)" +msgstr "" + +#. module: fastapi_captcha +#: model:ir.model.fields,help:fastapi_captcha.field_fastapi_endpoint__use_captcha +msgid "If checked, this endpoint will be protected by a captcha" +msgstr "" + +#. module: fastapi_captcha +#. odoo-python +#: code:addons/fastapi_captcha/models/fastapi_endpoint.py:0 +msgid "Invalid regex for captcha routes: %(regex)s (error: %(error)s)" +msgstr "" + +#. module: fastapi_captcha +#: model:ir.model.fields,help:fastapi_captcha.field_fastapi_endpoint__captcha_minimum_score +msgid "" +"Minimum score to accept the captcha if a score is provided by the captcha " +"service." +msgstr "" + +#. module: fastapi_captcha +#. odoo-python +#: code:addons/fastapi_captcha/models/fastapi_endpoint.py:0 +msgid "No secret key found for this endpoint" +msgstr "" + +#. module: fastapi_captcha +#: model:ir.model.fields.selection,name:fastapi_captcha.selection__fastapi_endpoint__captcha_type__recaptcha +msgid "Recaptcha" +msgstr "" + +#. module: fastapi_captcha +#. odoo-python +#: code:addons/fastapi_captcha/models/fastapi_endpoint.py:0 +msgid "Recaptcha validation failed: %s" +msgstr "" + +#. module: fastapi_captcha +#. odoo-python +#: code:addons/fastapi_captcha/models/fastapi_endpoint.py:0 +msgid "Recaptcha validation failed: score %(score)s < %(min_score)s" +msgstr "" + +#. module: fastapi_captcha +#: model:ir.model.fields,help:fastapi_captcha.field_fastapi_endpoint__captcha_routes_regex +msgid "" +"Regexes to match against routes url that should be protected by this " +"captcha, comma separated. If empty, all routes will be protected" +msgstr "" + +#. module: fastapi_captcha +#: model:ir.model.fields,help:fastapi_captcha.field_fastapi_endpoint__captcha_secret_key +msgid "Secret key to use for the captcha validation" +msgstr "" + +#. module: fastapi_captcha +#: model:ir.model.fields,help:fastapi_captcha.field_fastapi_endpoint__captcha_type +msgid "Type of captcha to use for this endpoint" +msgstr "" + +#. module: fastapi_captcha +#: model:ir.model.fields,field_description:fastapi_captcha.field_fastapi_endpoint__use_captcha +msgid "Use Captcha" +msgstr "" diff --git a/fastapi_captcha/i18n/fr.po b/fastapi_captcha/i18n/fr.po new file mode 100644 index 000000000..dea1d68ae --- /dev/null +++ b/fastapi_captcha/i18n/fr.po @@ -0,0 +1,165 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fastapi_captcha +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2026-07-16 13:42+0000\n" +"Last-Translator: Thibault REY \n" +"Language-Team: none\n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n > 1;\n" +"X-Generator: Weblate 5.15.2\n" + +#. module: fastapi_captcha +#: model:ir.model.fields.selection,name:fastapi_captcha.selection__fastapi_endpoint__captcha_type__altcha +msgid "Altcha" +msgstr "Altcha" + +#. module: fastapi_captcha +#. odoo-python +#: code:addons/fastapi_captcha/models/fastapi_endpoint.py:0 +msgid "Altcha (%(url)s) validation failed: %(error)s" +msgstr "??chec de la validation d'Altcha (%(url)s) : %(error)s" + +#. module: fastapi_captcha +#: model:ir.model.fields,field_description:fastapi_captcha.field_fastapi_endpoint__captcha_custom_verify_url +msgid "Captcha Custom Verify Url" +msgstr "URL de v??rification personnalis??e du Captcha" + +#. module: fastapi_captcha +#: model:ir.model.fields,field_description:fastapi_captcha.field_fastapi_endpoint__captcha_minimum_score +msgid "Captcha Minimum Score" +msgstr "Score minimum du Captcha" + +#. module: fastapi_captcha +#: model:ir.model.fields,field_description:fastapi_captcha.field_fastapi_endpoint__captcha_routes_regex +msgid "Captcha Routes Regex" +msgstr "Expressions r??guli??res (regex) des routes prot??g??es par le Captcha" + +#. module: fastapi_captcha +#: model:ir.model.fields,field_description:fastapi_captcha.field_fastapi_endpoint__captcha_secret_key +msgid "Captcha Secret Key" +msgstr "Cl?? secr??te du Captcha" + +#. module: fastapi_captcha +#: model:ir.model.fields,field_description:fastapi_captcha.field_fastapi_endpoint__captcha_type +msgid "Captcha Type" +msgstr "Type de Captcha" + +#. module: fastapi_captcha +#. odoo-python +#: code:addons/fastapi_captcha/captcha_middleware.py:0 +msgid "Captcha token not found in headers" +msgstr "Le jeton Captcha n'a pas ??t?? trouv?? dans les en-t??tes" + +#. module: fastapi_captcha +#. odoo-python +#: code:addons/fastapi_captcha/captcha_middleware.py:0 +msgid "Captcha validation failed: %s" +msgstr "??chec de la validation du Captcha : %s" + +#. module: fastapi_captcha +#: model:ir.model.fields,help:fastapi_captcha.field_fastapi_endpoint__captcha_custom_verify_url +msgid "Custom URL to use for the captcha verification" +msgstr "URL personnalis??e ?? utiliser pour la v??rification par le Captcha" + +#. module: fastapi_captcha +#: model:ir.model,name:fastapi_captcha.model_fastapi_endpoint +msgid "FastAPI Endpoint" +msgstr "Endpoint FastAPI" + +#. module: fastapi_captcha +#: model:ir.model.fields.selection,name:fastapi_captcha.selection__fastapi_endpoint__captcha_type__hcaptcha +msgid "Hcaptcha" +msgstr "Hcaptcha" + +#. module: fastapi_captcha +#. odoo-python +#: code:addons/fastapi_captcha/models/fastapi_endpoint.py:0 +msgid "Hcaptcha validation failed: %s" +msgstr "??chec de la validation Hcaptcha : %s" + +#. module: fastapi_captcha +#. odoo-python +#: code:addons/fastapi_captcha/models/fastapi_endpoint.py:0 +msgid "" +"Hcaptcha validation failed: score %(score)s < %(min_score)s (%(reason)s)" +msgstr "" +"??chec de la validation Hcaptcha : score %(score)s < %(min_score)s (%(reason)" +"s)" + +#. module: fastapi_captcha +#: model:ir.model.fields,help:fastapi_captcha.field_fastapi_endpoint__use_captcha +msgid "If checked, this endpoint will be protected by a captcha" +msgstr "Si cette case est coch??e, ce Endpoint sera prot??g?? par un captcha" + +#. module: fastapi_captcha +#. odoo-python +#: code:addons/fastapi_captcha/models/fastapi_endpoint.py:0 +msgid "Invalid regex for captcha routes: %(regex)s (error: %(error)s)" +msgstr "" +"Expression r??guli??re (regex) non valide pour les routes captcha : %(regex)s " +"(erreur : %(error)s)" + +#. module: fastapi_captcha +#: model:ir.model.fields,help:fastapi_captcha.field_fastapi_endpoint__captcha_minimum_score +msgid "" +"Minimum score to accept the captcha if a score is provided by the captcha " +"service." +msgstr "" +"Score minimum requis pour valider le captcha si un score est fourni par le " +"service de captcha." + +#. module: fastapi_captcha +#. odoo-python +#: code:addons/fastapi_captcha/models/fastapi_endpoint.py:0 +msgid "No secret key found for this endpoint" +msgstr "Aucune cl?? secr??te n'a ??t?? trouv??e pour ce Endpoint" + +#. module: fastapi_captcha +#: model:ir.model.fields.selection,name:fastapi_captcha.selection__fastapi_endpoint__captcha_type__recaptcha +msgid "Recaptcha" +msgstr "Recaptcha" + +#. module: fastapi_captcha +#. odoo-python +#: code:addons/fastapi_captcha/models/fastapi_endpoint.py:0 +msgid "Recaptcha validation failed: %s" +msgstr "??chec de la validation Recaptcha : %s" + +#. module: fastapi_captcha +#. odoo-python +#: code:addons/fastapi_captcha/models/fastapi_endpoint.py:0 +msgid "Recaptcha validation failed: score %(score)s < %(min_score)s" +msgstr "??chec de la validation Recaptcha : score %(score)s < %(min_score)s" + +#. module: fastapi_captcha +#: model:ir.model.fields,help:fastapi_captcha.field_fastapi_endpoint__captcha_routes_regex +msgid "" +"Regexes to match against routes url that should be protected by this " +"captcha, comma separated. If empty, all routes will be protected" +msgstr "" +"Expressions r??guli??res (regex) permettant de faire correspondre les URL des " +"routes qui doivent ??tre prot??g??es par ce captcha, s??par??es par des virgules. " +"Si ce champ est vide, toutes les routes seront prot??g??es." + +#. module: fastapi_captcha +#: model:ir.model.fields,help:fastapi_captcha.field_fastapi_endpoint__captcha_secret_key +msgid "Secret key to use for the captcha validation" +msgstr "Cl?? secr??te ?? utiliser pour la validation du captcha" + +#. module: fastapi_captcha +#: model:ir.model.fields,help:fastapi_captcha.field_fastapi_endpoint__captcha_type +msgid "Type of captcha to use for this endpoint" +msgstr "Type de CAPTCHA ?? utiliser pour ce point de terminaison" + +#. module: fastapi_captcha +#: model:ir.model.fields,field_description:fastapi_captcha.field_fastapi_endpoint__use_captcha +msgid "Use Captcha" +msgstr "Utiliser le Captcha" diff --git a/fastapi_captcha/i18n/it.po b/fastapi_captcha/i18n/it.po new file mode 100644 index 000000000..0d0505de0 --- /dev/null +++ b/fastapi_captcha/i18n/it.po @@ -0,0 +1,154 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fastapi_captcha +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" + +#. module: fastapi_captcha +#: model:ir.model.fields.selection,name:fastapi_captcha.selection__fastapi_endpoint__captcha_type__altcha +msgid "Altcha" +msgstr "" + +#. module: fastapi_captcha +#. odoo-python +#: code:addons/fastapi_captcha/models/fastapi_endpoint.py:0 +msgid "Altcha (%(url)s) validation failed: %(error)s" +msgstr "" + +#. module: fastapi_captcha +#: model:ir.model.fields,field_description:fastapi_captcha.field_fastapi_endpoint__captcha_custom_verify_url +msgid "Captcha Custom Verify Url" +msgstr "" + +#. module: fastapi_captcha +#: model:ir.model.fields,field_description:fastapi_captcha.field_fastapi_endpoint__captcha_minimum_score +msgid "Captcha Minimum Score" +msgstr "" + +#. module: fastapi_captcha +#: model:ir.model.fields,field_description:fastapi_captcha.field_fastapi_endpoint__captcha_routes_regex +msgid "Captcha Routes Regex" +msgstr "" + +#. module: fastapi_captcha +#: model:ir.model.fields,field_description:fastapi_captcha.field_fastapi_endpoint__captcha_secret_key +msgid "Captcha Secret Key" +msgstr "" + +#. module: fastapi_captcha +#: model:ir.model.fields,field_description:fastapi_captcha.field_fastapi_endpoint__captcha_type +msgid "Captcha Type" +msgstr "" + +#. module: fastapi_captcha +#. odoo-python +#: code:addons/fastapi_captcha/captcha_middleware.py:0 +msgid "Captcha token not found in headers" +msgstr "" + +#. module: fastapi_captcha +#. odoo-python +#: code:addons/fastapi_captcha/captcha_middleware.py:0 +msgid "Captcha validation failed: %s" +msgstr "" + +#. module: fastapi_captcha +#: model:ir.model.fields,help:fastapi_captcha.field_fastapi_endpoint__captcha_custom_verify_url +msgid "Custom URL to use for the captcha verification" +msgstr "" + +#. module: fastapi_captcha +#: model:ir.model,name:fastapi_captcha.model_fastapi_endpoint +msgid "FastAPI Endpoint" +msgstr "" + +#. module: fastapi_captcha +#: model:ir.model.fields.selection,name:fastapi_captcha.selection__fastapi_endpoint__captcha_type__hcaptcha +msgid "Hcaptcha" +msgstr "" + +#. module: fastapi_captcha +#. odoo-python +#: code:addons/fastapi_captcha/models/fastapi_endpoint.py:0 +msgid "Hcaptcha validation failed: %s" +msgstr "" + +#. module: fastapi_captcha +#. odoo-python +#: code:addons/fastapi_captcha/models/fastapi_endpoint.py:0 +msgid "" +"Hcaptcha validation failed: score %(score)s < %(min_score)s (%(reason)s)" +msgstr "" + +#. module: fastapi_captcha +#: model:ir.model.fields,help:fastapi_captcha.field_fastapi_endpoint__use_captcha +msgid "If checked, this endpoint will be protected by a captcha" +msgstr "" + +#. module: fastapi_captcha +#. odoo-python +#: code:addons/fastapi_captcha/models/fastapi_endpoint.py:0 +msgid "Invalid regex for captcha routes: %(regex)s (error: %(error)s)" +msgstr "" + +#. module: fastapi_captcha +#: model:ir.model.fields,help:fastapi_captcha.field_fastapi_endpoint__captcha_minimum_score +msgid "" +"Minimum score to accept the captcha if a score is provided by the captcha " +"service." +msgstr "" + +#. module: fastapi_captcha +#. odoo-python +#: code:addons/fastapi_captcha/models/fastapi_endpoint.py:0 +msgid "No secret key found for this endpoint" +msgstr "" + +#. module: fastapi_captcha +#: model:ir.model.fields.selection,name:fastapi_captcha.selection__fastapi_endpoint__captcha_type__recaptcha +msgid "Recaptcha" +msgstr "" + +#. module: fastapi_captcha +#. odoo-python +#: code:addons/fastapi_captcha/models/fastapi_endpoint.py:0 +msgid "Recaptcha validation failed: %s" +msgstr "" + +#. module: fastapi_captcha +#. odoo-python +#: code:addons/fastapi_captcha/models/fastapi_endpoint.py:0 +msgid "Recaptcha validation failed: score %(score)s < %(min_score)s" +msgstr "" + +#. module: fastapi_captcha +#: model:ir.model.fields,help:fastapi_captcha.field_fastapi_endpoint__captcha_routes_regex +msgid "" +"Regexes to match against routes url that should be protected by this " +"captcha, comma separated. If empty, all routes will be protected" +msgstr "" + +#. module: fastapi_captcha +#: model:ir.model.fields,help:fastapi_captcha.field_fastapi_endpoint__captcha_secret_key +msgid "Secret key to use for the captcha validation" +msgstr "" + +#. module: fastapi_captcha +#: model:ir.model.fields,help:fastapi_captcha.field_fastapi_endpoint__captcha_type +msgid "Type of captcha to use for this endpoint" +msgstr "" + +#. module: fastapi_captcha +#: model:ir.model.fields,field_description:fastapi_captcha.field_fastapi_endpoint__use_captcha +msgid "Use Captcha" +msgstr "" diff --git a/fastapi_captcha/models/__init__.py b/fastapi_captcha/models/__init__.py new file mode 100644 index 000000000..b825fab92 --- /dev/null +++ b/fastapi_captcha/models/__init__.py @@ -0,0 +1 @@ +from . import fastapi_endpoint diff --git a/fastapi_captcha/models/fastapi_endpoint.py b/fastapi_captcha/models/fastapi_endpoint.py new file mode 100644 index 000000000..3895d7063 --- /dev/null +++ b/fastapi_captcha/models/fastapi_endpoint.py @@ -0,0 +1,226 @@ +# Copyright 2025 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import re +from typing import Annotated + +import requests +from starlette.middleware import Middleware + +from odoo import api, fields, models +from odoo.exceptions import AccessError, UserError, ValidationError + +from fastapi import Depends, Header + +from ..captcha_middleware import CaptchaMiddleware + + +class FastapiEndpoint(models.Model): + _inherit = "fastapi.endpoint" + + use_captcha = fields.Boolean( + help="If checked, this endpoint will be protected by a captcha", + ) + + captcha_type = fields.Selection( + [ + ("recaptcha", "Recaptcha"), + ("hcaptcha", "Hcaptcha"), + ("altcha", "Altcha"), + ], + help="Type of captcha to use for this endpoint", + ) + + captcha_secret_key = fields.Char( + help="Secret key to use for the captcha validation", + groups="base.group_system", + ) + + captcha_routes_regex = fields.Char( + help="Regexes to match against routes url that should be protected " + "by this captcha, comma separated. If empty, all routes will be protected", + ) + + captcha_minimum_score = fields.Float( + default=0.5, + help="Minimum score to accept the captcha if a score is provided by the " + "captcha service.", + ) + + captcha_custom_verify_url = fields.Char( + help="Custom URL to use for the captcha verification", + ) + + @property + def _server_env_fields(self): + fields = getattr(super(), "_server_env_fields", None) or {} + fields["captcha_secret_key"] = {} + return fields + + @api.constrains("captcha_routes_regex") + def _check_captcha_routes_regex(self): + """Check that the captcha routes regex is valid""" + for record in self: + if record.captcha_routes_regex: + for rex in record.captcha_routes_regex.split(","): + rex = rex.strip() + if not rex: + continue + # Check that the regex is valid + try: + re.compile(rex) + except re.error as e: + raise ValidationError( + self.env._( + "Invalid regex for captcha routes: %(regex)s " + "(error: %(error)s)", + regex=rex, + error=str(e), + ) + ) from e + + def _get_fastapi_app_middlewares(self): + # Add the captcha middleware to the list of middlewares if enabled + middlewares = super()._get_fastapi_app_middlewares() + if self.use_captcha: + middlewares.append( + Middleware( + CaptchaMiddleware, + endpoint_id=self.id, + root_path=self.root_path, + routes_regex=[ + re.compile(rex) for rex in self.captcha_routes_regex.split(",") + ] + if self.captcha_routes_regex + else None, + ) + ) + return middlewares + + def _get_fastapi_app_dependencies(self): + # Add the captcha header to the list of dependencies + dependencies = super()._get_fastapi_app_dependencies() + if self.use_captcha: + dependencies.append(Depends(captcha_token)) + + return dependencies + + def validate_captcha(self, captcha_response): + """Validate the captcha response.""" + secret_key = self.captcha_secret_key + if not secret_key: + raise UserError(self.env._("No secret key found for this endpoint")) + + if self.captcha_type == "recaptcha": + return self._validate_recaptcha(captcha_response, secret_key) + elif self.captcha_type == "hcaptcha": + return self._validate_hcaptcha(captcha_response, secret_key) + elif self.captcha_type == "altcha": + return self._validate_altcha(captcha_response, secret_key) + + def _validate_recaptcha(self, captcha_response, secret_key): + """Validate the recaptcha response""" + data = { + "secret": secret_key, + "response": captcha_response, + } + response = requests.post( + "https://www.google.com/recaptcha/api/siteverify", + data=data, + timeout=10, + ) + result = response.json() + success = result.get("success", False) + if not success: + error_codes = result.get("error-codes", ["?"]) + raise AccessError( + self.env._( + "Recaptcha validation failed: %s", + ", ".join(error_codes), + ) + ) + score = result.get("score", 1) + if score < self.captcha_minimum_score: + raise AccessError( + self.env._( + "Recaptcha validation failed: score %(score)s < %(min_score)s", + score=score, + min_score=self.captcha_minimum_score, + ) + ) + + def _validate_hcaptcha(self, captcha_response, secret_key): + """Validate the hcaptcha response""" + + data = { + "secret": secret_key, + "response": captcha_response, + } + response = requests.post( + "https://api.hcaptcha.com/siteverify", data=data, timeout=10 + ) + result = response.json() + success = result.get("success", False) + if not success: + error_codes = result.get("error-codes", ["?"]) + raise AccessError( + self.env._( + "Hcaptcha validation failed: %s", + ", ".join(error_codes), + ) + ) + score = result.get("score", 1) + if score < self.captcha_minimum_score: + raise AccessError( + self.env._( + "Hcaptcha validation failed: score %(score)s < %(min_score)s (%(reason)s)", + score=score, + min_score=self.captcha_minimum_score, + reason=result.get("score_reason", ""), + ) + ) + + def _validate_altcha(self, captcha_response, secret_key): + """Validate the altcha response""" + data = { + "apiKey": secret_key, + "payload": captcha_response, + } + url = ( + self.captcha_custom_verify_url + or "https://eu.altcha.org/api/v1/challenge/verify" + ) + response = requests.post(url, data=data, timeout=10) + result = response.json() + success = result.get("verified", False) + if not success: + error = result.get("error", "?") + raise AccessError( + self.env._( + "Altcha (%(url)s) validation failed: %(error)s", + url=url, + error=error, + ) + ) + + @api.model + def _fastapi_app_fields(self): + # We need to reload fastapi app when we change these captcha fields + fields = super()._fastapi_app_fields() + return [ + "use_captcha", + "captcha_routes_regex", + ] + fields + + +def captcha_token( + captcha_token: Annotated[ + str | None, + Header( + alias="X-Captcha-Token", + description="The X-Captcha-Token header is used to specify the captcha ", + ), + ] = None, +) -> str: + return captcha_token diff --git a/fastapi_captcha/pyproject.toml b/fastapi_captcha/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/fastapi_captcha/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/fastapi_captcha/readme/CONTRIBUTORS.md b/fastapi_captcha/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..328a37da8 --- /dev/null +++ b/fastapi_captcha/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Florian Mounier diff --git a/fastapi_captcha/readme/DESCRIPTION.md b/fastapi_captcha/readme/DESCRIPTION.md new file mode 100644 index 000000000..743b4e136 --- /dev/null +++ b/fastapi_captcha/readme/DESCRIPTION.md @@ -0,0 +1,8 @@ +This module provides a simple way to protect several fastapi endpoints routes with a +captcha. + +It currently supports the following captcha providers: + +- [Google reCAPTCHA](https://www.google.com/recaptcha) +- [hCaptcha](https://www.hcaptcha.com/) +- [Altcha](https://altcha.org/) diff --git a/fastapi_captcha/readme/USAGE.md b/fastapi_captcha/readme/USAGE.md new file mode 100644 index 000000000..0967b20d8 --- /dev/null +++ b/fastapi_captcha/readme/USAGE.md @@ -0,0 +1,5 @@ +Check the `Use Captcha` checkbox in your FastAPI endpoint to enable captcha validation, +then enter your captcha provider, secret key and an array of route url regex. + +Every matching route will now require a valid captcha token in the X-Captcha-Token +header. diff --git a/fastapi_captcha/static/description/icon.png b/fastapi_captcha/static/description/icon.png new file mode 100644 index 000000000..1dcc49c24 Binary files /dev/null and b/fastapi_captcha/static/description/icon.png differ diff --git a/fastapi_captcha/static/description/index.html b/fastapi_captcha/static/description/index.html new file mode 100644 index 000000000..f972ec265 --- /dev/null +++ b/fastapi_captcha/static/description/index.html @@ -0,0 +1,447 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Fastapi Captcha

+ +

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

+

This module provides a simple way to protect several fastapi endpoints +routes with a captcha.

+

It currently supports the following captcha providers:

+ +

Table of contents

+ +
+

Usage

+

Check the Use Captcha checkbox in your FastAPI endpoint to enable +captcha validation, then enter your captcha provider, secret key and an +array of route url regex.

+

Every matching route will now require a valid captcha token in the +X-Captcha-Token header.

+
+
+

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

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.

+

Current maintainer:

+

paradoxxxzero

+

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_captcha/tests/__init__.py b/fastapi_captcha/tests/__init__.py new file mode 100644 index 000000000..4bd3138a6 --- /dev/null +++ b/fastapi_captcha/tests/__init__.py @@ -0,0 +1 @@ +from . import test_fastapi_captcha diff --git a/fastapi_captcha/tests/test_fastapi_captcha.py b/fastapi_captcha/tests/test_fastapi_captcha.py new file mode 100644 index 000000000..379e2d175 --- /dev/null +++ b/fastapi_captcha/tests/test_fastapi_captcha.py @@ -0,0 +1,334 @@ +# Copyright 2025 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from unittest.mock import patch + +import requests + +from odoo.exceptions import AccessError, UserError, ValidationError + +from odoo.addons.fastapi.tests.common import FastAPITransactionCase + +from fastapi import status + + +class FastAPICaptcha(FastAPITransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env["fastapi.endpoint"]._load_demo_data() + cls.default_fastapi_running_user = cls.env.ref("fastapi.my_demo_app_user") + cls.default_fastapi_authenticated_partner = cls.env["res.partner"].create( + {"name": "FastAPI Demo"} + ) + cls.endpoint = cls.env.ref("fastapi.fastapi_endpoint_demo") + cls.endpoint.use_captcha = True + cls.endpoint.captcha_type = "recaptcha" + cls.endpoint.captcha_secret_key = "test_secret" + cls.default_fastapi_app = cls.endpoint._get_app() + + def test_no_secret_key(self): + self.endpoint.captcha_secret_key = False + with self._create_test_client() as test_client: + with self.assertRaisesRegex( + UserError, + "No secret key found for this endpoint", + ): + test_client.get("/demo/", headers={"X-Captcha-Token": "valid"}) + + def test_invalid_regex(self): + with self.assertRaisesRegex( + ValidationError, + r"Invalid regex for captcha routes: /route/\( ", + ): + self.endpoint.captcha_routes_regex = r"/route/(" + + def test_missing_header(self): + with self._create_test_client() as test_client: + with self.assertRaisesRegex( + AccessError, + "Captcha token not found in headers", + ): + test_client.get("/demo/") + with self.assertRaisesRegex( + AccessError, + "Captcha token not found in headers", + ): + test_client.get("/demo/who_ami") + + def test_invalid_header_recaptcha(self): + with patch( + "odoo.addons.fastapi_captcha.models.fastapi_endpoint.requests.post", + return_value=requests.Response(), + ) as mock_post: + mock_post.return_value.status_code = 200 + mock_post.return_value.json = lambda: { + "success": False, + "error-codes": ["invalid-input-response"], + } + with self._create_test_client() as test_client: + with self.assertRaisesRegex( + AccessError, + "Recaptcha validation failed: invalid-input-response", + ): + test_client.get("/demo/", headers={"X-Captcha-Token": "invalid"}) + with self.assertRaisesRegex( + AccessError, + "Recaptcha validation failed: invalid-input-response", + ): + test_client.get( + "/demo/who_ami", headers={"X-Captcha-Token": "invalid"} + ) + + def test_valid_header_recaptcha(self): + with patch( + "odoo.addons.fastapi_captcha.models.fastapi_endpoint.requests.post", + return_value=requests.Response(), + ) as mock_post: + mock_post.return_value.status_code = 200 + mock_post.return_value.json = lambda: { + "success": True, + "score": 0.9, + } + with self._create_test_client() as test_client: + response = test_client.get( + "/demo/", headers={"X-Captcha-Token": "valid"} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json(), {"Hello": "World"}) + + response = test_client.get( + "/demo/who_ami", headers={"X-Captcha-Token": "valid"} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + partner = self.default_fastapi_authenticated_partner + self.assertDictEqual( + response.json(), + { + "name": partner.name, + "display_name": partner.display_name, + }, + ) + + def test_invalid_header_hcaptcha(self): + self.endpoint.captcha_type = "hcaptcha" + self.endpoint.captcha_minimum_score = 0.8 + with patch( + "odoo.addons.fastapi_captcha.models.fastapi_endpoint.requests.post", + return_value=requests.Response(), + ) as mock_post: + mock_post.return_value.status_code = 200 + mock_post.return_value.json = lambda: { + "success": False, + "error-codes": ["invalid-input-response"], + } + with self._create_test_client() as test_client: + with self.assertRaisesRegex( + AccessError, + "Hcaptcha validation failed: invalid-input-response", + ): + test_client.get("/demo/", headers={"X-Captcha-Token": "invalid"}) + with self.assertRaisesRegex( + AccessError, + "Hcaptcha validation failed: invalid-input-response", + ): + test_client.get( + "/demo/who_ami", headers={"X-Captcha-Token": "invalid"} + ) + + def test_valid_header_hcaptcha(self): + self.endpoint.captcha_type = "hcaptcha" + self.endpoint.captcha_minimum_score = 0.8 + with patch( + "odoo.addons.fastapi_captcha.models.fastapi_endpoint.requests.post", + return_value=requests.Response(), + ) as mock_post: + mock_post.return_value.status_code = 200 + mock_post.return_value.json = lambda: { + "success": True, + "score": 0.9, + } + with self._create_test_client() as test_client: + response = test_client.get( + "/demo/", headers={"X-Captcha-Token": "valid"} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json(), {"Hello": "World"}) + + response = test_client.get( + "/demo/who_ami", headers={"X-Captcha-Token": "valid"} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + partner = self.default_fastapi_authenticated_partner + self.assertDictEqual( + response.json(), + { + "name": partner.name, + "display_name": partner.display_name, + }, + ) + + def test_valid_header_low_score_hcaptcha(self): + self.endpoint.captcha_type = "hcaptcha" + self.endpoint.captcha_minimum_score = 0.8 + with patch( + "odoo.addons.fastapi_captcha.models.fastapi_endpoint.requests.post", + return_value=requests.Response(), + ) as mock_post: + mock_post.return_value.status_code = 200 + mock_post.return_value.json = lambda: { + "success": True, + "score": 0.6, + "score_reason": "low-confidence", + } + with self._create_test_client() as test_client: + with self.assertRaisesRegex( + AccessError, + r"Hcaptcha validation failed: score 0.6 < 0.8 \(low-confidence\)", + ): + test_client.get("/demo/", headers={"X-Captcha-Token": "valid"}) + + def test_invalid_header_altcha(self): + self.endpoint.captcha_type = "altcha" + with patch( + "odoo.addons.fastapi_captcha.models.fastapi_endpoint.requests.post", + return_value=requests.Response(), + ) as mock_post: + mock_post.return_value.status_code = 200 + mock_post.return_value.json = lambda: { + "verified": False, + "error": "invalid-input-response", + } + with self._create_test_client() as test_client: + with self.assertRaisesRegex( + AccessError, + r"Altcha \(https://eu.altcha.org/api/v1/challenge/verify\) " + "validation failed: invalid-input-response", + ): + test_client.get("/demo/", headers={"X-Captcha-Token": "invalid"}) + + self.assertGreaterEqual(mock_post.call_count, 1) + self.assertEqual( + mock_post.call_args.args[0], + "https://eu.altcha.org/api/v1/challenge/verify", + ) + + with self.assertRaisesRegex( + AccessError, + r"Altcha \(https://eu.altcha.org/api/v1/challenge/verify\) " + "validation failed: invalid-input-response", + ): + test_client.get( + "/demo/who_ami", headers={"X-Captcha-Token": "invalid"} + ) + + def test_valid_header_altcha(self): + self.endpoint.captcha_type = "altcha" + with patch( + "odoo.addons.fastapi_captcha.models.fastapi_endpoint.requests.post", + return_value=requests.Response(), + ) as mock_post: + mock_post.return_value.status_code = 200 + mock_post.return_value.json = lambda: { + "verified": True, + } + with self._create_test_client() as test_client: + response = test_client.get( + "/demo/", headers={"X-Captcha-Token": "valid"} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json(), {"Hello": "World"}) + + self.assertGreaterEqual(mock_post.call_count, 1) + self.assertEqual( + mock_post.call_args.args[0], + "https://eu.altcha.org/api/v1/challenge/verify", + ) + response = test_client.get( + "/demo/who_ami", headers={"X-Captcha-Token": "valid"} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + partner = self.default_fastapi_authenticated_partner + self.assertDictEqual( + response.json(), + { + "name": partner.name, + "display_name": partner.display_name, + }, + ) + + def test_valid_header_custom_url_altcha(self): + self.endpoint.captcha_type = "altcha" + self.endpoint.captcha_custom_verify_url = "https://custom.exemple.org/verify" + + with patch( + "odoo.addons.fastapi_captcha.models.fastapi_endpoint.requests.post", + return_value=requests.Response(), + ) as mock_post: + mock_post.return_value.status_code = 200 + mock_post.return_value.json = lambda: { + "verified": True, + } + with self._create_test_client() as test_client: + response = test_client.get( + "/demo/", headers={"X-Captcha-Token": "valid"} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json(), {"Hello": "World"}) + + self.assertGreaterEqual(mock_post.call_count, 1) + self.assertEqual( + mock_post.call_args.args[0], + "https://custom.exemple.org/verify", + ) + response = test_client.get( + "/demo/who_ami", headers={"X-Captcha-Token": "valid"} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + partner = self.default_fastapi_authenticated_partner + self.assertDictEqual( + response.json(), + { + "name": partner.name, + "display_name": partner.display_name, + }, + ) + + def test_routes_matching_1(self): + self.endpoint.captcha_routes_regex = "/demo/wh.*,/demo/ca.?" + # Refresh app + self.default_fastapi_app = self.endpoint._get_app() + + with self._create_test_client() as test_client: + response = test_client.get("/demo") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json(), {"Hello": "World"}) + + with self.assertRaisesRegex( + AccessError, + "Captcha token not found in headers", + ): + test_client.get("/demo/who_ami") + + def test_routes_matching_2(self): + self.endpoint.captcha_routes_regex = "/demo" + # Refresh app + self.default_fastapi_app = self.endpoint._get_app() + + with self._create_test_client() as test_client: + with self.assertRaisesRegex( + AccessError, + "Captcha token not found in headers", + ): + test_client.get("/demo") + + response = test_client.get("/demo/who_ami") + self.assertEqual(response.status_code, status.HTTP_200_OK) + partner = self.default_fastapi_authenticated_partner + self.assertDictEqual( + response.json(), + { + "name": partner.name, + "display_name": partner.display_name, + }, + ) diff --git a/fastapi_captcha/views/fastapi_endpoint_views.xml b/fastapi_captcha/views/fastapi_endpoint_views.xml new file mode 100644 index 000000000..424eb15b2 --- /dev/null +++ b/fastapi_captcha/views/fastapi_endpoint_views.xml @@ -0,0 +1,29 @@ + + + + + fastapi.endpoint + + + + + + + + + + + + + + + + +