Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# generated from manifests external_dependencies
altcha
127 changes: 127 additions & 0 deletions website_altcha/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
========================
Privacy Friendly Captcha
========================

..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:ad4756a7f15fb03bdbfe14d8f738109df9bc20d0af3d9c500b00fa14eb8aa46b
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

.. |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%2Fwebsite-lightgray.png?logo=github
:target: https://github.com/OCA/website/tree/18.0/website_altcha
:alt: OCA/website
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/website-18-0/website-18-0-website_altcha
: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/website&target_branch=18.0
:alt: Try me on Runboat

|badge1| |badge2| |badge3| |badge4| |badge5|

This module allows to use a Captcha System completly handled by Odoo.

It relies on Altcha (https://altcha.org), an OpenSource captcha
alternative.

**Table of contents**

.. contents::
:local:

Use Cases / Context
===================

Currently, Odoo provides 2 options:

- Google Recaptcha relies on tracking of the user. It implies cookies
- Cloudfare Turnstile relies on signals of the browser so it is less
GDPR problematic. However, it relies on a third party infrastructure.
The decision is made from a probabilistic perspective (likely a human)

With this new module, everything relies on our own system with no
cookies, no tracking and no network calls.

The way to solve it is to add a deterministic puzzle to solve. Bots need
to spend more CPU, making it costly at scale.

Configuration
=============

Go to Configuration/Website, check "Enable Altcha" under "Privacy". Some
extra parameters will appear with all the ALTCHA information. This
parameters are website dependent.

- ``altcha_key``: This key is made to create the challenge and review it
later

Also, the system adds the option to use some extra parameters:

- ``altcha_secret_key``: Key used to use deterministic mode. Using it
will make it faster from a server perspective.
- ``altcha_algorithm``: Algorithm used, by default ``PBKDF2/SHA-512``,
however, we can use:

- Fast ones only for testing purposes: ``SHA-256``, ``SHA-384``,
``SHA-512``
- Good by default: ``PBKDF2/SHA-256``, ``PBKDF2/SHA-384``,
``PBKDF2/SHA-512``
- Memory Hard: ``SCRYPT``. To be implemented
- Memory Hard (it required argon2-cffi): ``ARGON2ID``. To be
implemented

- ``altcha_timeout``: Number of minutes that we will trust the key, by
default 5
- ``altcha_cost``: Cost of the challenge. By default, 5000

Bug Tracker
===========

Bugs are tracked on `GitHub Issues <https://github.com/OCA/website/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 <https://github.com/OCA/website/issues/new?body=module:%20website_altcha%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.

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

Credits
=======

Authors
-------

* Dixmit

Contributors
------------

- `Dixmit <https://www.dixmit.com>`__

- Enric Tobella
- Luís David Rodríguez

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.

This module is part of the `OCA/website <https://github.com/OCA/website/tree/18.0/website_altcha>`_ project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
2 changes: 2 additions & 0 deletions website_altcha/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from . import controllers
from . import models
34 changes: 34 additions & 0 deletions website_altcha/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Copyright 2025 Dixmit
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

{
"name": "Privacy Friendly Captcha",
"summary": """Use self hosted privacy friendly captcha for
verifying website users are not bots""",
"version": "18.0.1.0.0",
"license": "AGPL-3",
"author": "Dixmit,Odoo Community Association (OCA)",
"website": "https://github.com/OCA/website",
"depends": [
"website",
],
"assets": {
"web.assets_frontend": [
"website_altcha/static/src/**/*.esm.js",
"website_altcha/static/src/**/*.xml",
"website_altcha/static/src/**/*.scss",
],
"web.altcha_libs": [
"website_altcha/static/lib/altcha.js",
"website_altcha/static/lib/altcha-l10n.js",
"website_altcha/static/lib/altcha.css",
],
},
"external_dependencies": {
"python": ["altcha"],
},
"data": [
"security/ir.model.access.csv",
"views/res_config_settings_views.xml",
],
}
1 change: 1 addition & 0 deletions website_altcha/controllers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import altcha
39 changes: 39 additions & 0 deletions website_altcha/controllers/altcha.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import json
import secrets
from datetime import timedelta

import altcha.v2 as altcha

from odoo import fields, http
from odoo.http import request


class AltchaController(http.Controller):
@http.route("/altcha", type="http", auth="public", website=True)
def generate_altcha_challenge(self):
timeout = int(request.website.sudo().altcha_timeout or 5)
cost = int(request.website.sudo().altcha_cost or 1_000)
algorithm = request.website.sudo().altcha_algorithm or "PBKDF2/SHA-512"
expires = fields.Datetime.now() + timedelta(minutes=timeout)
key = request.website.sudo().altcha_key
secret_key = request.website.sudo().altcha_private_key
parameters = {
"algorithm": algorithm,
"cost": cost,
"hmac_secret": key,
"expires_at": expires,
}
if secret_key:
parameters["hmac_key_secret"] = secret_key
parameters["counter"] = secrets.randbelow(cost) + cost
challenge = altcha.create_challenge(**parameters)
request.env["altcha.key"].sudo().create(
{
"key": request.env["ir.http"]._get_altcha_key(challenge.signature),
"expires_at": expires,
}
)
return request.make_response(
json.dumps(challenge.to_dict()),
headers=[("Content-Type", "application/json")],
)
4 changes: 4 additions & 0 deletions website_altcha/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from . import ir_http
from . import altcha_key
from . import website
from . import res_config_settings
24 changes: 24 additions & 0 deletions website_altcha/models/altcha_key.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Copyright 2025 Dixmit
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

from odoo import api, fields, models


class AltchaKey(models.Model):
_name = "altcha.key"
_description = "Altcha Key"

key = fields.Char(required=True)
expires_at = fields.Datetime(required=True)
used = fields.Boolean(default=False)

_sql_constraints = [
("key_uniq", "unique(key)", "The Altcha key must be unique."),
]

@api.autovacuum
def _autovacuum_expired_keys(self):
"""Delete expired Altcha keys."""
now = fields.Datetime.now()
expired_keys = self.search([("expires_at", "<", now)])
expired_keys.unlink()
90 changes: 90 additions & 0 deletions website_altcha/models/ir_http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Copyright 2025 Dixmit
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

import base64
import hashlib
import hmac
import json
import logging

import altcha.v2 as altcha

from odoo import _, api, models
from odoo.exceptions import UserError
from odoo.http import request

_logger = logging.getLogger(__name__)


class IrHttp(models.AbstractModel):
_inherit = "ir.http"

@api.model
def get_frontend_session_info(self):
frontend_session_info = super().get_frontend_session_info()
return self._add_altcha_public_key_to_session_info(frontend_session_info)

@api.model
def _add_altcha_public_key_to_session_info(self, session_info):
"""Add the Altcha public key to the given session_info object"""
session_info["altcha_public_key"] = bool(request.website.sudo().altcha_key)
return session_info

@api.model
def _get_altcha_key(self, signature):
return hmac.new(
signature.encode("utf-8"),
request.httprequest.remote_addr.encode("utf-8"),
digestmod=hashlib.sha256,
).hexdigest()

@api.model
def _verify_request_recaptcha_token(self, action):
result = super()._verify_request_recaptcha_token(action)
if not result:
return result
if not request.website.sudo().altcha_key:
return result
altcha_signature = request.params.get("altcha")
if not altcha_signature:
_logger.warning("Altcha token missing in request")
raise UserError(_("Suspicious activity detected by Altcha"))
result = altcha.verify_solution(
altcha_signature,
hmac_secret=request.website.sudo().altcha_key,
hmac_key_secret=request.website.sudo().altcha_private_key,
)
is_valid = result.verified
if not is_valid:
error = []
if result.invalid_signature:
error.append("Invalid Signature")
if result.invalid_solution:
error.append("Invalid Solution")
if result.expired:
error.append("Expired")
if result.error:
error.append(result.error)
_logger.warning(
"""Altcha verification failed: %s""",
", ".join(error),
)
raise UserError(_("Suspicious activity detected by Altcha"))
altcha_request = json.loads(base64.b64decode(altcha_signature))
unique_id = self._get_altcha_key(altcha_request["challenge"]["signature"])
record = (
self.env["altcha.key"]
.sudo()
.search(
[
("key", "=", unique_id),
("used", "=", False),
],
limit=1,
)
)
if not record:
_logger.warning(f"Altcha key not found or already used: {unique_id}")
raise UserError(_("Suspicious activity detected by Altcha"))
record.used = True
return result
52 changes: 52 additions & 0 deletions website_altcha/models/res_config_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Copyright 2025 Dixmit
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

import uuid

from odoo import api, fields, models


class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"

altcha_enabled = fields.Boolean(
string="Enable Altcha",
help="Enable Altcha functionality on the website.",
compute="_compute_altcha_enabled",
readonly=False,
)
altcha_key = fields.Char(
related="website_id.altcha_key",
readonly=False,
)
altcha_private_key = fields.Char(
related="website_id.altcha_private_key",
readonly=False,
)
altcha_algorithm = fields.Selection(
related="website_id.altcha_algorithm",
readonly=False,
)
altcha_timeout = fields.Integer(
related="website_id.altcha_timeout",
readonly=False,
)
altcha_cost = fields.Integer(
related="website_id.altcha_cost",
readonly=False,
)

@api.depends("altcha_key")
def _compute_altcha_enabled(self):
for record in self:
record.altcha_enabled = bool(record.altcha_key)

@api.onchange("altcha_enabled")
def _onchange_altcha_enabled(self):
for record in self:
if not record.altcha_enabled:
record.altcha_key = False
record.altcha_private_key = False
else:
record.altcha_key = str(uuid.uuid4())
record.altcha_private_key = str(uuid.uuid4())
Loading
Loading