Skip to content

Commit 5761a8e

Browse files
committed
Merge PR #600 into 18.0
Signed-off-by sebastienbeau
2 parents 056b002 + 45e2554 commit 5761a8e

33 files changed

Lines changed: 2065 additions & 0 deletions

fastapi_captcha/README.rst

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
===============
2+
Fastapi Captcha
3+
===============
4+
5+
..
6+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
7+
!! This file is generated by oca-gen-addon-readme !!
8+
!! changes will be overwritten. !!
9+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
10+
!! source digest: sha256:a507efff5b29eb67557d6283c396db18daddc1e48115ede431daff7f686594b6
11+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
12+
13+
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
14+
:target: https://odoo-community.org/page/development-status
15+
:alt: Beta
16+
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
17+
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
18+
:alt: License: AGPL-3
19+
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Frest--framework-lightgray.png?logo=github
20+
:target: https://github.com/OCA/rest-framework/tree/18.0/fastapi_captcha
21+
:alt: OCA/rest-framework
22+
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
23+
:target: https://translation.odoo-community.org/projects/rest-framework-18-0/rest-framework-18-0-fastapi_captcha
24+
:alt: Translate me on Weblate
25+
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
26+
:target: https://runboat.odoo-community.org/builds?repo=OCA/rest-framework&target_branch=18.0
27+
:alt: Try me on Runboat
28+
29+
|badge1| |badge2| |badge3| |badge4| |badge5|
30+
31+
This module provides a simple way to protect several fastapi endpoints
32+
routes with a captcha.
33+
34+
It currently supports the following captcha providers:
35+
36+
- `Google reCAPTCHA <https://www.google.com/recaptcha>`__
37+
- `hCaptcha <https://www.hcaptcha.com/>`__
38+
- `Altcha <https://altcha.org/>`__
39+
40+
**Table of contents**
41+
42+
.. contents::
43+
:local:
44+
45+
Usage
46+
=====
47+
48+
Check the ``Use Captcha`` checkbox in your FastAPI endpoint to enable
49+
captcha validation, then enter your captcha provider, secret key and an
50+
array of route url regex.
51+
52+
Every matching route will now require a valid captcha token in the
53+
X-Captcha-Token header.
54+
55+
Bug Tracker
56+
===========
57+
58+
Bugs are tracked on `GitHub Issues <https://github.com/OCA/rest-framework/issues>`_.
59+
In case of trouble, please check there if your issue has already been reported.
60+
If you spotted it first, help us to smash it by providing a detailed and welcomed
61+
`feedback <https://github.com/OCA/rest-framework/issues/new?body=module:%20fastapi_captcha%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
62+
63+
Do not contact contributors directly about support or help with technical issues.
64+
65+
Credits
66+
=======
67+
68+
Authors
69+
-------
70+
71+
* Akretion
72+
73+
Contributors
74+
------------
75+
76+
- Florian Mounier florian.mounier@akretion.com
77+
78+
Maintainers
79+
-----------
80+
81+
This module is maintained by the OCA.
82+
83+
.. image:: https://odoo-community.org/logo.png
84+
:alt: Odoo Community Association
85+
:target: https://odoo-community.org
86+
87+
OCA, or the Odoo Community Association, is a nonprofit organization whose
88+
mission is to support the collaborative development of Odoo features and
89+
promote its widespread use.
90+
91+
.. |maintainer-paradoxxxzero| image:: https://github.com/paradoxxxzero.png?size=40px
92+
:target: https://github.com/paradoxxxzero
93+
:alt: paradoxxxzero
94+
95+
Current `maintainer <https://odoo-community.org/page/maintainer-role>`__:
96+
97+
|maintainer-paradoxxxzero|
98+
99+
This module is part of the `OCA/rest-framework <https://github.com/OCA/rest-framework/tree/18.0/fastapi_captcha>`_ project on GitHub.
100+
101+
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

fastapi_captcha/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import models

fastapi_captcha/__manifest__.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Copyright 2025 Akretion (http://www.akretion.com).
2+
# @author Florian Mounier <florian.mounier@akretion.com>
3+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
4+
5+
{
6+
"name": "Fastapi Captcha",
7+
"version": "18.0.1.0.0",
8+
"author": "Akretion, Odoo Community Association (OCA)",
9+
"summary": "Add a captcha to your FastAPI routes",
10+
"category": "Tools",
11+
"depends": ["fastapi"],
12+
"website": "https://github.com/OCA/rest-framework",
13+
"data": [
14+
"views/fastapi_endpoint_views.xml",
15+
],
16+
"maintainers": ["paradoxxxzero"],
17+
"installable": True,
18+
"license": "AGPL-3",
19+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Copyright 2025 Akretion (http://www.akretion.com).
2+
# @author Florian Mounier <florian.mounier@akretion.com>
3+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
4+
5+
from starlette.middleware.base import BaseHTTPMiddleware
6+
7+
from odoo.exceptions import AccessError
8+
9+
from odoo.addons.fastapi.context import odoo_env_ctx
10+
11+
12+
class CaptchaMiddleware(BaseHTTPMiddleware):
13+
def __init__(self, app, endpoint_id, root_path, routes_regex=None):
14+
super().__init__(app)
15+
self.endpoint_id = endpoint_id
16+
self.root_path = root_path
17+
self.routes_regex = routes_regex
18+
19+
async def dispatch(self, request, call_next):
20+
url = request.url.path.replace(self.root_path, "", 1)
21+
if self.routes_regex and not any(
22+
rex.fullmatch(url) for rex in self.routes_regex
23+
):
24+
return await call_next(request)
25+
26+
env = odoo_env_ctx.get()
27+
endpoint = env["fastapi.endpoint"].sudo().browse(self.endpoint_id)
28+
token = request.headers.get("X-Captcha-Token")
29+
if not token:
30+
raise AccessError(
31+
env._("Captcha token not found in headers"),
32+
)
33+
try:
34+
endpoint.validate_captcha(token)
35+
except AccessError as e:
36+
raise e
37+
except OSError as e:
38+
raise AccessError(
39+
env._("Captcha validation failed: %s") % str(e),
40+
) from e
41+
response = await call_next(request)
42+
return response

fastapi_captcha/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import fastapi_endpoint
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
# Copyright 2025 Akretion (http://www.akretion.com).
2+
# @author Florian Mounier <florian.mounier@akretion.com>
3+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
4+
5+
import re
6+
from typing import Annotated
7+
8+
import requests
9+
from starlette.middleware import Middleware
10+
11+
from odoo import api, fields, models
12+
from odoo.exceptions import AccessError, UserError, ValidationError
13+
14+
from fastapi import Depends, Header
15+
16+
from ..captcha_middleware import CaptchaMiddleware
17+
18+
19+
class FastapiEndpoint(models.Model):
20+
_inherit = "fastapi.endpoint"
21+
22+
use_captcha = fields.Boolean(
23+
help="If checked, this endpoint will be protected by a captcha",
24+
)
25+
26+
captcha_type = fields.Selection(
27+
[
28+
("recaptcha", "Recaptcha"),
29+
("hcaptcha", "Hcaptcha"),
30+
("altcha", "Altcha"),
31+
],
32+
help="Type of captcha to use for this endpoint",
33+
)
34+
35+
captcha_secret_key = fields.Char(
36+
help="Secret key to use for the captcha validation",
37+
groups="base.group_system",
38+
)
39+
40+
captcha_routes_regex = fields.Char(
41+
help="Regexes to match against routes url that should be protected "
42+
"by this captcha, comma separated. If empty, all routes will be protected",
43+
)
44+
45+
captcha_minimum_score = fields.Float(
46+
default=0.5,
47+
help="Minimum score to accept the captcha if a score is provided by the "
48+
"captcha service.",
49+
)
50+
51+
captcha_custom_verify_url = fields.Char(
52+
help="Custom URL to use for the captcha verification",
53+
)
54+
55+
@property
56+
def _server_env_fields(self):
57+
fields = getattr(super(), "_server_env_fields", None) or {}
58+
fields["captcha_secret_key"] = {}
59+
return fields
60+
61+
@api.constrains("captcha_routes_regex")
62+
def _check_captcha_routes_regex(self):
63+
"""Check that the captcha routes regex is valid"""
64+
for record in self:
65+
if record.captcha_routes_regex:
66+
for rex in record.captcha_routes_regex.split(","):
67+
rex = rex.strip()
68+
if not rex:
69+
continue
70+
# Check that the regex is valid
71+
try:
72+
re.compile(rex)
73+
except re.error as e:
74+
raise ValidationError(
75+
self.env._(
76+
"Invalid regex for captcha routes: %(regex)s "
77+
"(error: %(error)s)"
78+
)
79+
% {
80+
"regex": rex,
81+
"error": str(e),
82+
}
83+
) from e
84+
85+
def _get_fastapi_app_middlewares(self):
86+
# Add the captcha middleware to the list of middlewares if enabled
87+
middlewares = super()._get_fastapi_app_middlewares()
88+
if self.use_captcha:
89+
middlewares.append(
90+
Middleware(
91+
CaptchaMiddleware,
92+
endpoint_id=self.id,
93+
root_path=self.root_path,
94+
routes_regex=[
95+
re.compile(rex) for rex in self.captcha_routes_regex.split(",")
96+
]
97+
if self.captcha_routes_regex
98+
else None,
99+
)
100+
)
101+
return middlewares
102+
103+
def _get_fastapi_app_dependencies(self):
104+
# Add the captcha header to the list of dependencies
105+
dependencies = super()._get_fastapi_app_dependencies()
106+
if self.use_captcha:
107+
dependencies.append(Depends(captcha_token))
108+
109+
return dependencies
110+
111+
def validate_captcha(self, captcha_response):
112+
"""Validate the captcha response."""
113+
secret_key = self.captcha_secret_key
114+
if not secret_key:
115+
raise UserError(self.env._("No secret key found for this endpoint"))
116+
117+
if self.captcha_type == "recaptcha":
118+
return self._validate_recaptcha(captcha_response, secret_key)
119+
elif self.captcha_type == "hcaptcha":
120+
return self._validate_hcaptcha(captcha_response, secret_key)
121+
elif self.captcha_type == "altcha":
122+
return self._validate_altcha(captcha_response, secret_key)
123+
124+
def _validate_recaptcha(self, captcha_response, secret_key):
125+
"""Validate the recaptcha response"""
126+
data = {
127+
"secret": secret_key,
128+
"response": captcha_response,
129+
}
130+
response = requests.post(
131+
"https://www.google.com/recaptcha/api/siteverify",
132+
data=data,
133+
timeout=10,
134+
)
135+
result = response.json()
136+
success = result.get("success", False)
137+
if not success:
138+
error_codes = result.get("error-codes", ["?"])
139+
raise AccessError(
140+
self.env._("Recaptcha validation failed: %s") % ", ".join(error_codes)
141+
)
142+
score = result.get("score", 1)
143+
if score < self.captcha_minimum_score:
144+
raise AccessError(
145+
self.env._(
146+
"Recaptcha validation failed: score %(score)s < %(min_score)s"
147+
)
148+
% {
149+
"score": score,
150+
"min_score": self.captcha_minimum_score,
151+
}
152+
)
153+
154+
def _validate_hcaptcha(self, captcha_response, secret_key):
155+
"""Validate the hcaptcha response"""
156+
157+
data = {
158+
"secret": secret_key,
159+
"response": captcha_response,
160+
}
161+
response = requests.post(
162+
"https://api.hcaptcha.com/siteverify", data=data, timeout=10
163+
)
164+
result = response.json()
165+
success = result.get("success", False)
166+
if not success:
167+
error_codes = result.get("error-codes", ["?"])
168+
raise AccessError(
169+
self.env._("Hcaptcha validation failed: %s") % ", ".join(error_codes)
170+
)
171+
score = result.get("score", 1)
172+
if score < self.captcha_minimum_score:
173+
raise AccessError(
174+
self.env._(
175+
"Hcaptcha validation failed: score %(score)s < %(min_score)s "
176+
"(%(reason)s)"
177+
)
178+
% {
179+
"score": score,
180+
"min_score": self.captcha_minimum_score,
181+
"reason": result.get("score_reason", ""),
182+
}
183+
)
184+
185+
def _validate_altcha(self, captcha_response, secret_key):
186+
"""Validate the altcha response"""
187+
data = {
188+
"apiKey": secret_key,
189+
"payload": captcha_response,
190+
}
191+
url = (
192+
self.captcha_custom_verify_url
193+
or "https://eu.altcha.org/api/v1/challenge/verify"
194+
)
195+
response = requests.post(url, data=data, timeout=10)
196+
result = response.json()
197+
success = result.get("verified", False)
198+
if not success:
199+
error = result.get("error", "?")
200+
raise AccessError(
201+
self.env._("Altcha (%(url)s) validation failed: %(error)s")
202+
% {"url": url, "error": error}
203+
)
204+
205+
@api.model
206+
def _fastapi_app_fields(self):
207+
# We need to reload fastapi app when we change these captcha fields
208+
fields = super()._fastapi_app_fields()
209+
return [
210+
"use_captcha",
211+
"captcha_routes_regex",
212+
] + fields
213+
214+
215+
def captcha_token(
216+
captcha_token: Annotated[
217+
str | None,
218+
Header(
219+
alias="X-Captcha-Token",
220+
description="The X-Captcha-Token header is used to specify the captcha ",
221+
),
222+
] = None,
223+
) -> str:
224+
return captcha_token

fastapi_captcha/pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[build-system]
2+
requires = ["whool"]
3+
build-backend = "whool.buildapi"
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Florian Mounier <florian.mounier@akretion.com>

0 commit comments

Comments
 (0)