[ADD] website_cf_turnstile: add cloudflare turnstile support

This module allows to add secret key to add the turnstile captcha on
each snippet website_form.

Cloudflare Turnstile
--------------------
A friendly, free CAPTCHA replacement
Turnstile delivers frustration-free, CAPTCHA-free web experiences to
website visitors.
Turnstile stops abuse and confirms visitors are real without the data
privacy concerns or awful UX that CAPTCHAs thrust on users.

closes odoo/odoo#119246

X-original-commit: 4aca39a533e9d41f5f452f36a1ffc001f586b4f4
Signed-off-by: Jérémy Kersten <jke@odoo.com>
This commit is contained in:
Jeremy Kersten
2023-04-24 18:25:53 +02:00
parent 761d39658e
commit cb1388ed9e
12 changed files with 240 additions and 1 deletions
@@ -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',
@@ -176,6 +176,13 @@
<div class="mt16 text-warning"><strong>Save</strong> this page and come back here to set up reCaptcha.</div>
</div>
</setting>
<setting help="Protect your forms with CF Turnstile." id="cf-turnstile">
<field name="module_website_cf_turnstile"/>
<div class="content-group" attrs="{'invisible': [('module_website_cf_turnstile', '=', False)]}" id="turnstile_warning">
<div class="mt16 text-warning"><strong>Save</strong> this page and come back here to set up Cloudflare turnstile.</div>
</div>
</setting>
</block>
<block title="Performance" groups="base.group_no_one" name="performance">
@@ -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)
+4
View File
@@ -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):
@@ -1,4 +1,4 @@
/** @odoo-module **/
/** @odoo-module alias=website.s_website_form **/
import core from "web.core";
import time from "web.time";
+4
View File
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
@@ -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,
}
@@ -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
@@ -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'
@@ -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')
@@ -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';
$(`<div class="s_turnstile cf-turnstile float-end"
data-action="website_form"
data-appearance="${mode}"
data-response-field-name="turnstile_captcha"
data-sitekey="${session.turnstile_site_key}"
></div>
<script class="s_turnstile" src="https://challenges.cloudflare.com/turnstile/v0/api.js"></script>
`).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);
},
});
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.web.turnstile</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base_setup.res_config_settings_view_form"/>
<field name="arch" type="xml">
<div id="turnstile_warning" position="replace">
<div class="content-group" id="cfturnstile_configuration_settings">
<span class="o_form_label" for="">Cloudflare Turnstile</span>
<div class="mt16 row">
<label for="turnstile_site_key" class="col-3 o_light_label"/>
<field name="turnstile_site_key"/>
</div>
<div class="mt16 row">
<label for="turnstile_secret_key" class="col-3 o_light_label"/>
<field name="turnstile_secret_key"/>
</div>
<div>
<a href="https://blog.cloudflare.com/turnstile-private-captcha-alternative/" class="oe_link" target="_blank">
<i class="fa fa-arrow-right"/> More info
</a>
</div>
</div>
</div>
</field>
</record>
</odoo>