diff --git a/addons/base_setup/models/res_config_settings.py b/addons/base_setup/models/res_config_settings.py index 3a42786af3c..068ea21e437 100644 --- a/addons/base_setup/models/res_config_settings.py +++ b/addons/base_setup/models/res_config_settings.py @@ -33,6 +33,7 @@ class ResConfigSettings(models.TransientModel): module_partner_autocomplete = fields.Boolean("Partner Autocomplete") module_base_geolocalize = fields.Boolean("GeoLocalize") module_google_recaptcha = fields.Boolean("reCAPTCHA") + module_website_cf_turnstile = fields.Boolean("Cloudflare Turnstile") report_footer = fields.Html(related="company_id.report_footer", string='Custom Report Footer', help="Footer text displayed at the bottom of all reports.", readonly=False) group_multi_currency = fields.Boolean(string='Multi-Currencies', implied_group='base.group_multi_currency', diff --git a/addons/base_setup/views/res_config_settings_views.xml b/addons/base_setup/views/res_config_settings_views.xml index 1052641e1b0..f197b60fc33 100644 --- a/addons/base_setup/views/res_config_settings_views.xml +++ b/addons/base_setup/views/res_config_settings_views.xml @@ -176,6 +176,13 @@
Save this page and come back here to set up reCaptcha.
+ + +
+
Save this page and come back here to set up Cloudflare turnstile.
+
+
+ diff --git a/addons/google_recaptcha/models/ir_http.py b/addons/google_recaptcha/models/ir_http.py index ef18056f762..d872275b4a9 100644 --- a/addons/google_recaptcha/models/ir_http.py +++ b/addons/google_recaptcha/models/ir_http.py @@ -36,6 +36,11 @@ class Http(models.AbstractModel): If no recaptcha private key is set the recaptcha verification is considered inactive and this method will return True. """ + res = super()._verify_request_recaptcha_token(action) + + if not res: + return res + ip_addr = request.httprequest.remote_addr token = request.params.pop('recaptcha_token_response', False) recaptcha_result = request.env['ir.http']._verify_recaptcha_token(ip_addr, token, action) diff --git a/addons/website/models/ir_http.py b/addons/website/models/ir_http.py index ea24ddabcd9..048b5ed3269 100644 --- a/addons/website/models/ir_http.py +++ b/addons/website/models/ir_http.py @@ -425,6 +425,10 @@ class Http(models.AbstractModel): # is not restricted by the website module. return result + @api.model + def _verify_request_recaptcha_token(self, action): + return True + class ModelConverter(ir_http.ModelConverter): diff --git a/addons/website/static/src/snippets/s_website_form/000.js b/addons/website/static/src/snippets/s_website_form/000.js index 14ab98a1528..ed80dc3309b 100644 --- a/addons/website/static/src/snippets/s_website_form/000.js +++ b/addons/website/static/src/snippets/s_website_form/000.js @@ -1,4 +1,4 @@ -/** @odoo-module **/ +/** @odoo-module alias=website.s_website_form **/ import core from "web.core"; import time from "web.time"; diff --git a/addons/website_cf_turnstile/__init__.py b/addons/website_cf_turnstile/__init__.py new file mode 100644 index 00000000000..dc5e6b693d1 --- /dev/null +++ b/addons/website_cf_turnstile/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/addons/website_cf_turnstile/__manifest__.py b/addons/website_cf_turnstile/__manifest__.py new file mode 100644 index 00000000000..e2486b2602e --- /dev/null +++ b/addons/website_cf_turnstile/__manifest__.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Cloudflare Turnstile', + 'category': 'Hidden', + 'version': '1.0', + 'description': """ + This module implements Cloudflare Turnstile so that you can prevent bot spam on your forms. + """, + 'depends': ['website'], + 'data': [ + 'views/res_config_settings_view.xml', + ], + 'assets': { + 'web.assets_frontend': [ + 'website_cf_turnstile/static/src/js/turnstile.js', + ], + }, + 'license': 'LGPL-3', + 'installable': True, +} diff --git a/addons/website_cf_turnstile/models/__init__.py b/addons/website_cf_turnstile/models/__init__.py new file mode 100644 index 00000000000..2d6a3834916 --- /dev/null +++ b/addons/website_cf_turnstile/models/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import ir_http +from . import res_config_settings diff --git a/addons/website_cf_turnstile/models/ir_http.py b/addons/website_cf_turnstile/models/ir_http.py new file mode 100644 index 00000000000..fcaf341e689 --- /dev/null +++ b/addons/website_cf_turnstile/models/ir_http.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import logging +import requests + +from odoo import api, models, _ +from odoo.http import request +from odoo.exceptions import UserError, ValidationError + +logger = logging.getLogger(__name__) + + +class Http(models.AbstractModel): + _inherit = 'ir.http' + + @api.model + def get_frontend_session_info(self): + """Add the Turnstile public key to the given session_info object""" + session = super().get_frontend_session_info() + + site_key = self.env['ir.config_parameter'].sudo().get_param('cf.turnstile_site_key') + if site_key: + session['turnstile_site_key'] = site_key + + return session + + @api.model + def _verify_request_recaptcha_token(self, action): + """ Verify the recaptcha token for the current request. + If no recaptcha private key is set the recaptcha verification + is considered inactive and this method will return True. + """ + res = super()._verify_request_recaptcha_token(action) + + if not res: + return res + + ip_addr = request.httprequest.remote_addr + token = request.params.pop('turnstile_captcha', False) + turnstile_result = request.env['ir.http']._verify_turnstile_token(ip_addr, token, action) + if turnstile_result in ['is_human', 'no_secret']: + return True + if turnstile_result == 'wrong_secret': + raise ValidationError(_("The Cloudflare turnstile private key is invalid.")) + elif turnstile_result == 'wrong_token': + raise ValidationError(_("The CloudFlare human validation failed.")) + elif turnstile_result == 'timeout': + raise UserError(_("Your request has timed out, please retry.")) + elif turnstile_result == 'bad_request': + raise UserError(_("The request is invalid or malformed.")) + else: # wrong_action e.g. + return False + + @api.model + def _verify_turnstile_token(self, ip_addr, token, action=False): + """ + Verify a turnstile token and returns the result as a string. + Turnstile verify DOC: https://developers.cloudflare.com/turnstile/get-started/server-side-validation/ + + :return: The result of the call to the cloudflare API: + is_human: The token is valid and the user trustworthy. + is_bot: The user is not trustworthy and most likely a bot. + no_secret: No private key in settings. + wrong_action: the action performed to obtain the token does not match the one we are verifying. + wrong_token: The token provided is invalid or empty. + wrong_secret: The private key provided in settings is invalid. + timeout: The request has timout or the token provided is too old. + bad_request: The request is invalid or malformed. + internal-error: The request failed. + :rtype: str + """ + private_key = request.env['ir.config_parameter'].sudo().get_param('cf.turnstile_secret_key') + if not private_key: + return 'no_secret' + try: + r = requests.post('https://challenges.cloudflare.com/turnstile/v0/siteverify', { + 'secret': private_key, + 'response': token, + 'remoteip': ip_addr, + }, timeout=3.05) + result = r.json() + res_success = result['success'] + res_action = res_success and action and result['action'] + except requests.exceptions.Timeout: + logger.error("Turnstile verification timeout for ip address %s", ip_addr) + return 'timeout' + except Exception: + logger.error("Turnstile verification bad request response") + return 'bad_request' + + if res_success: + if res_action and res_action != action: + logger.warning("Turnstile verification for ip address %s failed with action %f, expected: %s.", ip_addr, res_action, action) + return 'wrong_action' + logger.info("Turnstile verification for ip address %s succeeded", ip_addr) + return 'is_human' + errors = result.get('error-codes', []) + logger.warning("Turnstile verification for ip address %s failed error codes %r. token was: [%s]", ip_addr, errors, token) + for error in errors: + if error in ['missing-input-secret', 'invalid-input-secret']: + return 'wrong_secret' + if error in ['missing-input-response', 'invalid-input-response']: + return 'wrong_token' + if error in ('timeout-or-duplicate', 'internal-error'): + return 'timeout' + if error == 'bad-request': + return 'bad_request' + return 'is_bot' diff --git a/addons/website_cf_turnstile/models/res_config_settings.py b/addons/website_cf_turnstile/models/res_config_settings.py new file mode 100644 index 00000000000..5aae7b107bd --- /dev/null +++ b/addons/website_cf_turnstile/models/res_config_settings.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + turnstile_site_key = fields.Char("CF Site Key", config_parameter='cf.turnstile_site_key', groups='base.group_system') + turnstile_secret_key = fields.Char("CF Secret Key", config_parameter='cf.turnstile_secret_key', groups='base.group_system') diff --git a/addons/website_cf_turnstile/static/src/js/turnstile.js b/addons/website_cf_turnstile/static/src/js/turnstile.js new file mode 100644 index 00000000000..7d276e55fb5 --- /dev/null +++ b/addons/website_cf_turnstile/static/src/js/turnstile.js @@ -0,0 +1,45 @@ +/** @odoo-module **/ + +import "website.s_website_form"; // force deps +import publicWidget from 'web.public.widget'; +import { session } from "@web/session"; + +publicWidget.registry.s_website_form.include({ + /** + * @override + */ + start: function () { + const res = this._super(...arguments); + this.cleanTurnstile(); + if (!this.isEditable && !this.$('.s_turnstile').length && session.turnstile_site_key) { + const mode = new URLSearchParams(window.location.search).get('cf') == 'show' ? 'always' : 'interaction-only'; + $(`
+ + `).insertAfter('.s_website_form_send, .o_website_form_send'); + } + return res; + }, + + /** + * Remove potential existing loaded script/token + */ + cleanTurnstile: function () { + if (this.$('.s_turnstile').length) { + this.$('.s_turnstile').remove(); + } + }, + + /** + * @override + * Discard all library changes to reset the state of the Html. + */ + destroy: function () { + this.cleanTurnstile(); + this._super(...arguments); + }, +}); diff --git a/addons/website_cf_turnstile/views/res_config_settings_view.xml b/addons/website_cf_turnstile/views/res_config_settings_view.xml new file mode 100644 index 00000000000..3acd8206e7d --- /dev/null +++ b/addons/website_cf_turnstile/views/res_config_settings_view.xml @@ -0,0 +1,28 @@ + + + + res.config.settings.view.form.inherit.web.turnstile + res.config.settings + + +
+
+ Cloudflare Turnstile +
+
+
+
+ +
+
+
+
+