[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:
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user