Skip to content

Commit

Permalink
Merge pull request #9 from bellisk/add_recaptcha
Browse files Browse the repository at this point in the history
Add reCaptcha to dataset subscribe signup
  • Loading branch information
kovalch authored Jun 20, 2024
2 parents 4ba42c9 + 27e8534 commit 89556b4
Show file tree
Hide file tree
Showing 9 changed files with 192 additions and 8 deletions.
14 changes: 14 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 37 additions & 1 deletion ckanext/subscribe/action.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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,
Expand All @@ -38,13 +61,26 @@ 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
'''
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 = {
Expand Down
1 change: 1 addition & 0 deletions ckanext/subscribe/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions ckanext/subscribe/helpers.py
Original file line number Diff line number Diff line change
@@ -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
21 changes: 16 additions & 5 deletions ckanext/subscribe/plugin.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,28 @@
# 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)
plugins.implements(plugins.IRoutes)
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()

Expand Down Expand Up @@ -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,
}
2 changes: 1 addition & 1 deletion ckanext/subscribe/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',)],
Expand Down Expand Up @@ -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],
}


Expand Down
5 changes: 5 additions & 0 deletions ckanext/subscribe/templates/package/snippets/info.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@
<form method='post' action="{{ h.url_for('/subscribe/signup') }}" id="subscribe-form" enctype="multipart/form-data" class="form-inline">
<!-- (Bootstrap 3) <div class="form-group input-group-sm"> -->
<input id="subscribe-email" type="email" name="email" class="form-control input-small" value="" placeholder="" />
{% if h.apply_recaptcha() %}
<!-- reCAPTCHA -->
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
<div class="g-recaptcha" data-sitekey={{ h.recaptcha_publickey() }} style="transform:scale(0.85);transform-origin:0"></div>
{% endif %}
<input id="subscribe-dataset" type="hidden" name="dataset" value="{{ pkg.name }}"` />
<!-- </div> -->
<button type="submit" class="btn btn-default" name="save">{{ _('Submit') }}</button>
Expand Down
100 changes: 100 additions & 0 deletions ckanext/subscribe/tests/test_action.py
Original file line number Diff line number Diff line change
Expand Up @@ -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='[email protected]',
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='[email protected]',
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='[email protected]',
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, '[email protected]')
eq(subscription['object_type'], 'dataset')
eq(subscription['object_id'], dataset['id'])
eq(subscription['email'], '[email protected]')
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='[email protected]',
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, '[email protected]')
eq(subscription['object_type'], 'dataset')
eq(subscription['object_id'], dataset['id'])
eq(subscription['email'], '[email protected]')
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):
Expand Down
6 changes: 5 additions & 1 deletion test.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 89556b4

Please sign in to comment.