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

+
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
+
+
+
+
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.
+
+
+
+
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.
+
+
+
+
+
+
+
+
This module is maintained by the OCA.
+
+
+
+
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:
+

+
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

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 @@
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.
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
+
+
+

+
This module adds Altcha service as a FastApi router and add local Altcha
+verification as a captcha method.
+
Table of contents
+
+
+
+
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.
+
+
+
+
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.
+
+
+
+
+
+
+
+
This module is maintained by the OCA.
+
+
+
+
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:
+

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

+

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 @@
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.
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
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
-

+

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