From 685fc14bd95c6eb4ae82c939b44ae4e6d3b9e3e1 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Fri, 16 May 2025 18:10:48 +0200 Subject: [PATCH 1/5] [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 f36921cca004efeb7de4973460542d809294e637 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Mon, 1 Sep 2025 16:42:38 +0200 Subject: [PATCH 2/5] [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 c6de54050f74ba45d5e88b29383253c25507b8ec Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Tue, 19 Aug 2025 16:18:59 +0200 Subject: [PATCH 3/5] [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 fbc9b8d4bd4c1850414d327f41f6579ec9853b5d Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Tue, 19 Aug 2025 16:22:11 +0200 Subject: [PATCH 4/5] [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 7d14583d3..c106267bc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ # generated from manifests external_dependencies a2wsgi>=1.10.6 +altcha apispec apispec>=4.0.0 cerberus 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 def47195512cdfb9c68c323058f802d741d940ab Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Tue, 14 Apr 2026 18:09:21 +0200 Subject: [PATCH 5/5] [IMP] fastapi_captcha_altcha_backend: Handle altcha v2 challenges --- fastapi_captcha_altcha_backend/README.rst | 9 +- .../__manifest__.py | 2 +- .../models/fastapi_endpoint.py | 21 +-- .../readme/USAGE.md | 8 +- .../routers/altcha.py | 46 ++++-- fastapi_captcha_altcha_backend/schemas.py | 23 --- .../static/description/index.html | 7 +- .../tests/__init__.py | 1 + .../test_fastapi_captcha_altcha_backend.py | 143 ++++++++++++++++++ requirements.txt | 2 +- 10 files changed, 211 insertions(+), 51 deletions(-) delete mode 100644 fastapi_captcha_altcha_backend/schemas.py create mode 100644 fastapi_captcha_altcha_backend/tests/__init__.py create mode 100644 fastapi_captcha_altcha_backend/tests/test_fastapi_captcha_altcha_backend.py diff --git a/fastapi_captcha_altcha_backend/README.rst b/fastapi_captcha_altcha_backend/README.rst index 43a6f9f0a..7ce806404 100644 --- a/fastapi_captcha_altcha_backend/README.rst +++ b/fastapi_captcha_altcha_backend/README.rst @@ -40,8 +40,13 @@ 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 +captcha verification. + +Get the challenge from the /altcha/v2/challenge endpoint if you are +using altcha widget v3+ or from /altcha/v1/challenge if you are still +using altcha widget v2 or less. + +Choose the altcha_local captcha type in your FastAPI endpoint configuration. Bug Tracker diff --git a/fastapi_captcha_altcha_backend/__manifest__.py b/fastapi_captcha_altcha_backend/__manifest__.py index f114feecf..1d95bd613 100644 --- a/fastapi_captcha_altcha_backend/__manifest__.py +++ b/fastapi_captcha_altcha_backend/__manifest__.py @@ -17,7 +17,7 @@ "license": "AGPL-3", "external_dependencies": { "python": [ - "altcha", + "altcha>=2.0.0", ] }, } diff --git a/fastapi_captcha_altcha_backend/models/fastapi_endpoint.py b/fastapi_captcha_altcha_backend/models/fastapi_endpoint.py index b567d5bf5..98699c4a0 100644 --- a/fastapi_captcha_altcha_backend/models/fastapi_endpoint.py +++ b/fastapi_captcha_altcha_backend/models/fastapi_endpoint.py @@ -3,11 +3,11 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). from odoo import _, fields, models -from odoo.exceptions import AccessError, UserError, ValidationError +from odoo.exceptions import AccessError, UserError try: import altcha - from altcha import verify_solution + from altcha import verify_solution, verify_solution_v1 except ImportError: altcha = None @@ -35,14 +35,17 @@ def _validate_altcha_local(self, captcha_response, secret_key): 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} - ) + result = verify_solution(captcha_response, secret_key) + if not result.verified: + # Check using legacy verification for backward compatibility with old challenges + verified, err = verify_solution_v1(captcha_response, secret_key, True) + if not verified: + raise AccessError( + _("Altcha validation failed: %(error)s") + % {"error": "\n--\n".join(e for e in (err, result.error) if e)} + ) - return except Exception as e: - raise ValidationError( + raise AccessError( _("Failed to process Altcha payload: %(error)s") % {"error": str(e)} ) from e diff --git a/fastapi_captcha_altcha_backend/readme/USAGE.md b/fastapi_captcha_altcha_backend/readme/USAGE.md index e4f7a0714..5e2b367a2 100644 --- a/fastapi_captcha_altcha_backend/readme/USAGE.md +++ b/fastapi_captcha_altcha_backend/readme/USAGE.md @@ -1,3 +1,7 @@ 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. +verification. + +Get the challenge from the /altcha/v2/challenge endpoint if you are using altcha widget v3+ +or from /altcha/v1/challenge if you are still using altcha widget v2 or less. + +Choose the altcha_local captcha type in your FastAPI endpoint configuration. diff --git a/fastapi_captcha_altcha_backend/routers/altcha.py b/fastapi_captcha_altcha_backend/routers/altcha.py index 6e2a9309c..ac7052d55 100644 --- a/fastapi_captcha_altcha_backend/routers/altcha.py +++ b/fastapi_captcha_altcha_backend/routers/altcha.py @@ -2,6 +2,7 @@ # @author Florian Mounier # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). import datetime +import secrets from typing import Annotated from odoo import _ @@ -14,19 +15,18 @@ try: import altcha - from altcha import ChallengeOptions, create_challenge + from altcha import create_challenge, create_challenge_v1 except ImportError: altcha = None -from ..schemas import AltchaChallenge altcha_router = APIRouter(tags=["altcha"]) -@altcha_router.get("/altcha/challenge") -def altcha_challenge( +@altcha_router.get("/altcha/v2/challenge") +def altcha_challenge_v2( endpoint: Annotated[FastapiEndpoint, Depends(fastapi_endpoint)], -) -> AltchaChallenge: +) -> dict: if not altcha: raise ValidationError(_("Altcha library is not installed.")) secret_key = endpoint.sudo().captcha_secret_key @@ -35,12 +35,36 @@ def altcha_challenge( try: challenge = create_challenge( - ChallengeOptions( - expires=datetime.datetime.now() + datetime.timedelta(minutes=5), - hmac_key=secret_key, - max_number=50000, - ) + algorithm="PBKDF2/SHA-256", + cost=5000, + counter=secrets.randbelow(5000) + 5000, + hmac_secret=secret_key, + expires_at=datetime.datetime.now() + datetime.timedelta(minutes=5), ) - return AltchaChallenge.from_challenge(challenge) + + return challenge.to_dict() + except Exception as e: + raise AccessDenied(_("Failed to create Altcha challenge.")) from e + + +@altcha_router.get("/altcha/challenge", deprecated=True) +@altcha_router.get("/altcha/v1/challenge") +def altcha_challenge_v1( + endpoint: Annotated[FastapiEndpoint, Depends(fastapi_endpoint)], +) -> dict: + 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_v1( + expires=datetime.datetime.now() + datetime.timedelta(minutes=5), + hmac_key=secret_key, + max_number=50000, + ) + + return challenge.to_dict() 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 deleted file mode 100644 index 8de165f72..000000000 --- a/fastapi_captcha_altcha_backend/schemas.py +++ /dev/null @@ -1,23 +0,0 @@ -# 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 index 44dba5cb5..6ca5aed06 100644 --- a/fastapi_captcha_altcha_backend/static/description/index.html +++ b/fastapi_captcha_altcha_backend/static/description/index.html @@ -388,8 +388,11 @@

    Fastapi Captcha Altcha Backend

    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 +captcha verification.

    +

    Get the challenge from the /altcha/v2/challenge endpoint if you are +using altcha widget v3+ or from /altcha/v1/challenge if you are still +using altcha widget v2 or less.

    +

    Choose the altcha_local captcha type in your FastAPI endpoint configuration.

    diff --git a/fastapi_captcha_altcha_backend/tests/__init__.py b/fastapi_captcha_altcha_backend/tests/__init__.py new file mode 100644 index 000000000..8832eb25b --- /dev/null +++ b/fastapi_captcha_altcha_backend/tests/__init__.py @@ -0,0 +1 @@ +from . import test_fastapi_captcha_altcha_backend diff --git a/fastapi_captcha_altcha_backend/tests/test_fastapi_captcha_altcha_backend.py b/fastapi_captcha_altcha_backend/tests/test_fastapi_captcha_altcha_backend.py new file mode 100644 index 000000000..d2579dd44 --- /dev/null +++ b/fastapi_captcha_altcha_backend/tests/test_fastapi_captcha_altcha_backend.py @@ -0,0 +1,143 @@ +# Copyright 2025 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import base64 +import json + +from altcha import Challenge, Payload, solve_challenge, solve_challenge_v1 + +from odoo.exceptions import AccessError + +from odoo.addons.fastapi.tests.common import FastAPITransactionCase + +from fastapi import status + +from ..routers import altcha_router + + +class FastAPICaptchaAltchaBackend(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 = "altcha_local" + cls.endpoint.captcha_secret_key = "test_secret" + cls.endpoint.captcha_routes_regex = "/demo" + cls.default_fastapi_app = cls.endpoint._get_app() + cls.default_fastapi_app.include_router(altcha_router) + cls.default_fastapi_dependency_overrides = { + k: v + for k, v in cls.default_fastapi_app.dependency_overrides.items() + if k.__name__ != "authenticated_partner_impl" + } + + 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/") + + def test_valid_header_altcha_v1(self): + with self._create_test_client() as test_client: + response = test_client.get("/altcha/v1/challenge") + self.assertEqual(response.status_code, status.HTTP_200_OK, response.text) + challenge = response.json() + self.assertEqual(challenge["algorithm"], "SHA-256") + self.assertIn("challenge", challenge) + self.assertIn("salt", challenge) + self.assertIn("signature", challenge) + self.assertIn("maxNumber", challenge) + + # Solve the v1 challenge using the altcha library + solution = solve_challenge_v1( + challenge=challenge["challenge"], + salt=challenge["salt"], + algorithm=challenge["algorithm"], + max_number=challenge["maxNumber"], + ) + token = base64.b64encode( + json.dumps({**challenge, "number": solution.number}).encode() + ).decode() + + response = test_client.get("/demo/", headers={"X-Captcha-Token": token}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json(), {"Hello": "World"}) + + def test_valid_header_altcha_v2(self): + with self._create_test_client() as test_client: + response = test_client.get("/altcha/v2/challenge") + self.assertEqual(response.status_code, status.HTTP_200_OK, response.text) + challenge = response.json() + self.assertIn("parameters", challenge) + self.assertEqual(challenge["parameters"]["algorithm"], "PBKDF2/SHA-256") + self.assertIn("cost", challenge["parameters"]) + self.assertIn("keyPrefix", challenge["parameters"]) + self.assertIn("salt", challenge["parameters"]) + self.assertIn("nonce", challenge["parameters"]) + self.assertIn("expiresAt", challenge["parameters"]) + self.assertIn("signature", challenge) + + # Solve the v2 challenge using the altcha library + challenge = Challenge.from_dict(challenge) + solution = solve_challenge(challenge) + token = Payload(challenge, solution).to_base64() + + response = test_client.get("/demo/", headers={"X-Captcha-Token": token}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json(), {"Hello": "World"}) + + def test_invalid_header_altcha_v1(self): + with self._create_test_client() as test_client: + with self.assertRaisesRegex( + AccessError, + "Altcha payload expired", + ): + test_client.get( + "/demo/", + headers={ + "X-Captcha-Token": ( + "eyJhbGdvcml0aG0iOiAiU0hBLTI1NiIsICJjaGFsbGVuZ2UiOi" + "AiYjI5OTQ3MjJkYTBlNGViM2VjZWI3ZGNjNGNjNjVlODcyYWI0" + "YTMzYzU4MjFhZjE1NGQ1NGNmMmY5Njg4MWViNiIsICJtYXhOdW" + "1iZXIiOiA1MDAwMCwgInNhbHQiOiAiZjZjOGQzNjZkYWIzOTVj" + "ZjE0ZDVjOTdhP2V4cGlyZXM9MTc3NjE4MTE1NiYiLCAic2lnbm" + "F0dXJlIjogImQ0Y2Q0MzYyZTdkZGE5NmU5ZjJjM2FiNWU4MmIw" + "YTE5YmI1ZmY3MmI0ZWFlOGI0MzJlMmY4YTkyOWE0NzI4OGUiLC" + "AibnVtYmVyIjogMzEwNTF9" + ) + }, + ) + + def test_invalid_header_altcha_v2(self): + with self._create_test_client() as test_client: + with self.assertRaisesRegex( + AccessError, + "Altcha validation failed", + ): + test_client.get( + "/demo/", + headers={ + "X-Captcha-Token": ( + "eyJjaGFsbGVuZ2UiOiB7InBhcmFtZXRlcnMiOiB7ImFsZ29yaX" + "RobSI6ICJQQktERjIvU0hBLTI1NiIsICJjb3N0IjogNTAwMCwg" + "ImtleUxlbmd0aCI6IDMyLCAia2V5UHJlZml4IjogImVhOTdmZm" + "YxYTI2Mjc3MTY5ZjY0YmFiNjkwNzU3MDc2IiwgIm5vbmNlIjog" + "IjM2YmFmMWM1MzRmYjFhNWYyOGE5YTNiMmE0ZjVmMGU1IiwgIn" + "NhbHQiOiAiMGM4Mzg4MWIwNzI4NDFjN2FiMDgyODQwNjAwYWI5" + "OWEiLCAiZXhwaXJlc0F0IjogMTc3NjE4MTM5M30sICJzaWduYX" + "R1cmUiOiAiYjJhM2ZkOTQ2YjBlZTllYzcxODA2OGI2YTYwM2Vl" + "NjAxY2RiYjNiNTg3YzkxZjE2MTUxOTY2MGEwZWFlZGU1MSJ9LC" + "Aic29sdXRpb24iOiB7ImNvdW50ZXIiOiA3MDQxLCAiZGVyaXZl" + "ZEtleSI6ICJlYTk3ZmZmMWEyNjI3NzE2OWY2NGJhYjY5MDc1Nz" + "A3NjMwMGY0NTJiMzgxOTgyOTljOTU1M2UxZDk5NGQyNTBmIiwg" + "InRpbWUiOiA0NzA5LjI1ODgzNTk5OTIwOH19" + ) + }, + ) diff --git a/requirements.txt b/requirements.txt index c106267bc..3b4b8de80 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ # generated from manifests external_dependencies a2wsgi>=1.10.6 -altcha +altcha>=2.0.0 apispec apispec>=4.0.0 cerberus