diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000..508a30c --- /dev/null +++ b/AUTHORS @@ -0,0 +1,5 @@ +Contributors based on gitlog: +Joe Jasinski +Jin Sun +amatellanes +Pablo Recio (pablorecio) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ebc8d00 --- /dev/null +++ b/LICENSE @@ -0,0 +1,31 @@ +Copyright 2014 (c) Imaginary Landscape LLC +All rights reserved. + +Major updates to the project and name change made by Imaginary Landscape LLC. +All licensing follows the below 3-clause BSD license. + + +Copyright (c) Praekelt Foundation +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Praekelt Foundation nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL PRAEKELT FOUNDATION BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..15f1435 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,9 @@ +global-include LICENSE README.md setup.py LICENSE MANIFEST.in +include LICENSE +include README.md +include MANIFEST.in +include setup.py +recursive-include demo * +recursive-include nocaptcha_recaptcha * +recursive-exclude * __pycache__ +recursive-exclude * *.py[co] diff --git a/README.md b/README.md new file mode 100644 index 0000000..0d02c03 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +[![Build Status](https://travis-ci.org/ImaginaryLandscape/django-nocaptcha-recaptcha.svg?branch=master)](https://travis-ci.org/ImaginaryLandscape/django-nocaptcha-recaptcha) + +# SUMMARY + +Add new-style Google ReCaptcha widgets to your Django forms simply by adding a +NoReCaptchaField field to said forms. + +# ABOUT + +In late 2014, Google updated their ReCaptcha service, changing its API. The update significantly +changes the appearance and function of ReCaptcha. This has been referred to as +ReCaptcha 2 or "nocaptcha recaptcha". + +This module is intended to be a successor to django-recaptcha to support the new style +Google Recaptcha. It borrows a lot of the logic from the django-recaptcha, but has been +updated to support the Google change. + +For the Google documentation for this service, visit the following: + + https://developers.google.com/recaptcha/intro + +The original django-recaptcha project is located at the following location: + + https://github.com/praekelt/django-recaptcha + +# FEATURES + + - Implements Google's New "NoCaptcha ReCaptcha Field" + - Uses the fallback option for browsers without JavaScript + - Easy to add to a Form via a FormField + - Works similar to django-recaptcha + - Working demo projects + - Works with Python 2.7 and 3.4 + +# INSTALL + + pip install django-nocaptcha-recaptcha + +# CONFIGURE + +Add nocaptcha_recaptcha to your INSTALLED_APPS setting + +Add the following to settings.py + + Required settings: + NORECAPTCHA_SITE_KEY (string) = the Google provided site_key + NORECAPTCHA_SECRET_KEY (string) = the Google provided secret_key + + Optional Settings: + NORECAPTCHA_VERIFY_URL (string) = reCaptcha api endpoint for verification. + Best to leave this as the default setting. + Default is https://www.google.com/recaptcha/api/siteverify + NORECAPTCHA_WIDGET_TEMPLATE (string) = location for the widget template. + Default is nocaptcha_recaptcha/widget.html + + +Add the field to a form that you want to protect. + + from nocaptcha_recaptcha.fields import NoReCaptchaField + + class DemoForm(forms.Form): + ..... + captcha = NoReCaptchaField() + + +Add Google's JavaScript library to your base template or elsewhere, so it is +available on the page containing the django form. + + + + +(optional) +You can customize the field. + +- You can add attributes to the g-recaptcha div tag through the following + + captcha = NoReCaptchaField(gtag_attrs={'data-theme':'dark'})) + +- You can override the template for the widget like you would any + other django template. + + +# DEMO PROJECT + +The demo project includes a fully working example of this module. +To use it, run the following: + + cd demo + export NORECAPTCHA_SITE_KEY="" + export NORECAPTCHA_SECRET_KEY="" + ./manage.py runserver + + # in a browser, visit http://localhost:8000 + +# TESTING + + python setup.py test diff --git a/demo/demo/__init__.py b/demo/demo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demo/demo/settings.py b/demo/demo/settings.py new file mode 100644 index 0000000..d763a55 --- /dev/null +++ b/demo/demo/settings.py @@ -0,0 +1,212 @@ +# Django settings for demo project. +import os +import sys +from django import VERSION + +PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), )) +sys.path.insert(0, os.path.join(PROJECT_ROOT, '..', '..')) + +DEBUG = True + +ADMINS = ( + # ('Your Name', 'your_email@example.com'), +) + +MANAGERS = ADMINS + +DATABASES = { + 'default': { + # Add 'postgresql_psycopg2', 'mysql', 'sqlite3' or 'oracle'. + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': 'demo.sqlite3', # Or path to database file if using sqlite3. + # The following settings are not used with sqlite3: + 'USER': '', + 'PASSWORD': '', + 'HOST': '', + # Empty for localhost through domain sockets or '127.0.0.1' for + # localhost through TCP. + 'PORT': '', # Set to empty string for default. + } +} + + +# Hosts/domain names that are valid for this site; required if DEBUG is False +# See https://docs.djangoproject.com/en/1.5/ref/settings/#allowed-hosts +ALLOWED_HOSTS = [] + +# Local time zone for this installation. Choices can be found here: +# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name +# although not all choices may be available on all operating systems. +# In a Windows environment this must be set to your system time zone. +TIME_ZONE = 'America/Chicago' + +# Language code for this installation. All choices can be found here: +# http://www.i18nguy.com/unicode/language-identifiers.html +LANGUAGE_CODE = 'en-us' + +SITE_ID = 1 + +# If you set this to False, Django will make some optimizations so as not +# to load the internationalization machinery. +USE_I18N = True + +# If you set this to False, Django will not format dates, numbers and +# calendars according to the current locale. +USE_L10N = True + +# If you set this to False, Django will not use timezone-aware datetimes. +USE_TZ = True + +# Absolute filesystem path to the directory that will hold user-uploaded files. +# Example: "/var/www/example.com/media/" +MEDIA_ROOT = '' + +# URL that handles the media served from MEDIA_ROOT. Make sure to use a +# trailing slash. +# Examples: "http://example.com/media/", "http://media.example.com/" +MEDIA_URL = '' + +# Absolute path to the directory static files should be collected to. +# Don't put anything in this directory yourself; store your static files +# in apps' "static/" subdirectories and in STATICFILES_DIRS. +# Example: "/var/www/example.com/static/" +STATIC_ROOT = '' + +# URL prefix for static files. +# Example: "http://example.com/static/", "http://static.example.com/" +STATIC_URL = '/static/' + +# Additional locations of static files +STATICFILES_DIRS = ( + # Put strings here, like "/home/html/static" or "C:/www/django/static". + # Always use forward slashes, even on Windows. + # Don't forget to use absolute paths, not relative paths. +) + +# List of finder classes that know how to find static files in +# various locations. +STATICFILES_FINDERS = ( + 'django.contrib.staticfiles.finders.FileSystemFinder', + 'django.contrib.staticfiles.finders.AppDirectoriesFinder', + # 'django.contrib.staticfiles.finders.DefaultStorageFinder', +) + +# Make this unique, and don't share it with anybody. +SECRET_KEY = 'x$47^-hmv-kaa0trcc*ry%+b^^2f)$rs#cl)6j&!)j2c&h%88e' + + +MIDDLEWARE_CLASSES = ( + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + # Uncomment the next line for simple clickjacking protection: + # 'django.middleware.clickjacking.XFrameOptionsMiddleware', +) + +ROOT_URLCONF = 'demo.urls' + +# Python dotted path to the WSGI application used by Django's runserver. +WSGI_APPLICATION = 'demo.wsgi.application' + + +if VERSION >= (1, 8, 0): + + TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [ + os.path.join(PROJECT_ROOT, 'templates'), + ], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.contrib.auth.context_processors.auth', + 'django.template.context_processors.i18n', + 'django.template.context_processors.request', + 'django.template.context_processors.media', + 'django.template.context_processors.static', + 'django.contrib.messages.context_processors.messages', + 'example.apps.core.context_processors.site', + ], + }, + }, + ] +else: + + TEMPLATE_DEBUG = DEBUG + + # List of callables that know how to import templates from various sources. + TEMPLATE_LOADERS = ( + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', + # 'django.template.loaders.eggs.Loader', + ) + + TEMPLATE_DIRS = ( + # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". + # Always use forward slashes, even on Windows. + # Don't forget to use absolute paths, not relative paths. + os.path.join(PROJECT_ROOT, 'templates'), + ) + + +INSTALLED_APPS = ( + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.sites', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django.contrib.admin', + # Uncomment the next line to enable admin documentation: + # 'django.contrib.admindocs', + + # Only needed for running unit tests + 'nocaptcha_recaptcha', +) + + +SESSION_SERIALIZER = 'django.contrib.sessions.serializers.JSONSerializer' + +# A sample logging configuration. The only tangible logging +# performed by this configuration is to send an email to +# the site admins on every HTTP 500 error when DEBUG=False. +# See http://docs.djangoproject.com/en/dev/topics/logging for +# more details on how to customize your logging configuration. +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'filters': { + 'require_debug_false': { + '()': 'django.utils.log.RequireDebugFalse' + } + }, + 'handlers': { + 'mail_admins': { + 'level': 'ERROR', + 'filters': ['require_debug_false'], + 'class': 'django.utils.log.AdminEmailHandler' + }, + 'console': { + 'level': 'DEBUG', + 'class': 'logging.StreamHandler', + }, + }, + 'loggers': { + 'django.request': { + 'handlers': ['mail_admins'], + 'level': 'ERROR', + 'propagate': True, + }, + 'iscapeauth': { + 'handlers': ['console'], + 'level': "DEBUG", + 'propogate': True, + } + } +} + +NORECAPTCHA_SITE_KEY = os.environ.get('NORECAPTCHA_SITE_KEY', "") +NORECAPTCHA_SECRET_KEY = os.environ.get('NORECAPTCHA_SECRET_KEY', "") diff --git a/demo/demo/templates/index.html b/demo/demo/templates/index.html new file mode 100644 index 0000000..e02b2b4 --- /dev/null +++ b/demo/demo/templates/index.html @@ -0,0 +1,14 @@ + + + reCAPTCHA demo: Simple page + + + + +
{% csrf_token %} + {{ form.as_p }} + +
+ + + \ No newline at end of file diff --git a/demo/demo/templates/success.html b/demo/demo/templates/success.html new file mode 100644 index 0000000..7af069f --- /dev/null +++ b/demo/demo/templates/success.html @@ -0,0 +1,8 @@ + + + reCAPTCHA demo: Simple page + + +Success! + + \ No newline at end of file diff --git a/demo/demo/urls.py b/demo/demo/urls.py new file mode 100644 index 0000000..0307794 --- /dev/null +++ b/demo/demo/urls.py @@ -0,0 +1,18 @@ +from django.contrib import admin +from django.conf.urls import include, url +from django.views.generic import TemplateView + +from . import views + +admin.autodiscover() + +urlpatterns = [ + + url(r'^$', views.DemoView.as_view(template_name="index.html"), {}, + name="index"), + url(r'^success/$', TemplateView.as_view(template_name="success.html"), {}, + name="success"), + + # Uncomment the next line to enable the admin: + url(r'^admin/', include(admin.site.urls)), +] diff --git a/demo/demo/views.py b/demo/demo/views.py new file mode 100644 index 0000000..6d2c4b4 --- /dev/null +++ b/demo/demo/views.py @@ -0,0 +1,18 @@ +from django import forms +from django.core.urlresolvers import reverse_lazy +from django.views.generic import FormView + +from nocaptcha_recaptcha.fields import NoReCaptchaField + + +class DemoForm(forms.Form): + username = forms.CharField(required=True) + captcha = NoReCaptchaField(gtag_attrs={'data-theme': 'dark'}) + + +class DemoView(FormView): + form_class = DemoForm + success_url = reverse_lazy('success') + + def form_valid(self, form): + return super(DemoView, self).form_valid(form) \ No newline at end of file diff --git a/demo/demo/wsgi.py b/demo/demo/wsgi.py new file mode 100644 index 0000000..8d4d998 --- /dev/null +++ b/demo/demo/wsgi.py @@ -0,0 +1,33 @@ +""" +WSGI config for demo project. + +This module contains the WSGI application used by Django's development server +and any production WSGI deployments. It should expose a module-level variable +named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover +this application via the ``WSGI_APPLICATION`` setting. + +Usually you will have the standard Django WSGI application here, but it also +might make sense to replace the whole Django WSGI application with a custom one +that later delegates to the Django one. For example, you could introduce WSGI +middleware here, or combine a Django application with an application of another +framework. + +""" +import os + +# We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks +# if running multiple sites in the same mod_wsgi process. To fix this, use +# mod_wsgi daemon mode with each site in its own daemon process, or use +# os.environ["DJANGO_SETTINGS_MODULE"] = "demo.settings" +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "demo.settings") + +# This application object is used by any WSGI server configured to use this +# file. This includes Django's development server, if the WSGI_APPLICATION +# setting points here. +from django.core.wsgi import get_wsgi_application + +application = get_wsgi_application() + +# Apply WSGI middleware here. +# from helloworld.wsgi import HelloWorldApplication +# application = HelloWorldApplication(application) diff --git a/demo/manage.py b/demo/manage.py new file mode 100755 index 0000000..86cc0b0 --- /dev/null +++ b/demo/manage.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "demo.settings") + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/demo/requirements.txt b/demo/requirements.txt new file mode 100644 index 0000000..b16808d --- /dev/null +++ b/demo/requirements.txt @@ -0,0 +1,3 @@ +Django>=1.5,<1.11 +mock>=1.0.1 +six>=1.8.0 diff --git a/nocaptcha_recaptcha/__init__.py b/nocaptcha_recaptcha/__init__.py new file mode 100644 index 0000000..50404c7 --- /dev/null +++ b/nocaptcha_recaptcha/__init__.py @@ -0,0 +1,4 @@ +__version__ = '0.0.19' + +from .fields import NoReCaptchaField +from .widgets import NoReCaptchaWidget diff --git a/nocaptcha_recaptcha/_compat.py b/nocaptcha_recaptcha/_compat.py new file mode 100644 index 0000000..22c1f91 --- /dev/null +++ b/nocaptcha_recaptcha/_compat.py @@ -0,0 +1,18 @@ +import sys + +PY2 = sys.version_info[0] == 2 +if PY2: + text_type = unicode + from urllib2 import Request, urlopen + from urllib import urlencode +else: + from urllib.request import Request, urlopen + from urllib.parse import urlencode + + text_type = str + + +def want_bytes(s, encoding='utf-8', errors='strict'): + if isinstance(s, text_type): + s = s.encode(encoding, errors) + return s diff --git a/nocaptcha_recaptcha/client.py b/nocaptcha_recaptcha/client.py new file mode 100644 index 0000000..84dcadd --- /dev/null +++ b/nocaptcha_recaptcha/client.py @@ -0,0 +1,117 @@ +import logging + +import django + +try: + import json +except ImportError: + from django.utils import simplejson as json + +from django.conf import settings +from django.template.loader import render_to_string +from django.utils.translation import get_language +from django.utils.encoding import force_text + +from ._compat import want_bytes, urlencode, Request, urlopen, PY2 + +logger = logging.getLogger(__name__) + +DEFAULT_VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify" +DEFAULT_FALLBACK_URL = "https://www.google.com/recaptcha/api/fallback" +DEFAULT_WIDGET_TEMPLATE = 'nocaptcha_recaptcha/widget.html' + +VERIFY_URL = getattr(settings, "NORECAPTCHA_VERIFY_URL", + DEFAULT_VERIFY_URL) + +FALLBACK_URL = getattr(settings, "NORECAPTCHA_FALLBACK_URL", + DEFAULT_FALLBACK_URL) + +WIDGET_TEMPLATE = getattr(settings, "NORECAPTCHA_WIDGET_TEMPLATE", + DEFAULT_WIDGET_TEMPLATE) + + +class RecaptchaResponse(object): + def __init__(self, is_valid, error_codes=None): + self.is_valid = is_valid + self.error_codes = error_codes + + +def displayhtml(site_key, gtag_attrs, js_params): + """Gets the HTML to display for reCAPTCHA + + site_key -- The public api key provided by Google ReCaptcha + """ + + if 'hl' not in js_params: + js_params['hl'] = get_language()[:2] + + return render_to_string( + WIDGET_TEMPLATE, + { + 'fallback_url': FALLBACK_URL, + 'site_key': site_key, + 'js_params': js_params, + 'gtag_attrs': gtag_attrs, + }) + + +def submit(g_nocaptcha_response_value, secret_key, remoteip): + """ + Submits a reCAPTCHA request for verification. Returns RecaptchaResponse + for the request + + recaptcha_response_field -- The value of recaptcha_response_field + from the form + secret_key -- your reCAPTCHA private key + remoteip -- the user's ip address + """ + + if not (g_nocaptcha_response_value and len(g_nocaptcha_response_value)): + return RecaptchaResponse( + is_valid=False, + error_codes=['incorrect-captcha-sol'] + ) + + params = urlencode({ + 'secret': want_bytes(secret_key), + 'remoteip': want_bytes(remoteip), + 'response': want_bytes(g_nocaptcha_response_value), + }) + + if not PY2: + params = params.encode('utf-8') + + req = Request( + url=VERIFY_URL, data=params, + headers={ + 'Content-type': 'application/x-www-form-urlencoded', + 'User-agent': 'noReCAPTCHA Python' + } + ) + + httpresp = urlopen(req) + + try: + res = force_text(httpresp.read()) + return_values = json.loads(res) + except (ValueError, TypeError): + return RecaptchaResponse( + is_valid=False, + error_codes=['json-read-issue'] + ) + except: + return RecaptchaResponse( + is_valid=False, + error_codes=['unknown-network-issue'] + ) + finally: + httpresp.close() + + return_code = return_values.get("success", False) + error_codes = return_values.get('error-codes', []) + logger.debug("%s - %s" % (return_code, error_codes)) + + if return_code is True: + return RecaptchaResponse(is_valid=True) + else: + return RecaptchaResponse(is_valid=False, error_codes=error_codes) diff --git a/nocaptcha_recaptcha/fields.py b/nocaptcha_recaptcha/fields.py new file mode 100644 index 0000000..738a295 --- /dev/null +++ b/nocaptcha_recaptcha/fields.py @@ -0,0 +1,75 @@ +import os +import sys + +from django import forms +from django.conf import settings + +try: + from django.utils.encoding import smart_unicode +except ImportError: + from django.utils.encoding import smart_text as smart_unicode + +from django.core.exceptions import ValidationError +from django.utils.translation import ugettext_lazy as _ + +from . import client +from .widgets import NoReCaptchaWidget + + +class NoReCaptchaField(forms.CharField): + default_error_messages = { + 'captcha_invalid': _('Incorrect, please try again.') + } + + def __init__(self, site_key=None, secret_key=None, + gtag_attrs={}, js_params={}, *args, **kwargs): + """ + site_key = the Google provided site_key + secret_key = the Google provided secret_key + gtag_attrs = html input attributes to provide + to the g-recaptcha tag + js_params = parameters to passed to the javascript backend + + See: https://developers.google.com/recaptcha/docs/display + """ + site_key = site_key if site_key else \ + settings.NORECAPTCHA_SITE_KEY + self.secret_key = secret_key if secret_key else \ + settings.NORECAPTCHA_SECRET_KEY + + self.widget = NoReCaptchaWidget( + site_key=site_key, gtag_attrs=gtag_attrs, js_params=js_params) + self.required = True + super(NoReCaptchaField, self).__init__(*args, **kwargs) + + def get_remote_ip(self): + """ + Return the remote IP from the request. + First check the REMOTE_ADDR header and then the + HTTP_X_FORWARDED_FOR header. + """ + f = sys._getframe() + while f: + if 'request' in f.f_locals: + request = f.f_locals['request'] + if request: + remote_ip = request.META.get('REMOTE_ADDR', '') + forwarded_ip = request.META.get('HTTP_X_FORWARDED_FOR', '') + ip = remote_ip if not forwarded_ip else forwarded_ip + return ip + f = f.f_back + + def clean(self, value): + super(NoReCaptchaField, self).clean(value) + g_nocaptcha_response_value = smart_unicode(value) + if os.environ.get('NORECAPTCHA_TESTING', None) == 'True' \ + and g_nocaptcha_response_value == 'PASSED': + return value + + check_captcha = client.submit( + g_nocaptcha_response_value, secret_key=self.secret_key, + remoteip=self.get_remote_ip()) + + if not check_captcha.is_valid: + raise ValidationError(self.error_messages['captcha_invalid']) + return value diff --git a/nocaptcha_recaptcha/models.py b/nocaptcha_recaptcha/models.py new file mode 100644 index 0000000..e69de29 diff --git a/nocaptcha_recaptcha/templates/nocaptcha_recaptcha/widget.html b/nocaptcha_recaptcha/templates/nocaptcha_recaptcha/widget.html new file mode 100644 index 0000000..17d6d11 --- /dev/null +++ b/nocaptcha_recaptcha/templates/nocaptcha_recaptcha/widget.html @@ -0,0 +1,21 @@ +
+ \ No newline at end of file diff --git a/nocaptcha_recaptcha/tests.py b/nocaptcha_recaptcha/tests.py new file mode 100644 index 0000000..9b0205c --- /dev/null +++ b/nocaptcha_recaptcha/tests.py @@ -0,0 +1,79 @@ +import os +import json + +from django.forms import Form +from django.test import TestCase + +import mock + +from nocaptcha_recaptcha import fields, client + + +class TestForm(Form): + captcha = fields.NoReCaptchaField(gtag_attrs={'data-theme': 'dark'}) + + +class TestCase(TestCase): + def setUp(self): + os.environ['NORECAPTCHA_TESTING'] = 'True' + + def test_envvar_enabled(self): + form_params = {'g-recaptcha-response': 'PASSED'} + form = TestForm(form_params) + self.assertTrue(form.is_valid()) + + def test_envvar_disabled(self): + os.environ['NORECAPTCHA_TESTING'] = 'False' + form_params = {'g-recaptcha-response': 'PASSED'} + form = TestForm(form_params) + self.assertFalse(form.is_valid()) + + @mock.patch('nocaptcha_recaptcha.client.urlopen') + def test_client_submit_empty_input(self, mock_urlopen): + """ + Should return False if input is empty string + """ + result = client.submit('', '', '') + self.assertFalse(result.is_valid) + self.assertEqual(['incorrect-captcha-sol'], result.error_codes) + + @mock.patch('nocaptcha_recaptcha.client.urlopen') + def test_client_submit_correct(self, mock_urlopen): + """ + Should return True if response is correct + """ + mock_resp = mock.Mock() + mock_resp.read.return_value = json.dumps( + {'success': True, 'error-codes': []}) + mock_urlopen.return_value = mock_resp + result = client.submit('a', 'a', 'a') + self.assertTrue(result.is_valid) + self.assertEqual(result.error_codes, None) + + @mock.patch('nocaptcha_recaptcha.client.urlopen') + def test_client_submit_response_not_json(self, mock_urlopen): + """ + Should return json read error if response is not json + """ + mock_resp = mock.Mock() + mock_resp.read.return_value = "{'success': True, 'error-codes': []}" + mock_urlopen.return_value = mock_resp + result = client.submit('a', 'a', 'a') + self.assertFalse(result.is_valid) + self.assertEqual(result.error_codes, ['json-read-issue']) + + @mock.patch('nocaptcha_recaptcha.client.urlopen') + def test_client_submit_response_incorrect(self, mock_urlopen): + """ + Should return false if response is incorrect + """ + mock_resp = mock.Mock() + mock_resp.read.return_value = json.dumps( + {'success': False, 'error-codes': ['ERROR']}) + mock_urlopen.return_value = mock_resp + result = client.submit('a', 'a', 'a') + self.assertFalse(result.is_valid) + self.assertEqual(result.error_codes, ['ERROR']) + + def tearDown(self): + del os.environ['NORECAPTCHA_TESTING'] diff --git a/nocaptcha_recaptcha/views.py b/nocaptcha_recaptcha/views.py new file mode 100644 index 0000000..e69de29 diff --git a/nocaptcha_recaptcha/widgets.py b/nocaptcha_recaptcha/widgets.py new file mode 100644 index 0000000..95380e5 --- /dev/null +++ b/nocaptcha_recaptcha/widgets.py @@ -0,0 +1,24 @@ +from django import forms +from django.conf import settings +from django.utils.safestring import mark_safe + +from . import client + + +class NoReCaptchaWidget(forms.widgets.Widget): + g_nocaptcha_response = 'g-recaptcha-response' + + def __init__(self, site_key=None, + gtag_attrs={}, js_params={}, *args, **kwargs): + self.site_key = site_key if site_key else \ + settings.NORECAPTCHA_SITE_KEY + super(NoReCaptchaWidget, self).__init__(*args, **kwargs) + self.gtag_attrs = gtag_attrs + self.js_params = js_params + + def render(self, name, value, gtag_attrs=None, **kwargs): + return mark_safe(u'%s' % client.displayhtml( + self.site_key, self.gtag_attrs, self.js_params)) + + def value_from_datadict(self, data, files, name): + return data.get(self.g_nocaptcha_response, None) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..40c19d7 --- /dev/null +++ b/setup.py @@ -0,0 +1,38 @@ +import os +from setuptools import setup, find_packages + + +def read(fname): + return open(os.path.join(os.path.dirname(__file__), fname)).read() + + +setup( + name='django-nocaptcha-recaptcha', + version='0.0.20', + description='Django nocaptcha recaptcha form field/widget app.', + long_description=read('README.md'), + author='Imaginary Landscape', + author_email='jjasinski@imgescape.com', + keywords=['django', 'recaptcha', 'field', 'nocaptcha'], + license='BSD', + url='https://github.com/ImaginaryLandscape/django-nocaptcha-recaptcha', + packages=find_packages(), + tests_require=[ + 'mock', + ], + test_suite="setuptest.setuptest.SetupTestSuite", + include_package_data=True, + classifiers=[ + "Framework :: Django", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Intended Audience :: Developers", + "Programming Language :: Python :: 2.6", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3.4", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Topic :: Internet :: WWW/HTTP :: Dynamic Content", + ], + zip_safe=False, +) diff --git a/test_settings.py b/test_settings.py new file mode 100644 index 0000000..4ff3137 --- /dev/null +++ b/test_settings.py @@ -0,0 +1,23 @@ +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': 'test.sqlite', + } +} + +INSTALLED_APPS = [ + 'nocaptcha_recaptcha', +] + +MIDDLEWARE_CLASSES = [ + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.locale.LocaleMiddleware', + 'django.middleware.doc.XViewMiddleware', + 'django.middleware.common.CommonMiddleware', +] + +NORECAPTCHA_SECRET_KEY = 'privkey' +NORECAPTCHA_SITE_KEY = 'pubkey'