From 541148613451f4c14a299672d91c4397d8a9f6de Mon Sep 17 00:00:00 2001 From: fja-odoo Date: Fri, 24 Apr 2020 10:50:28 +0000 Subject: [PATCH] [IMP] google_recaptcha, *: integrate recaptchaV3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * = base_setup, website_form, website_sale, website_crm, crm_iap_lead_website, website_hr_recruitment, website_mass_mailing Integrate reCaptchaV3 on website_form submit and website_mass_mailing subscription. You can now use ReCaptchaV3 to add reCaptcha verification in any module using google_recaptcha. Also added a better error management on the form with custom messages. task-2217980 closes odoo/odoo#48466 Related: odoo/enterprise#9649 Signed-off-by: Jérémy Kersten (jke) --- .../base_setup/models/res_config_settings.py | 1 + .../views/res_config_settings_views.xml | 14 +++ .../crm_iap_lead_website/controllers/main.py | 5 +- addons/google_recaptcha/__init__.py | 4 + addons/google_recaptcha/__manifest__.py | 17 ++++ addons/google_recaptcha/models/__init__.py | 5 + addons/google_recaptcha/models/ir_http.py | 96 +++++++++++++++++++ .../models/res_config_settings.py | 13 +++ .../static/src/js/recaptcha.js | 60 ++++++++++++ .../static/src/scss/recaptcha.scss | 5 + .../static/src/xml/recaptcha.xml | 14 +++ addons/google_recaptcha/views/assets.xml | 17 ++++ .../views/res_config_settings_view.xml | 31 ++++++ addons/website_crm/controllers/main.py | 5 +- addons/website_form/__manifest__.py | 2 +- addons/website_form/controllers/main.py | 19 +++- .../static/src/snippets/s_website_form/000.js | 34 +++++-- .../src/snippets/s_website_form/001.scss | 10 +- .../src/snippets/s_website_form/options.js | 31 +++++- .../static/src/xml/website_form.xml | 10 +- .../static/src/xml/website_form_editor.xml | 9 ++ .../views/snippets/s_website_form.xml | 2 + .../website_mass_mailing/controllers/main.py | 11 ++- .../src/js/website_mass_mailing.editor.js | 35 +++++++ .../static/src/js/website_mass_mailing.js | 48 ++++++++-- .../views/snippets_templates.xml | 19 ++-- addons/website_sale/views/templates.xml | 2 +- 27 files changed, 468 insertions(+), 51 deletions(-) create mode 100644 addons/google_recaptcha/__init__.py create mode 100644 addons/google_recaptcha/__manifest__.py create mode 100644 addons/google_recaptcha/models/__init__.py create mode 100644 addons/google_recaptcha/models/ir_http.py create mode 100644 addons/google_recaptcha/models/res_config_settings.py create mode 100644 addons/google_recaptcha/static/src/js/recaptcha.js create mode 100644 addons/google_recaptcha/static/src/scss/recaptcha.scss create mode 100644 addons/google_recaptcha/static/src/xml/recaptcha.xml create mode 100644 addons/google_recaptcha/views/assets.xml create mode 100644 addons/google_recaptcha/views/res_config_settings_view.xml diff --git a/addons/base_setup/models/res_config_settings.py b/addons/base_setup/models/res_config_settings.py index 410d5130b0b..2248f46bfb1 100644 --- a/addons/base_setup/models/res_config_settings.py +++ b/addons/base_setup/models/res_config_settings.py @@ -30,6 +30,7 @@ class ResConfigSettings(models.TransientModel): module_web_unsplash = fields.Boolean("Unsplash Image Library") module_partner_autocomplete = fields.Boolean("Partner Autocomplete") module_base_geolocalize = fields.Boolean("GeoLocalize") + module_google_recaptcha = fields.Boolean("reCAPTCHA: Easy on Humans, Hard on Bots") report_footer = fields.Text(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 1ddab7646ff..07f41954c03 100644 --- a/addons/base_setup/views/res_config_settings_views.xml +++ b/addons/base_setup/views/res_config_settings_views.xml @@ -354,6 +354,20 @@ +
+
+ +
+
+
+
diff --git a/addons/crm_iap_lead_website/controllers/main.py b/addons/crm_iap_lead_website/controllers/main.py index e824a57bbef..4a9b63573bf 100644 --- a/addons/crm_iap_lead_website/controllers/main.py +++ b/addons/crm_iap_lead_website/controllers/main.py @@ -8,12 +8,11 @@ from odoo.http import request class ContactController(WebsiteForm): - @http.route('/website_form/', type='http', auth="public", methods=['POST'], website=True) - def website_form(self, model_name, **kwargs): + def _handle_website_form(self, model_name, **kwargs): if model_name == 'crm.lead': # Add the ip_address to the request in order to add this to the lead # that will be created. With this, we avoid to create a lead from # reveal if a lead is already created from the contact form. request.params['reveal_ip'] = request.httprequest.remote_addr - return super(ContactController, self).website_form(model_name, **kwargs) + return super(ContactController, self)._handle_website_form(model_name, **kwargs) diff --git a/addons/google_recaptcha/__init__.py b/addons/google_recaptcha/__init__.py new file mode 100644 index 00000000000..dc5e6b693d1 --- /dev/null +++ b/addons/google_recaptcha/__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/google_recaptcha/__manifest__.py b/addons/google_recaptcha/__manifest__.py new file mode 100644 index 00000000000..627313574b3 --- /dev/null +++ b/addons/google_recaptcha/__manifest__.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Google reCAPTCHA integration', + 'category': 'Hidden', + 'version': '1.0', + 'description': """ + This module implements reCaptchaV3 so that you can prevent bot spam on your public modules. + """, + 'depends': ['base_setup'], + 'data': [ + 'views/assets.xml', + 'views/res_config_settings_view.xml', + ], + 'auto_install': True, +} diff --git a/addons/google_recaptcha/models/__init__.py b/addons/google_recaptcha/models/__init__.py new file mode 100644 index 00000000000..2d6a3834916 --- /dev/null +++ b/addons/google_recaptcha/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/google_recaptcha/models/ir_http.py b/addons/google_recaptcha/models/ir_http.py new file mode 100644 index 00000000000..59dbfd14c96 --- /dev/null +++ b/addons/google_recaptcha/models/ir_http.py @@ -0,0 +1,96 @@ +# -*- 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 _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. + """ + 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, 'website_form') + if recaptcha_result in ['is_human', 'no_secret']: + return True + if recaptcha_result == 'wrong_secret': + raise ValidationError(_("The reCaptcha private key is invalid.")) + elif recaptcha_result == 'wrong_token': + raise ValidationError(_("The reCaptcha token is invalid.")) + elif recaptcha_result == 'timeout': + raise UserError(_("Your request has timed out, please retry.")) + elif recaptcha_result == 'bad_request': + raise UserError(_("The request is invalid or malformed.")) + else: + return False + + @api.model + def _verify_recaptcha_token(self, ip_addr, token, action=False): + """ + Verify a recaptchaV3 token and returns the result as a string. + RecaptchaV3 verify DOC: https://developers.google.com/recaptcha/docs/verify + + :return: The result of the call to the google 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 reCaptcha secret set 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. + :rtype: str + """ + private_key = request.env['ir.config_parameter'].sudo().get_param('recaptcha_private_key') + if not private_key: + return 'no_secret' + min_score = request.env['ir.config_parameter'].sudo().get_param('recaptcha_min_score') + try: + r = requests.post('https://www.recaptcha.net/recaptcha/api/siteverify', { + 'secret': private_key, + 'response': token, + 'remoteip': ip_addr, + }, timeout=2) # it takes ~50ms to retrieve the response + result = r.json() + res_success = result['success'] + res_action = res_success and action and result['action'] + except requests.exceptions.Timeout: + logger.error("Trial captcha verification timeout for ip address %s", ip_addr) + return 'timeout' + except Exception: + logger.error("Trial captcha verification bad request response") + return 'bad_request' + + if res_success: + score = result.get('score', False) + if score < float(min_score): + logger.warning("Trial captcha verification for ip address %s failed with score %f.", ip_addr, score) + return 'is_bot' + if res_action and res_action != action: + logger.warning("Trial captcha verification for ip address %s failed with action %f, expected: %s.", ip_addr, score, action) + return 'wrong_action' + logger.info("Trial captcha verification for ip address %s succeeded with score %f.", ip_addr, score) + return 'is_human' + errors = result.get('error-codes', []) + logger.warning("Trial captcha 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 == 'timeout-or-duplicate': + return 'timeout' + if error == 'bad-request': + return 'bad_request' + return 'is_bot' diff --git a/addons/google_recaptcha/models/res_config_settings.py b/addons/google_recaptcha/models/res_config_settings.py new file mode 100644 index 00000000000..2d366ec5cda --- /dev/null +++ b/addons/google_recaptcha/models/res_config_settings.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import requests + +from odoo import _, api, fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + recaptcha_public_key = fields.Char("Site Key", config_parameter='recaptcha_public_key', groups='base.group_system') + recaptcha_private_key = fields.Char("Secret Key", config_parameter='recaptcha_private_key', groups='base.group_system') + recaptcha_min_score = fields.Float("Minimum score", config_parameter='recaptcha_min_score', groups='base.group_system', default="0.5", help="Should be between 0.0 and 1.0") diff --git a/addons/google_recaptcha/static/src/js/recaptcha.js b/addons/google_recaptcha/static/src/js/recaptcha.js new file mode 100644 index 00000000000..9bab4e7827a --- /dev/null +++ b/addons/google_recaptcha/static/src/js/recaptcha.js @@ -0,0 +1,60 @@ +odoo.define('google_recaptcha.ReCaptchaV3', function (require) { +"use strict"; + +const ajax = require('web.ajax'); +const Class = require('web.Class'); +const core = require('web.core'); + +const _t = core._t; + +const ReCaptcha = Class.extend({ + /** + * @override + */ + init: function () { + this._publicKey = odoo.reCaptchaPublicKey; + }, + /** + * Loads the recaptcha libraries. + * + * @returns {Promise|boolean} promise if libs are loading else false if the reCaptcha key is empty. + */ + loadLibs: function () { + if (this._publicKey) { + this._recaptchaReady = ajax.loadJS(`https://www.recaptcha.net/recaptcha/api.js?render=${this._publicKey}`) + .then(() => new Promise(resolve => window.grecaptcha.ready(() => resolve()))); + return this._recaptchaReady.then(() => !!document.querySelector('.grecaptcha-badge')); + } + return false; + }, + /** + * Returns an object with the token if reCaptcha call succeeds + * If no key is set an object with a message is returned + * If an error occured an object with the error message is returned + * + * @param {string} action + * @returns {Promise|Object} + */ + getToken: async function (action) { + if (!this._publicKey) { + return { + message: _t("No recaptcha public key set."), + }; + } + await this._recaptchaReady; + try { + return { + token: await window.grecaptcha.execute(this._publicKey, {action: action}) + }; + } catch (e) { + return { + error: _t("The recaptcha public key is invalid."), + }; + } + }, +}); + +return { + ReCaptcha: ReCaptcha, +}; +}); diff --git a/addons/google_recaptcha/static/src/scss/recaptcha.scss b/addons/google_recaptcha/static/src/scss/recaptcha.scss new file mode 100644 index 00000000000..1603d4749b7 --- /dev/null +++ b/addons/google_recaptcha/static/src/scss/recaptcha.scss @@ -0,0 +1,5 @@ +// Hide google recaptcha V3 don't forget to add legal +// https://developers.google.com/recaptcha/docs/faq#id-like-to-hide-the-recaptcha-badge.-what-is-allowed +.grecaptcha-badge { + visibility: hidden; +} diff --git a/addons/google_recaptcha/static/src/xml/recaptcha.xml b/addons/google_recaptcha/static/src/xml/recaptcha.xml new file mode 100644 index 00000000000..4b7c23b4c2f --- /dev/null +++ b/addons/google_recaptcha/static/src/xml/recaptcha.xml @@ -0,0 +1,14 @@ + + + + + + Protected by reCAPTCHA, + Privacy Policy + & + Terms of Service + apply. + + + + diff --git a/addons/google_recaptcha/views/assets.xml b/addons/google_recaptcha/views/assets.xml new file mode 100644 index 00000000000..d657d0e5c42 --- /dev/null +++ b/addons/google_recaptcha/views/assets.xml @@ -0,0 +1,17 @@ + + + +