diff --git a/README.rst b/README.rst index 7892c4c..b863b0d 100644 --- a/README.rst +++ b/README.rst @@ -176,6 +176,20 @@ Config settings # The day of the week that weekly notification subscriptions are sent ckanext.subscribe.weekly_notification_day = friday + *** reCAPTCHA implementation *** + Applying reCAPTCHA helps enhance the security of the dataset subscription form by preventing automated bots from submitting them. + + To integrate reCAPTCHA, you must first register your domain at https://www.google.com/recaptcha and obtain private and public keys. + These keys, along with the reCAPTCHA API URL, need to be configured in your setup:: + + ckanext.subscribe.recaptcha.privatekey = [Your_Private_Key] + ckanext.subscribe.recaptcha.publickey = [Your_Public_Key] + ckanext.subscribe.recaptcha.api_url = [Your_API_URL], e.g., https://www.google.com/recaptcha/api/siteverify + + Additionally, ensure that the `apply_recaptcha` configuration is set to true in order to enable reCAPTCHA:: + + ckanext.subscribe.apply_recaptcha = false + --------------- Troubleshooting diff --git a/ckanext/subscribe/action.py b/ckanext/subscribe/action.py index aaae082..c19fbb0 100644 --- a/ckanext/subscribe/action.py +++ b/ckanext/subscribe/action.py @@ -2,10 +2,11 @@ import logging import datetime - +import requests import ckan.plugins as p from ckan.logic import validate # put in toolkit? from ckan.lib.mailer import MailerException +import ckan.plugins.toolkit as tk from ckanext.subscribe.model import Subscription, Frequency from ckanext.subscribe import ( @@ -21,6 +22,28 @@ NotFound = p.toolkit.ObjectNotFound +def _verify_recaptcha(recaptcha_response): + secret_key = tk.config.get('ckanext.subscribe.recaptcha.privatekey', '') + if not secret_key: + log.error('reCAPTCHA secret key is not configured.') + return False + + payload = { + 'secret': secret_key, + 'response': recaptcha_response + } + + recaptcha_api_url = tk.config.get( + 'ckanext.subscribe.recaptcha.api_url', + 'https://www.google.com/recaptcha/api/siteverify' + ) + + r = requests.post(recaptcha_api_url, data=payload) + result = r.json() + + return result.get('success', False) + + @validate(schema.subscribe_schema) def subscribe_signup(context, data_dict): '''Signup to get notifications of email. Causes a email to be sent, @@ -38,6 +61,7 @@ def subscribe_signup(context, data_dict): :param skip_verification: Doesn't send email - instead it marks the subscription as verified. Can be used by sysadmins only. (optional, default=False) + :param g_recaptcha_response: Recaptcha response from the signup form :returns: the newly created subscription :rtype: dictionary @@ -45,6 +69,18 @@ def subscribe_signup(context, data_dict): ''' model = context['model'] + # Retrieve the configuration value to apply recaptcha + apply_recaptcha = tk.asbool(tk.config.get( + 'ckanext.subscribe.apply_recaptcha', True + )) + + if apply_recaptcha: + # Verify reCAPTCHA response + recaptcha_response = data_dict['g_recaptcha_response'] + if not _verify_recaptcha(recaptcha_response): + raise tk.ValidationError('Invalid reCAPTCHA. Please try again.') + log.info('reCAPTCHA verification passed.') + _check_access(u'subscribe_signup', context, data_dict) data = { diff --git a/ckanext/subscribe/controller.py b/ckanext/subscribe/controller.py index a13dd72..b8c0c1d 100644 --- a/ckanext/subscribe/controller.py +++ b/ckanext/subscribe/controller.py @@ -45,6 +45,7 @@ def signup(self): 'dataset_id': request.POST.get('dataset'), 'group_id': request.POST.get('group'), 'organization_id': request.POST.get('organization'), + 'g_recaptcha_response': request.POST.get('g-recaptcha-response'), } context = { u'model': model, diff --git a/ckanext/subscribe/helpers.py b/ckanext/subscribe/helpers.py new file mode 100644 index 0000000..cc1e7eb --- /dev/null +++ b/ckanext/subscribe/helpers.py @@ -0,0 +1,13 @@ +import ckan.plugins.toolkit as tk + + +def get_recaptcha_publickey(): + """Get reCaptcha public key. + """ + return tk.config.get('ckanext.subscribe.recaptcha.publickey') + +def apply_recaptcha(): + """Apply recaptcha""" + apply_recaptcha = tk.asbool( + tk.config.get('ckanext.subscribe.apply_recaptcha', True)) + return apply_recaptcha diff --git a/ckanext/subscribe/plugin.py b/ckanext/subscribe/plugin.py index b4f16c8..a64b989 100644 --- a/ckanext/subscribe/plugin.py +++ b/ckanext/subscribe/plugin.py @@ -1,13 +1,13 @@ # encoding: utf-8 import ckan.plugins as plugins -import ckan.plugins.toolkit as toolkit +import ckan.plugins.toolkit as tk from ckanext.subscribe import action from ckanext.subscribe import auth from ckanext.subscribe import model as subscribe_model from ckanext.subscribe.interfaces import ISubscribe - +import ckanext.subscribe.helpers as subscribe_helpers class SubscribePlugin(plugins.SingletonPlugin): plugins.implements(plugins.IConfigurer) @@ -15,13 +15,14 @@ class SubscribePlugin(plugins.SingletonPlugin): plugins.implements(plugins.IActions) plugins.implements(plugins.IAuthFunctions) plugins.implements(ISubscribe, inherit=True) + plugins.implements(plugins.ITemplateHelpers) # IConfigurer def update_config(self, config_): - toolkit.add_template_directory(config_, 'templates') - toolkit.add_public_directory(config_, 'public') - toolkit.add_resource('fanstatic', 'subscribe') + tk.add_template_directory(config_, 'templates') + tk.add_public_directory(config_, 'public') + tk.add_resource('fanstatic', 'subscribe') subscribe_model.setup() @@ -81,3 +82,13 @@ def get_auth_functions(self): 'subscribe_send_any_notifications': auth.subscribe_send_any_notifications, } + + # ITemplateHelpers + def get_helpers(self): + """Provide template helper functions + """ + + return { + 'get_recaptcha_publickey': subscribe_helpers.get_recaptcha_publickey, # noqa + 'apply_recaptcha': subscribe_helpers.apply_recaptcha, + } diff --git a/ckanext/subscribe/schema.py b/ckanext/subscribe/schema.py index 178b38b..6a4e6f2 100644 --- a/ckanext/subscribe/schema.py +++ b/ckanext/subscribe/schema.py @@ -14,7 +14,6 @@ ignore_missing = get_validator('ignore_missing') boolean_validator = get_validator('boolean_validator') - def one_package_or_group_or_org(key, data, errors, context): num_objects_specified = len(filter(None, [data[('dataset_id',)], data[('group_id',)], @@ -44,6 +43,7 @@ def subscribe_schema(): u'email': [email], u'frequency': [ignore_empty, frequency_name_to_int], u'skip_verification': [boolean_validator], + u'g_recaptcha_response': [ignore_empty], } diff --git a/ckanext/subscribe/templates/package/snippets/info.html b/ckanext/subscribe/templates/package/snippets/info.html index d3dcb52..e0355b2 100644 --- a/ckanext/subscribe/templates/package/snippets/info.html +++ b/ckanext/subscribe/templates/package/snippets/info.html @@ -23,6 +23,11 @@
+ {% if h.apply_recaptcha() %} + + +
+ {% endif %} diff --git a/ckanext/subscribe/tests/test_action.py b/ckanext/subscribe/tests/test_action.py index 261c19c..514d6ea 100644 --- a/ckanext/subscribe/tests/test_action.py +++ b/ckanext/subscribe/tests/test_action.py @@ -222,6 +222,106 @@ def test_dataset_and_group_at_same_time(self, send_request_email): assert not send_request_email.called + # Adding the reCAPTCHA tests + @mock.patch('requests.post') + def test_verify_recaptcha_success(self, mock_post): + mock_post.return_value = mock.Mock(status_code=200) + mock_post.return_value.json.return_value = {'success': True} + + response = helpers.call_action( + "subscribe_signup", + {}, + email='bob@example.com', + dataset_id=factories.Dataset()["id"], + __extras={'g-recaptcha-response': 'test-recaptcha-response'} + ) + assert 'email' in response + + @mock.patch('requests.post') + def test_verify_recaptcha_failure(self, mock_post): + mock_post.return_value = mock.Mock(status_code=200) + mock_post.return_value.json.return_value = {'success': False} + + with assert_raises(ValidationError): + helpers.call_action( + "subscribe_signup", + {}, + email='bob@example.com', + dataset_id=factories.Dataset()["id"], + __extras={'g-recaptcha-response': 'test-recaptcha-response'} + ) + + @mock.patch('requests.post') + @mock.patch('ckanext.subscribe.email_verification.send_request_email') + def test_recaptcha_frontend_form(self, mock_post, send_request_email): + mock_post.return_value = mock.Mock(status_code=200) + mock_post.return_value.json.return_value = {'success': True} + + dataset = factories.Dataset() + + subscription = helpers.call_action( + "subscribe_signup", + {}, + email='bob@example.com', + dataset_id=dataset["id"], + __extras={'g-recaptcha-response': 'test-recaptcha-response'} + ) + + send_request_email.assert_called_once() + eq(send_request_email.call_args[0][0].object_type, 'dataset') + eq(send_request_email.call_args[0][0].object_id, dataset['id']) + eq(send_request_email.call_args[0][0].email, 'bob@example.com') + eq(subscription['object_type'], 'dataset') + eq(subscription['object_id'], dataset['id']) + eq(subscription['email'], 'bob@example.com') + eq(subscription['verified'], False) + assert 'verification_code' not in subscription + subscription_obj = model.Session.query(Subscription).get( + subscription['id']) + assert subscription_obj + + # Verify that 'g-recaptcha-response' was passed in __extras + extras = subscription['__extras'] + assert 'g-recaptcha-response' in extras + eq(extras['g-recaptcha-response'], 'test-recaptcha-response') + +@mock.patch('requests.post') +@mock.patch('ckanext.subscribe.email_verification.send_request_email') +@mock.patch('ckan.plugins.toolkit.request') +def test_recaptcha_backend_form(self, mock_request, mock_post, + send_request_email): + mock_post.return_value = mock.Mock(status_code=200) + mock_post.return_value.json.return_value = {'success': True} + + # Mock the request parameters to include g-recaptcha-response + mock_request.params = {'g-recaptcha-response': 'test-recaptcha-response'} + + dataset = factories.Dataset() + + subscription = helpers.call_action( + "subscribe_signup", + {}, + email='bob@example.com', + dataset_id=dataset["id"], + ) + + send_request_email.assert_called_once() + eq(send_request_email.call_args[0][0].object_type, 'dataset') + eq(send_request_email.call_args[0][0].object_id, dataset['id']) + eq(send_request_email.call_args[0][0].email, 'bob@example.com') + eq(subscription['object_type'], 'dataset') + eq(subscription['object_id'], dataset['id']) + eq(subscription['email'], 'bob@example.com') + eq(subscription['verified'], False) + assert 'verification_code' not in subscription + subscription_obj = model.Session.query(Subscription).get( + subscription['id']) + assert subscription_obj + + # Verify that 'g-recaptcha-response' was passed in request params + eq(mock_request.params.get('g-recaptcha-response'), + 'test-recaptcha-response') + class TestSubscribeVerify(object): def setup(self): diff --git a/test.ini b/test.ini index aad8540..2d61a4e 100644 --- a/test.ini +++ b/test.ini @@ -9,11 +9,15 @@ host = 0.0.0.0 port = 5000 [app:main] -use = config:../ckan/test-core.ini +use = config:/usr/lib/ckan/venv/src/ckan/test-core.ini # Insert any custom config settings to be used when running your extension's # tests here. ckan.plugins = subscribe +ckanext.subscribe.apply_recaptcha = false +ckanext.subscribe.recaptcha.api_url = https://www.google.com/recaptcha/api/siteverify +#ckanext.subscribe.recaptcha.privatekey = +#ckanext.subscribe.recaptcha.publickey = # Logging configuration