Skip to content

Commit 7336b2c

Browse files
committed
[ADD] fastapi_captcha
1 parent 01013f6 commit 7336b2c

File tree

15 files changed

+1049
-0
lines changed

15 files changed

+1049
-0
lines changed

fastapi_captcha/README.rst

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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/16.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-16-0/rest-framework-16-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=16.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 curreently 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+
Bug Tracker
53+
===========
54+
55+
Bugs are tracked on `GitHub Issues <https://github.com/OCA/rest-framework/issues>`_.
56+
In case of trouble, please check there if your issue has already been reported.
57+
If you spotted it first, help us to smash it by providing a detailed and welcomed
58+
`feedback <https://github.com/OCA/rest-framework/issues/new?body=module:%20fastapi_captcha%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
59+
60+
Do not contact contributors directly about support or help with technical issues.
61+
62+
Credits
63+
=======
64+
65+
Authors
66+
-------
67+
68+
* Akretion
69+
70+
Contributors
71+
------------
72+
73+
- Florian Mounier florian.mounier@akretion.com
74+
75+
Maintainers
76+
-----------
77+
78+
This module is maintained by the OCA.
79+
80+
.. image:: https://odoo-community.org/logo.png
81+
:alt: Odoo Community Association
82+
:target: https://odoo-community.org
83+
84+
OCA, or the Odoo Community Association, is a nonprofit organization whose
85+
mission is to support the collaborative development of Odoo features and
86+
promote its widespread use.
87+
88+
.. |maintainer-paradoxxxzero| image:: https://github.com/paradoxxxzero.png?size=40px
89+
:target: https://github.com/paradoxxxzero
90+
:alt: paradoxxxzero
91+
92+
Current `maintainer <https://odoo-community.org/page/maintainer-role>`__:
93+
94+
|maintainer-paradoxxxzero|
95+
96+
This module is part of the `OCA/rest-framework <https://github.com/OCA/rest-framework/tree/16.0/fastapi_captcha>`_ project on GitHub.
97+
98+
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: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
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": "16.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+
"demo": [],
18+
"installable": True,
19+
"license": "AGPL-3",
20+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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 import _
8+
from odoo.exceptions import AccessError
9+
10+
from odoo.addons.fastapi.context import odoo_env_ctx
11+
12+
13+
class CaptchaMiddleware(BaseHTTPMiddleware):
14+
def __init__(self, app, endpoint_id, root_path, routes_regex=None):
15+
super().__init__(app)
16+
self.endpoint_id = endpoint_id
17+
self.root_path = root_path
18+
self.routes_regex = routes_regex
19+
20+
async def dispatch(self, request, call_next):
21+
url = request.url.path.replace(self.root_path, "", 1)
22+
if self.routes_regex and not any(
23+
rex.fullmatch(url) for rex in self.routes_regex
24+
):
25+
return await call_next(request)
26+
27+
env = odoo_env_ctx.get()
28+
endpoint = env["fastapi.endpoint"].sudo().browse(self.endpoint_id)
29+
token = request.headers.get("X-Captcha-Token")
30+
if not token:
31+
raise AccessError(
32+
_("Captcha token not found in headers"),
33+
)
34+
endpoint.validate_captcha(token)
35+
response = await call_next(request)
36+
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: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
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+
@property
52+
def _server_env_fields(self):
53+
fields = getattr(super(), "_server_env_fields", None) or {}
54+
fields["captcha_secret_key"] = {}
55+
return fields
56+
57+
@api.constrains("captcha_routes_regex")
58+
def _check_captcha_routes_regex(self):
59+
"""Check that the captcha routes regex is valid"""
60+
for record in self:
61+
if record.captcha_routes_regex:
62+
for rex in record.captcha_routes_regex.split(","):
63+
rex = rex.strip()
64+
if not rex:
65+
continue
66+
# Check that the regex is valid
67+
try:
68+
re.compile(rex)
69+
except re.error as e:
70+
raise ValidationError(
71+
_(
72+
"Invalid regex for captcha routes: %(regex)s (error: %(error)s)"
73+
)
74+
% {
75+
"regex": rex,
76+
"error": str(e),
77+
}
78+
) from e
79+
80+
def _get_fastapi_app_middlewares(self):
81+
# Add the captcha middleware to the list of middlewares if enabled
82+
middlewares = super()._get_fastapi_app_middlewares()
83+
if self.use_captcha:
84+
middlewares.append(
85+
Middleware(
86+
CaptchaMiddleware,
87+
endpoint_id=self.id,
88+
root_path=self.root_path,
89+
routes_regex=[
90+
re.compile(rex) for rex in self.captcha_routes_regex.split(",")
91+
]
92+
if self.captcha_routes_regex
93+
else None,
94+
)
95+
)
96+
return middlewares
97+
98+
def _get_fastapi_app_dependencies(self):
99+
# Add the captcha header to the list of dependencies
100+
dependencies = super()._get_fastapi_app_dependencies()
101+
if self.use_captcha:
102+
dependencies.append(Depends(captcha_token))
103+
104+
return dependencies
105+
106+
def validate_captcha(self, captcha_response):
107+
"""Validate the captcha response."""
108+
secret_key = self.captcha_secret_key
109+
if not secret_key:
110+
raise UserError(_("No secret key found for this endpoint"))
111+
112+
if self.captcha_type == "recaptcha":
113+
return self._validate_recaptcha(captcha_response, secret_key)
114+
elif self.captcha_type == "hcaptcha":
115+
return self._validate_hcaptcha(captcha_response, secret_key)
116+
elif self.captcha_type == "altcha":
117+
return self._validate_altcha(captcha_response, secret_key)
118+
119+
def _validate_recaptcha(self, captcha_response, secret_key):
120+
"""Validate the recaptcha response"""
121+
data = {
122+
"secret": secret_key,
123+
"response": captcha_response,
124+
}
125+
response = requests.post(
126+
"https://www.google.com/recaptcha/api/siteverify",
127+
data=data,
128+
timeout=10,
129+
)
130+
result = response.json()
131+
success = result.get("success", False)
132+
if not success:
133+
error_codes = result.get("error-codes", ["?"])
134+
raise AccessError(
135+
_("Recaptcha validation failed: %s") % ", ".join(error_codes)
136+
)
137+
score = result.get("score", 1)
138+
if score < self.captcha_minimum_score:
139+
raise AccessError(
140+
_("Recaptcha validation failed: score %(score)s < %(min_score)s")
141+
% {
142+
"score": score,
143+
"min_score": self.captcha_minimum_score,
144+
}
145+
)
146+
147+
def _validate_hcaptcha(self, captcha_response, secret_key):
148+
"""Validate the hcaptcha response"""
149+
150+
data = {
151+
"secret": secret_key,
152+
"response": captcha_response,
153+
}
154+
response = requests.post(
155+
"https://api.hcaptcha.com/siteverify", data=data, timeout=10
156+
)
157+
result = response.json()
158+
success = result.get("success", False)
159+
if not success:
160+
error_codes = result.get("error-codes", ["?"])
161+
raise AccessError(
162+
_("Hcaptcha validation failed: %s") % ", ".join(error_codes)
163+
)
164+
score = result.get("score", 1)
165+
if score < self.captcha_minimum_score:
166+
raise AccessError(
167+
_(
168+
"Hcaptcha validation failed: score %(score)s < %(min_score)s (%(reason)s)"
169+
)
170+
% {
171+
"score": score,
172+
"min_score": self.captcha_minimum_score,
173+
"reason": result.get("score_reason", ""),
174+
}
175+
)
176+
177+
def _validate_altcha(self, captcha_response, secret_key):
178+
"""Validate the altcha response"""
179+
data = {
180+
"apiKey": secret_key,
181+
"payload": captcha_response,
182+
}
183+
response = requests.post(
184+
"https://eu.altcha.org/api/v1/challenge/verify",
185+
data=data,
186+
timeout=10,
187+
)
188+
result = response.json()
189+
success = result.get("verified", False)
190+
if not success:
191+
error = result.get("error", "?")
192+
raise AccessError(_("Altcha validation failed: %s") % error)
193+
194+
@api.model
195+
def _fastapi_app_fields(self):
196+
# We need to reload fastapi app when we change these captcha fields
197+
fields = super()._fastapi_app_fields()
198+
return [
199+
"use_captcha",
200+
"captcha_routes_regex",
201+
] + fields
202+
203+
204+
def captcha_token(
205+
captcha_token: Annotated[
206+
str | None,
207+
Header(
208+
alias="X-Captcha-Token",
209+
description="The X-Captcha-Token header is used to specify the captcha ",
210+
),
211+
] = None,
212+
) -> str:
213+
return captcha_token
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Florian Mounier <florian.mounier@akretion.com>
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
This module provides a simple way to protect several fastapi endpoints routes with a
2+
captcha.
3+
4+
It curreently supports the following captcha providers:
5+
6+
- [Google reCAPTCHA](https://www.google.com/recaptcha)
7+
- [hCaptcha](https://www.hcaptcha.com/)
8+
- [Altcha](https://altcha.org/)

fastapi_captcha/readme/USAGE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Check the `Use Captcha` checkbox in your FastAPI endpoint to enable captcha validation,
2+
then enter your captcha provider, secret key and an array of route url regex.

0 commit comments

Comments
 (0)