diff --git a/fastapi_captcha/README.rst b/fastapi_captcha/README.rst new file mode 100644 index 000000000..e2ebfe7b3 --- /dev/null +++ b/fastapi_captcha/README.rst @@ -0,0 +1,101 @@ +=============== +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 currently supports the following captcha providers: + +- `Google reCAPTCHA `__ +- `hCaptcha `__ +- `Altcha `__ + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Check the ``Use Captcha`` checkbox in your FastAPI endpoint to enable +captcha validation, then enter your captcha provider, secret key and an +array of route url regex. + +Every matching route will now require a valid captcha token in the +X-Captcha-Token header. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Akretion + +Contributors +------------ + +- Florian Mounier florian.mounier@akretion.com + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-paradoxxxzero| image:: https://github.com/paradoxxxzero.png?size=40px + :target: https://github.com/paradoxxxzero + :alt: paradoxxxzero + +Current `maintainer `__: + +|maintainer-paradoxxxzero| + +This module is part of the `OCA/rest-framework `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/fastapi_captcha/__init__.py b/fastapi_captcha/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/fastapi_captcha/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/fastapi_captcha/__manifest__.py b/fastapi_captcha/__manifest__.py new file mode 100644 index 000000000..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..2120b1644 --- /dev/null +++ b/fastapi_captcha/captcha_middleware.py @@ -0,0 +1,43 @@ +# 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"), + ) + 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/__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..7797fca05 --- /dev/null +++ b/fastapi_captcha/models/fastapi_endpoint.py @@ -0,0 +1,220 @@ +# Copyright 2025 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import re +from typing import Annotated + +import requests +from starlette.middleware import Middleware + +from odoo import _, api, fields, models +from odoo.exceptions import AccessError, UserError, ValidationError + +from fastapi import Depends, Header + +from ..captcha_middleware import CaptchaMiddleware + + +class FastapiEndpoint(models.Model): + _inherit = "fastapi.endpoint" + + use_captcha = fields.Boolean( + help="If checked, this endpoint will be protected by a captcha", + ) + + captcha_type = fields.Selection( + [ + ("recaptcha", "Recaptcha"), + ("hcaptcha", "Hcaptcha"), + ("altcha", "Altcha"), + ], + help="Type of captcha to use for this endpoint", + ) + + captcha_secret_key = fields.Char( + help="Secret key to use for the captcha validation", + groups="base.group_system", + ) + + captcha_routes_regex = fields.Char( + help="Regexes to match against routes url that should be protected " + "by this captcha, comma separated. If empty, all routes will be protected", + ) + + captcha_minimum_score = fields.Float( + default=0.5, + help="Minimum score to accept the captcha if a score is provided by the " + "captcha service.", + ) + + captcha_custom_verify_url = fields.Char( + help="Custom URL to use for the captcha verification", + ) + + @property + def _server_env_fields(self): + fields = getattr(super(), "_server_env_fields", None) or {} + fields["captcha_secret_key"] = {} + return fields + + @api.constrains("captcha_routes_regex") + def _check_captcha_routes_regex(self): + """Check that the captcha routes regex is valid""" + for record in self: + if record.captcha_routes_regex: + for rex in record.captcha_routes_regex.split(","): + rex = rex.strip() + if not rex: + continue + # Check that the regex is valid + try: + re.compile(rex) + except re.error as e: + raise ValidationError( + _( + "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, + } + 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 (%(url)s) validation failed: %(error)s") + % {"url": url, "error": error} + ) + + @api.model + def _fastapi_app_fields(self): + # We need to reload fastapi app when we change these captcha fields + fields = super()._fastapi_app_fields() + return [ + "use_captcha", + "captcha_routes_regex", + ] + fields + + +def captcha_token( + captcha_token: Annotated[ + str | None, + Header( + alias="X-Captcha-Token", + description="The X-Captcha-Token header is used to specify the captcha ", + ), + ] = None, +) -> str: + return captcha_token diff --git a/fastapi_captcha/readme/CONTRIBUTORS.md b/fastapi_captcha/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..328a37da8 --- /dev/null +++ b/fastapi_captcha/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Florian Mounier diff --git a/fastapi_captcha/readme/DESCRIPTION.md b/fastapi_captcha/readme/DESCRIPTION.md new file mode 100644 index 000000000..743b4e136 --- /dev/null +++ b/fastapi_captcha/readme/DESCRIPTION.md @@ -0,0 +1,8 @@ +This module provides a simple way to protect several fastapi endpoints routes with a +captcha. + +It currently supports the following captcha providers: + +- [Google reCAPTCHA](https://www.google.com/recaptcha) +- [hCaptcha](https://www.hcaptcha.com/) +- [Altcha](https://altcha.org/) diff --git a/fastapi_captcha/readme/USAGE.md b/fastapi_captcha/readme/USAGE.md new file mode 100644 index 000000000..0967b20d8 --- /dev/null +++ b/fastapi_captcha/readme/USAGE.md @@ -0,0 +1,5 @@ +Check the `Use Captcha` checkbox in your FastAPI endpoint to enable captcha validation, +then enter your captcha provider, secret key and an array of route url regex. + +Every matching route will now require a valid captcha token in the X-Captcha-Token +header. diff --git a/fastapi_captcha/static/description/index.html b/fastapi_captcha/static/description/index.html new file mode 100644 index 000000000..36ac1639d --- /dev/null +++ b/fastapi_captcha/static/description/index.html @@ -0,0 +1,441 @@ + + + + + +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 currently supports the following captcha providers:

+ +

Table of contents

+ +
+

Usage

+

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

+

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

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Akretion
  • +
+
+ +
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

paradoxxxzero

+

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

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

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