From 0d20fcc0c4a493ae1324c053107a038914c81239 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Fri, 16 May 2025 18:10:48 +0200 Subject: [PATCH 1/6] [ADD] fastapi_captcha --- fastapi_captcha/README.rst | 98 ++++ fastapi_captcha/__init__.py | 1 + fastapi_captcha/__manifest__.py | 20 + fastapi_captcha/captcha_middleware.py | 36 ++ fastapi_captcha/models/__init__.py | 1 + fastapi_captcha/models/fastapi_endpoint.py | 213 +++++++++ fastapi_captcha/readme/CONTRIBUTORS.md | 1 + fastapi_captcha/readme/DESCRIPTION.md | 8 + fastapi_captcha/readme/USAGE.md | 2 + fastapi_captcha/static/description/index.html | 439 ++++++++++++++++++ fastapi_captcha/tests/__init__.py | 1 + fastapi_captcha/tests/test_fastapi_captcha.py | 189 ++++++++ .../views/fastapi_endpoint_views.xml | 37 ++ .../odoo/addons/fastapi_captcha | 1 + setup/fastapi_captcha/setup.py | 6 + 15 files changed, 1053 insertions(+) create mode 100644 fastapi_captcha/README.rst create mode 100644 fastapi_captcha/__init__.py create mode 100644 fastapi_captcha/__manifest__.py create mode 100644 fastapi_captcha/captcha_middleware.py create mode 100644 fastapi_captcha/models/__init__.py create mode 100644 fastapi_captcha/models/fastapi_endpoint.py create mode 100644 fastapi_captcha/readme/CONTRIBUTORS.md create mode 100644 fastapi_captcha/readme/DESCRIPTION.md create mode 100644 fastapi_captcha/readme/USAGE.md create mode 100644 fastapi_captcha/static/description/index.html create mode 100644 fastapi_captcha/tests/__init__.py create mode 100644 fastapi_captcha/tests/test_fastapi_captcha.py create mode 100644 fastapi_captcha/views/fastapi_endpoint_views.xml create mode 120000 setup/fastapi_captcha/odoo/addons/fastapi_captcha create mode 100644 setup/fastapi_captcha/setup.py diff --git a/fastapi_captcha/README.rst b/fastapi_captcha/README.rst new file mode 100644 index 000000000..90272cdf1 --- /dev/null +++ b/fastapi_captcha/README.rst @@ -0,0 +1,98 @@ +=============== +Fastapi Captcha +=============== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:a507efff5b29eb67557d6283c396db18daddc1e48115ede431daff7f686594b6 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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_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-16-0/rest-framework-16-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=16.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 curreently 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. + +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..2169ca0d2 --- /dev/null +++ b/fastapi_captcha/__manifest__.py @@ -0,0 +1,20 @@ +# 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": "16.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"], + "demo": [], + "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..035c9038b --- /dev/null +++ b/fastapi_captcha/captcha_middleware.py @@ -0,0 +1,36 @@ +# 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 import _ +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( + _("Captcha token not found in headers"), + ) + endpoint.validate_captcha(token) + response = await call_next(request) + return response 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..4f0348d91 --- /dev/null +++ b/fastapi_captcha/models/fastapi_endpoint.py @@ -0,0 +1,213 @@ +# 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.", + ) + + @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( + _( + "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(_("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( + _("Recaptcha validation failed: %s") % ", ".join(error_codes) + ) + score = result.get("score", 1) + if score < self.captcha_minimum_score: + raise AccessError( + _("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( + _("Hcaptcha validation failed: %s") % ", ".join(error_codes) + ) + score = result.get("score", 1) + if score < self.captcha_minimum_score: + raise AccessError( + _( + "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, + } + response = requests.post( + "https://eu.altcha.org/api/v1/challenge/verify", + data=data, + timeout=10, + ) + result = response.json() + success = result.get("verified", False) + if not success: + error = result.get("error", "?") + raise AccessError(_("Altcha validation failed: %s") % 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/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..80049a67c --- /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 curreently 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..805f8a6b1 --- /dev/null +++ b/fastapi_captcha/readme/USAGE.md @@ -0,0 +1,2 @@ +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. diff --git a/fastapi_captcha/static/description/index.html b/fastapi_captcha/static/description/index.html new file mode 100644 index 000000000..1a7f65550 --- /dev/null +++ b/fastapi_captcha/static/description/index.html @@ -0,0 +1,439 @@ + + + + + +Fastapi Captcha + + + +
+

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 curreently 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.

+
+
+

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..23f9436d5 --- /dev/null +++ b/fastapi_captcha/tests/test_fastapi_captcha.py @@ -0,0 +1,189 @@ +# 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 + +from odoo.addons.fastapi.tests.common import FastAPITransactionCase + +from fastapi import status + + +class FastAPICaptcha(FastAPITransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + 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_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" + 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" + 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_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..7dde2cb63 --- /dev/null +++ b/fastapi_captcha/views/fastapi_endpoint_views.xml @@ -0,0 +1,37 @@ + + + + + + + fastapi.endpoint + + + + + + + + + + + + + + + + + diff --git a/setup/fastapi_captcha/odoo/addons/fastapi_captcha b/setup/fastapi_captcha/odoo/addons/fastapi_captcha new file mode 120000 index 000000000..2bbba3b2f --- /dev/null +++ b/setup/fastapi_captcha/odoo/addons/fastapi_captcha @@ -0,0 +1 @@ +../../../../fastapi_captcha \ No newline at end of file diff --git a/setup/fastapi_captcha/setup.py b/setup/fastapi_captcha/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/fastapi_captcha/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) From ac197855e547df444c54907d90f47aa346685b20 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Mon, 1 Sep 2025 16:42:38 +0200 Subject: [PATCH 2/6] [IMP] fastapi_captcha: Handle custom altcha url --- fastapi_captcha/captcha_middleware.py | 9 +- fastapi_captcha/models/fastapi_endpoint.py | 17 +- fastapi_captcha/tests/test_fastapi_captcha.py | 146 +++++++++++++++++- .../views/fastapi_endpoint_views.xml | 4 + 4 files changed, 169 insertions(+), 7 deletions(-) diff --git a/fastapi_captcha/captcha_middleware.py b/fastapi_captcha/captcha_middleware.py index 035c9038b..2120b1644 100644 --- a/fastapi_captcha/captcha_middleware.py +++ b/fastapi_captcha/captcha_middleware.py @@ -31,6 +31,13 @@ async def dispatch(self, request, call_next): raise AccessError( _("Captcha token not found in headers"), ) - endpoint.validate_captcha(token) + try: + endpoint.validate_captcha(token) + except AccessError as e: + raise e + except IOError as e: + raise AccessError( + _("Captcha validation failed: %s") % str(e), + ) from e response = await call_next(request) return response diff --git a/fastapi_captcha/models/fastapi_endpoint.py b/fastapi_captcha/models/fastapi_endpoint.py index 4f0348d91..7797fca05 100644 --- a/fastapi_captcha/models/fastapi_endpoint.py +++ b/fastapi_captcha/models/fastapi_endpoint.py @@ -48,6 +48,10 @@ class FastapiEndpoint(models.Model): "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 {} @@ -180,16 +184,19 @@ def _validate_altcha(self, captcha_response, secret_key): "apiKey": secret_key, "payload": captcha_response, } - response = requests.post( - "https://eu.altcha.org/api/v1/challenge/verify", - data=data, - timeout=10, + 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(_("Altcha validation failed: %s") % error) + raise AccessError( + _("Altcha (%(url)s) validation failed: %(error)s") + % {"url": url, "error": error} + ) @api.model def _fastapi_app_fields(self): diff --git a/fastapi_captcha/tests/test_fastapi_captcha.py b/fastapi_captcha/tests/test_fastapi_captcha.py index 23f9436d5..b5479a1fb 100644 --- a/fastapi_captcha/tests/test_fastapi_captcha.py +++ b/fastapi_captcha/tests/test_fastapi_captcha.py @@ -5,7 +5,7 @@ import requests -from odoo.exceptions import AccessError +from odoo.exceptions import AccessError, UserError, ValidationError from odoo.addons.fastapi.tests.common import FastAPITransactionCase @@ -26,6 +26,22 @@ def setUpClass(cls): 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( @@ -95,6 +111,7 @@ def test_valid_header_recaptcha(self): 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(), @@ -120,6 +137,7 @@ def test_invalid_header_hcaptcha(self): 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(), @@ -149,6 +167,132 @@ def test_valid_header_hcaptcha(self): }, ) + 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 diff --git a/fastapi_captcha/views/fastapi_endpoint_views.xml b/fastapi_captcha/views/fastapi_endpoint_views.xml index 7dde2cb63..21b6bcde1 100644 --- a/fastapi_captcha/views/fastapi_endpoint_views.xml +++ b/fastapi_captcha/views/fastapi_endpoint_views.xml @@ -28,6 +28,10 @@ attrs="{'required': [('use_captcha', '=', True)]}" /> + From b9daf3cc78727f917df33683259d54be637c514e Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Tue, 19 Aug 2025 16:18:59 +0200 Subject: [PATCH 3/6] [IMP] fastapi_captcha: Handle custom altcha url --- fastapi_captcha/README.rst | 5 ++++- fastapi_captcha/readme/DESCRIPTION.md | 2 +- fastapi_captcha/readme/USAGE.md | 3 +++ fastapi_captcha/static/description/index.html | 4 +++- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/fastapi_captcha/README.rst b/fastapi_captcha/README.rst index 90272cdf1..e2ebfe7b3 100644 --- a/fastapi_captcha/README.rst +++ b/fastapi_captcha/README.rst @@ -31,7 +31,7 @@ Fastapi Captcha This module provides a simple way to protect several fastapi endpoints routes with a captcha. -It curreently supports the following captcha providers: +It currently supports the following captcha providers: - `Google reCAPTCHA `__ - `hCaptcha `__ @@ -49,6 +49,9 @@ 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 =========== diff --git a/fastapi_captcha/readme/DESCRIPTION.md b/fastapi_captcha/readme/DESCRIPTION.md index 80049a67c..743b4e136 100644 --- a/fastapi_captcha/readme/DESCRIPTION.md +++ b/fastapi_captcha/readme/DESCRIPTION.md @@ -1,7 +1,7 @@ This module provides a simple way to protect several fastapi endpoints routes with a captcha. -It curreently supports the following captcha providers: +It currently supports the following captcha providers: - [Google reCAPTCHA](https://www.google.com/recaptcha) - [hCaptcha](https://www.hcaptcha.com/) diff --git a/fastapi_captcha/readme/USAGE.md b/fastapi_captcha/readme/USAGE.md index 805f8a6b1..0967b20d8 100644 --- a/fastapi_captcha/readme/USAGE.md +++ b/fastapi_captcha/readme/USAGE.md @@ -1,2 +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/index.html b/fastapi_captcha/static/description/index.html index 1a7f65550..36ac1639d 100644 --- a/fastapi_captcha/static/description/index.html +++ b/fastapi_captcha/static/description/index.html @@ -372,7 +372,7 @@

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 curreently supports the following captcha providers:

+

It currently supports the following captcha providers:

  • Google reCAPTCHA
  • hCaptcha
  • @@ -396,6 +396,8 @@

    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

    From c9c4a217bd28e2e18fafb4bdc2b6f7e964d92697 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Tue, 19 Aug 2025 16:22:11 +0200 Subject: [PATCH 4/6] [ADD] fastapi_captcha_altcha_backend --- fastapi_captcha_altcha_backend/README.rst | 93 ++++ fastapi_captcha_altcha_backend/__init__.py | 2 + .../__manifest__.py | 23 + .../models/__init__.py | 1 + .../models/fastapi_endpoint.py | 48 ++ .../readme/CONTRIBUTORS.md | 1 + .../readme/DESCRIPTION.md | 2 + .../readme/USAGE.md | 3 + .../routers/__init__.py | 1 + .../routers/altcha.py | 46 ++ fastapi_captcha_altcha_backend/schemas.py | 23 + .../static/description/index.html | 434 ++++++++++++++++++ requirements.txt | 1 + .../addons/fastapi_captcha_altcha_backend | 1 + setup/fastapi_captcha_altcha_backend/setup.py | 6 + 15 files changed, 685 insertions(+) create mode 100644 fastapi_captcha_altcha_backend/README.rst create mode 100644 fastapi_captcha_altcha_backend/__init__.py create mode 100644 fastapi_captcha_altcha_backend/__manifest__.py create mode 100644 fastapi_captcha_altcha_backend/models/__init__.py create mode 100644 fastapi_captcha_altcha_backend/models/fastapi_endpoint.py create mode 100644 fastapi_captcha_altcha_backend/readme/CONTRIBUTORS.md create mode 100644 fastapi_captcha_altcha_backend/readme/DESCRIPTION.md create mode 100644 fastapi_captcha_altcha_backend/readme/USAGE.md create mode 100644 fastapi_captcha_altcha_backend/routers/__init__.py create mode 100644 fastapi_captcha_altcha_backend/routers/altcha.py create mode 100644 fastapi_captcha_altcha_backend/schemas.py create mode 100644 fastapi_captcha_altcha_backend/static/description/index.html create mode 120000 setup/fastapi_captcha_altcha_backend/odoo/addons/fastapi_captcha_altcha_backend create mode 100644 setup/fastapi_captcha_altcha_backend/setup.py diff --git a/fastapi_captcha_altcha_backend/README.rst b/fastapi_captcha_altcha_backend/README.rst new file mode 100644 index 000000000..43a6f9f0a --- /dev/null +++ b/fastapi_captcha_altcha_backend/README.rst @@ -0,0 +1,93 @@ +============================== +Fastapi Captcha Altcha Backend +============================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:e97f2c1f5989e99007440eecfb45f75fb664da90312dfd68b8a61d8a321305c1 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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_captcha_altcha_backend + :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_captcha_altcha_backend + :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 Altcha service as a FastApi router and add local Altcha +verification as a captcha method. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Add the altcha router in your FastAPI application to enable the Altcha +captcha verification. Get the challenge from the /altcha/challenge +endpoint. Choose the altcha_local captcha type in your FastAPI endpoint +configuration. + +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_altcha_backend/__init__.py b/fastapi_captcha_altcha_backend/__init__.py new file mode 100644 index 000000000..9ef814457 --- /dev/null +++ b/fastapi_captcha_altcha_backend/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import routers diff --git a/fastapi_captcha_altcha_backend/__manifest__.py b/fastapi_captcha_altcha_backend/__manifest__.py new file mode 100644 index 000000000..f114feecf --- /dev/null +++ b/fastapi_captcha_altcha_backend/__manifest__.py @@ -0,0 +1,23 @@ +# 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 Altcha Backend", + "version": "16.0.1.0.0", + "author": "Akretion, Odoo Community Association (OCA)", + "summary": "Implement Altcha server in FastAPI", + "category": "Tools", + "depends": ["fastapi_captcha"], + "website": "https://github.com/OCA/rest-framework", + "data": [], + "maintainers": ["paradoxxxzero"], + "demo": [], + "installable": True, + "license": "AGPL-3", + "external_dependencies": { + "python": [ + "altcha", + ] + }, +} diff --git a/fastapi_captcha_altcha_backend/models/__init__.py b/fastapi_captcha_altcha_backend/models/__init__.py new file mode 100644 index 000000000..b825fab92 --- /dev/null +++ b/fastapi_captcha_altcha_backend/models/__init__.py @@ -0,0 +1 @@ +from . import fastapi_endpoint diff --git a/fastapi_captcha_altcha_backend/models/fastapi_endpoint.py b/fastapi_captcha_altcha_backend/models/fastapi_endpoint.py new file mode 100644 index 000000000..b567d5bf5 --- /dev/null +++ b/fastapi_captcha_altcha_backend/models/fastapi_endpoint.py @@ -0,0 +1,48 @@ +# Copyright 2025 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 +from odoo.exceptions import AccessError, UserError, ValidationError + +try: + import altcha + from altcha import verify_solution +except ImportError: + altcha = None + + +class FastapiEndpoint(models.Model): + _inherit = "fastapi.endpoint" + + captcha_type = fields.Selection( + selection_add=[ + ("altcha_local", "Altcha (Local)"), + ], + ) + + def validate_captcha(self, captcha_response): + """Validate the captcha response.""" + super().validate_captcha(captcha_response) + secret_key = self.captcha_secret_key + if self.captcha_type == "altcha_local": + if not altcha: + raise UserError(_("Altcha library is not installed.")) + return self._validate_altcha_local(captcha_response, secret_key) + + def _validate_altcha_local(self, captcha_response, secret_key): + """Validate the altcha""" + + try: + # Verify the solution + verified, err = verify_solution(captcha_response, secret_key, True) + if not verified: + raise AccessError( + _("Altcha validation failed: %(error)s") % {"error": err} + ) + + return + except Exception as e: + raise ValidationError( + _("Failed to process Altcha payload: %(error)s") % {"error": str(e)} + ) from e diff --git a/fastapi_captcha_altcha_backend/readme/CONTRIBUTORS.md b/fastapi_captcha_altcha_backend/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..328a37da8 --- /dev/null +++ b/fastapi_captcha_altcha_backend/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Florian Mounier diff --git a/fastapi_captcha_altcha_backend/readme/DESCRIPTION.md b/fastapi_captcha_altcha_backend/readme/DESCRIPTION.md new file mode 100644 index 000000000..b3c83e3c5 --- /dev/null +++ b/fastapi_captcha_altcha_backend/readme/DESCRIPTION.md @@ -0,0 +1,2 @@ +This module adds Altcha service as a FastApi router and add local Altcha verification as +a captcha method. diff --git a/fastapi_captcha_altcha_backend/readme/USAGE.md b/fastapi_captcha_altcha_backend/readme/USAGE.md new file mode 100644 index 000000000..e4f7a0714 --- /dev/null +++ b/fastapi_captcha_altcha_backend/readme/USAGE.md @@ -0,0 +1,3 @@ +Add the altcha router in your FastAPI application to enable the Altcha captcha +verification. Get the challenge from the /altcha/challenge endpoint. Choose the +altcha_local captcha type in your FastAPI endpoint configuration. diff --git a/fastapi_captcha_altcha_backend/routers/__init__.py b/fastapi_captcha_altcha_backend/routers/__init__.py new file mode 100644 index 000000000..dfff867b8 --- /dev/null +++ b/fastapi_captcha_altcha_backend/routers/__init__.py @@ -0,0 +1 @@ +from .altcha import altcha_router diff --git a/fastapi_captcha_altcha_backend/routers/altcha.py b/fastapi_captcha_altcha_backend/routers/altcha.py new file mode 100644 index 000000000..6e2a9309c --- /dev/null +++ b/fastapi_captcha_altcha_backend/routers/altcha.py @@ -0,0 +1,46 @@ +# Copyright 2025 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import datetime +from typing import Annotated + +from odoo import _ +from odoo.exceptions import AccessDenied, ValidationError + +from odoo.addons.fastapi.dependencies import fastapi_endpoint +from odoo.addons.fastapi.models import FastapiEndpoint + +from fastapi import APIRouter, Depends + +try: + import altcha + from altcha import ChallengeOptions, create_challenge +except ImportError: + altcha = None + +from ..schemas import AltchaChallenge + +altcha_router = APIRouter(tags=["altcha"]) + + +@altcha_router.get("/altcha/challenge") +def altcha_challenge( + endpoint: Annotated[FastapiEndpoint, Depends(fastapi_endpoint)], +) -> AltchaChallenge: + if not altcha: + raise ValidationError(_("Altcha library is not installed.")) + secret_key = endpoint.sudo().captcha_secret_key + if not secret_key: + raise ValidationError(_("Captcha secret key is not set for this endpoint.")) + + try: + challenge = create_challenge( + ChallengeOptions( + expires=datetime.datetime.now() + datetime.timedelta(minutes=5), + hmac_key=secret_key, + max_number=50000, + ) + ) + return AltchaChallenge.from_challenge(challenge) + except Exception as e: + raise AccessDenied(_("Failed to create Altcha challenge.")) from e diff --git a/fastapi_captcha_altcha_backend/schemas.py b/fastapi_captcha_altcha_backend/schemas.py new file mode 100644 index 000000000..8de165f72 --- /dev/null +++ b/fastapi_captcha_altcha_backend/schemas.py @@ -0,0 +1,23 @@ +# Copyright 2025 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from extendable_pydantic import StrictExtendableBaseModel + + +class AltchaChallenge(StrictExtendableBaseModel): + algorithm: str + challenge: str + max_number: int + salt: str + signature: str + + @classmethod + def from_challenge(cls, challenge): + return cls.model_construct( + algorithm=challenge.algorithm, + challenge=challenge.challenge, + max_number=challenge.max_number, + salt=challenge.salt, + signature=challenge.signature, + ) diff --git a/fastapi_captcha_altcha_backend/static/description/index.html b/fastapi_captcha_altcha_backend/static/description/index.html new file mode 100644 index 000000000..44dba5cb5 --- /dev/null +++ b/fastapi_captcha_altcha_backend/static/description/index.html @@ -0,0 +1,434 @@ + + + + + +Fastapi Captcha Altcha Backend + + + +
    +

    Fastapi Captcha Altcha Backend

    + + +

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

    +

    This module adds Altcha service as a FastApi router and add local Altcha +verification as a captcha method.

    +

    Table of contents

    + +
    +

    Usage

    +

    Add the altcha router in your FastAPI application to enable the Altcha +captcha verification. Get the challenge from the /altcha/challenge +endpoint. Choose the altcha_local captcha type in your FastAPI endpoint +configuration.

    +
    +
    +

    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/requirements.txt b/requirements.txt index b37f64671..3572b4b71 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ # generated from manifests external_dependencies a2wsgi>=1.10.6 +altcha apispec cerberus contextvars diff --git a/setup/fastapi_captcha_altcha_backend/odoo/addons/fastapi_captcha_altcha_backend b/setup/fastapi_captcha_altcha_backend/odoo/addons/fastapi_captcha_altcha_backend new file mode 120000 index 000000000..8227fb4c2 --- /dev/null +++ b/setup/fastapi_captcha_altcha_backend/odoo/addons/fastapi_captcha_altcha_backend @@ -0,0 +1 @@ +../../../../fastapi_captcha_altcha_backend \ No newline at end of file diff --git a/setup/fastapi_captcha_altcha_backend/setup.py b/setup/fastapi_captcha_altcha_backend/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/fastapi_captcha_altcha_backend/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) From 1d9c599d2a1475d2cf12f1865cbeaed04e223d22 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Mon, 16 Mar 2026 10:29:53 +0100 Subject: [PATCH 5/6] [MIG] fastapi_captcha --- fastapi_captcha/README.rst | 10 ++++---- fastapi_captcha/__manifest__.py | 3 +-- fastapi_captcha/captcha_middleware.py | 7 +++--- fastapi_captcha/models/fastapi_endpoint.py | 24 +++++++++++-------- fastapi_captcha/pyproject.toml | 3 +++ fastapi_captcha/static/description/index.html | 6 ++--- .../views/fastapi_endpoint_views.xml | 20 ++++------------ 7 files changed, 33 insertions(+), 40 deletions(-) create mode 100644 fastapi_captcha/pyproject.toml diff --git a/fastapi_captcha/README.rst b/fastapi_captcha/README.rst index e2ebfe7b3..bfd5ac850 100644 --- a/fastapi_captcha/README.rst +++ b/fastapi_captcha/README.rst @@ -17,13 +17,13 @@ Fastapi Captcha :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_captcha + :target: https://github.com/OCA/rest-framework/tree/18.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-16-0/rest-framework-16-0-fastapi_captcha + :target: https://translation.odoo-community.org/projects/rest-framework-18-0/rest-framework-18-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=16.0 + :target: https://runboat.odoo-community.org/builds?repo=OCA/rest-framework&target_branch=18.0 :alt: Try me on Runboat |badge1| |badge2| |badge3| |badge4| |badge5| @@ -58,7 +58,7 @@ 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 `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -96,6 +96,6 @@ Current `maintainer `__: |maintainer-paradoxxxzero| -This module is part of the `OCA/rest-framework `_ project on GitHub. +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/__manifest__.py b/fastapi_captcha/__manifest__.py index 2169ca0d2..25068baa5 100644 --- a/fastapi_captcha/__manifest__.py +++ b/fastapi_captcha/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Fastapi Captcha", - "version": "16.0.1.0.0", + "version": "18.0.1.0.0", "author": "Akretion, Odoo Community Association (OCA)", "summary": "Add a captcha to your FastAPI routes", "category": "Tools", @@ -14,7 +14,6 @@ "views/fastapi_endpoint_views.xml", ], "maintainers": ["paradoxxxzero"], - "demo": [], "installable": True, "license": "AGPL-3", } diff --git a/fastapi_captcha/captcha_middleware.py b/fastapi_captcha/captcha_middleware.py index 2120b1644..b6e276cf2 100644 --- a/fastapi_captcha/captcha_middleware.py +++ b/fastapi_captcha/captcha_middleware.py @@ -4,7 +4,6 @@ from starlette.middleware.base import BaseHTTPMiddleware -from odoo import _ from odoo.exceptions import AccessError from odoo.addons.fastapi.context import odoo_env_ctx @@ -29,15 +28,15 @@ async def dispatch(self, request, call_next): token = request.headers.get("X-Captcha-Token") if not token: raise AccessError( - _("Captcha token not found in headers"), + env._("Captcha token not found in headers"), ) try: endpoint.validate_captcha(token) except AccessError as e: raise e - except IOError as e: + except OSError as e: raise AccessError( - _("Captcha validation failed: %s") % str(e), + env._("Captcha validation failed: %s") % str(e), ) from e response = await call_next(request) return response diff --git a/fastapi_captcha/models/fastapi_endpoint.py b/fastapi_captcha/models/fastapi_endpoint.py index 7797fca05..00d4f7e38 100644 --- a/fastapi_captcha/models/fastapi_endpoint.py +++ b/fastapi_captcha/models/fastapi_endpoint.py @@ -8,7 +8,7 @@ import requests from starlette.middleware import Middleware -from odoo import _, api, fields, models +from odoo import api, fields, models from odoo.exceptions import AccessError, UserError, ValidationError from fastapi import Depends, Header @@ -72,8 +72,9 @@ def _check_captcha_routes_regex(self): re.compile(rex) except re.error as e: raise ValidationError( - _( - "Invalid regex for captcha routes: %(regex)s (error: %(error)s)" + self.env._( + "Invalid regex for captcha routes: %(regex)s " + "(error: %(error)s)" ) % { "regex": rex, @@ -111,7 +112,7 @@ def validate_captcha(self, captcha_response): """Validate the captcha response.""" secret_key = self.captcha_secret_key if not secret_key: - raise UserError(_("No secret key found for this endpoint")) + raise UserError(self.env._("No secret key found for this endpoint")) if self.captcha_type == "recaptcha": return self._validate_recaptcha(captcha_response, secret_key) @@ -136,12 +137,14 @@ def _validate_recaptcha(self, captcha_response, secret_key): if not success: error_codes = result.get("error-codes", ["?"]) raise AccessError( - _("Recaptcha validation failed: %s") % ", ".join(error_codes) + self.env._("Recaptcha validation failed: %s") % ", ".join(error_codes) ) score = result.get("score", 1) if score < self.captcha_minimum_score: raise AccessError( - _("Recaptcha validation failed: score %(score)s < %(min_score)s") + self.env._( + "Recaptcha validation failed: score %(score)s < %(min_score)s" + ) % { "score": score, "min_score": self.captcha_minimum_score, @@ -163,13 +166,14 @@ def _validate_hcaptcha(self, captcha_response, secret_key): if not success: error_codes = result.get("error-codes", ["?"]) raise AccessError( - _("Hcaptcha validation failed: %s") % ", ".join(error_codes) + self.env._("Hcaptcha validation failed: %s") % ", ".join(error_codes) ) score = result.get("score", 1) if score < self.captcha_minimum_score: raise AccessError( - _( - "Hcaptcha validation failed: score %(score)s < %(min_score)s (%(reason)s)" + self.env._( + "Hcaptcha validation failed: score %(score)s < %(min_score)s " + "(%(reason)s)" ) % { "score": score, @@ -194,7 +198,7 @@ def _validate_altcha(self, captcha_response, secret_key): if not success: error = result.get("error", "?") raise AccessError( - _("Altcha (%(url)s) validation failed: %(error)s") + self.env._("Altcha (%(url)s) validation failed: %(error)s") % {"url": url, "error": error} ) 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/static/description/index.html b/fastapi_captcha/static/description/index.html index 36ac1639d..1a9316e26 100644 --- a/fastapi_captcha/static/description/index.html +++ b/fastapi_captcha/static/description/index.html @@ -369,7 +369,7 @@

    Fastapi Captcha

    !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !! source digest: sha256:a507efff5b29eb67557d6283c396db18daddc1e48115ede431daff7f686594b6 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> -

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

    +

    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:

    @@ -404,7 +404,7 @@

    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.

    +feedback.

    Do not contact contributors directly about support or help with technical issues.

    @@ -432,7 +432,7 @@

    Maintainers

    promote its widespread use.

    Current maintainer:

    paradoxxxzero

    -

    This module is part of the OCA/rest-framework project on GitHub.

    +

    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/views/fastapi_endpoint_views.xml b/fastapi_captcha/views/fastapi_endpoint_views.xml index 21b6bcde1..424eb15b2 100644 --- a/fastapi_captcha/views/fastapi_endpoint_views.xml +++ b/fastapi_captcha/views/fastapi_endpoint_views.xml @@ -5,8 +5,6 @@ License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). --> - - fastapi.endpoint @@ -15,27 +13,17 @@ - - - + + + - From 5e7444ad99579df6442bb308d8f9c78a6db1ef58 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Mon, 16 Mar 2026 10:30:07 +0100 Subject: [PATCH 6/6] [MIG] fastapi_captcha_altcha_backend --- fastapi_captcha_altcha_backend/README.rst | 10 +++++----- fastapi_captcha_altcha_backend/__manifest__.py | 3 +-- .../models/fastapi_endpoint.py | 9 +++++---- fastapi_captcha_altcha_backend/pyproject.toml | 3 +++ .../static/description/index.html | 6 +++--- 5 files changed, 17 insertions(+), 14 deletions(-) create mode 100644 fastapi_captcha_altcha_backend/pyproject.toml diff --git a/fastapi_captcha_altcha_backend/README.rst b/fastapi_captcha_altcha_backend/README.rst index 43a6f9f0a..3c01964a9 100644 --- a/fastapi_captcha_altcha_backend/README.rst +++ b/fastapi_captcha_altcha_backend/README.rst @@ -17,13 +17,13 @@ Fastapi Captcha Altcha Backend :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_captcha_altcha_backend + :target: https://github.com/OCA/rest-framework/tree/18.0/fastapi_captcha_altcha_backend :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_captcha_altcha_backend + :target: https://translation.odoo-community.org/projects/rest-framework-18-0/rest-framework-18-0-fastapi_captcha_altcha_backend :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 + :target: https://runboat.odoo-community.org/builds?repo=OCA/rest-framework&target_branch=18.0 :alt: Try me on Runboat |badge1| |badge2| |badge3| |badge4| |badge5| @@ -50,7 +50,7 @@ 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 `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -88,6 +88,6 @@ Current `maintainer `__: |maintainer-paradoxxxzero| -This module is part of the `OCA/rest-framework `_ project on GitHub. +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_altcha_backend/__manifest__.py b/fastapi_captcha_altcha_backend/__manifest__.py index f114feecf..165f04864 100644 --- a/fastapi_captcha_altcha_backend/__manifest__.py +++ b/fastapi_captcha_altcha_backend/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Fastapi Captcha Altcha Backend", - "version": "16.0.1.0.0", + "version": "18.0.1.0.0", "author": "Akretion, Odoo Community Association (OCA)", "summary": "Implement Altcha server in FastAPI", "category": "Tools", @@ -12,7 +12,6 @@ "website": "https://github.com/OCA/rest-framework", "data": [], "maintainers": ["paradoxxxzero"], - "demo": [], "installable": True, "license": "AGPL-3", "external_dependencies": { diff --git a/fastapi_captcha_altcha_backend/models/fastapi_endpoint.py b/fastapi_captcha_altcha_backend/models/fastapi_endpoint.py index b567d5bf5..902af8c4b 100644 --- a/fastapi_captcha_altcha_backend/models/fastapi_endpoint.py +++ b/fastapi_captcha_altcha_backend/models/fastapi_endpoint.py @@ -2,7 +2,7 @@ # @author Florian Mounier # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo import _, fields, models +from odoo import fields, models from odoo.exceptions import AccessError, UserError, ValidationError try: @@ -27,7 +27,7 @@ def validate_captcha(self, captcha_response): secret_key = self.captcha_secret_key if self.captcha_type == "altcha_local": if not altcha: - raise UserError(_("Altcha library is not installed.")) + raise UserError(self.env._("Altcha library is not installed.")) return self._validate_altcha_local(captcha_response, secret_key) def _validate_altcha_local(self, captcha_response, secret_key): @@ -38,11 +38,12 @@ def _validate_altcha_local(self, captcha_response, secret_key): verified, err = verify_solution(captcha_response, secret_key, True) if not verified: raise AccessError( - _("Altcha validation failed: %(error)s") % {"error": err} + self.env._("Altcha validation failed: %(error)s") % {"error": err} ) return except Exception as e: raise ValidationError( - _("Failed to process Altcha payload: %(error)s") % {"error": str(e)} + self.env._("Failed to process Altcha payload: %(error)s") + % {"error": str(e)} ) from e diff --git a/fastapi_captcha_altcha_backend/pyproject.toml b/fastapi_captcha_altcha_backend/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/fastapi_captcha_altcha_backend/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/fastapi_captcha_altcha_backend/static/description/index.html b/fastapi_captcha_altcha_backend/static/description/index.html index 44dba5cb5..cbf88c963 100644 --- a/fastapi_captcha_altcha_backend/static/description/index.html +++ b/fastapi_captcha_altcha_backend/static/description/index.html @@ -369,7 +369,7 @@

    Fastapi Captcha Altcha Backend

    !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !! source digest: sha256:e97f2c1f5989e99007440eecfb45f75fb664da90312dfd68b8a61d8a321305c1 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> -

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

    +

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

    This module adds Altcha service as a FastApi router and add local Altcha verification as a captcha method.

    Table of contents

    @@ -397,7 +397,7 @@

    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.

    +feedback.

    Do not contact contributors directly about support or help with technical issues.

    @@ -425,7 +425,7 @@

    Maintainers

    promote its widespread use.

    Current maintainer:

    paradoxxxzero

    -

    This module is part of the OCA/rest-framework project on GitHub.

    +

    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.