Skip to content

Commit

Permalink
Merge 0034f0b into 94ad0f3
Browse files Browse the repository at this point in the history
  • Loading branch information
AltusBarry committed Aug 20, 2019
2 parents 94ad0f3 + 0034f0b commit 5d924fe
Show file tree
Hide file tree
Showing 11 changed files with 278 additions and 21 deletions.
73 changes: 65 additions & 8 deletions captcha/fields.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import hashlib
import logging
import os
import socket
Expand All @@ -6,8 +7,8 @@

from django import forms
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.core.exceptions import ValidationError
from django.core import signing
from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.utils.encoding import force_text
from django.utils.translation import ugettext_lazy as _

Expand All @@ -26,8 +27,11 @@ class ReCaptchaField(forms.CharField):
"captcha_invalid": _("Error verifying reCAPTCHA, please try again."),
"captcha_error": _("Error verifying reCAPTCHA, please try again."),
}
cache_key_salt = "recaptcha_field_result_cache"
cache_key_base = "%s-captcha-cached-result"

def __init__(self, public_key=None, private_key=None, *args, **kwargs):
def __init__(self, public_key=None, private_key=None,
wizard_persist_is_valid=None, *args, **kwargs):
"""
ReCaptchaField can accepts attributes which is a dictionary of
attributes to be passed to the ReCaptcha widget class. The widget will
Expand All @@ -45,6 +49,7 @@ def __init__(self, public_key=None, private_key=None, *args, **kwargs):

# reCAPTCHA fields are always required.
self.required = True
self.wizard_persist_is_valid = wizard_persist_is_valid or False

# Setup instance variables.
self.private_key = private_key or getattr(
Expand All @@ -55,18 +60,67 @@ def __init__(self, public_key=None, private_key=None, *args, **kwargs):
# Update widget attrs with data-sitekey.
self.widget.attrs["data-sitekey"] = self.public_key

def get_remote_ip(self):
def request(self):
f = sys._getframe()
while f:
request = f.f_locals.get("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
return request
f = f.f_back
return None

def get_remote_ip(self):
request = self.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

def _cache_key(self, path):
return hashlib.sha256(
(self.cache_key_base % path).encode("utf-8")
).hexdigest()

def _get_result(self):
is_valid = False
request = self.request()
token = request.session.get(
self._cache_key(request.get_full_path()), None
)
if not token:
return is_valid

# Make use of the signing package to ensure the token has not expired.
try:
# TODO: max_age, global setting or field kwarg
is_valid = signing.loads(
token,
salt=self.cache_key_salt,
max_age=10
)
except signing.SignatureExpired:
return is_valid

return is_valid

def _set_result(self):
request = self.request()
token = signing.dumps(
True,
salt=self.cache_key_salt
)
request.session[self._cache_key(request.get_full_path())] = token

def validate(self, value):
# Do not do any further validation. This field has already
# been validated successfully.
# NOTE: Needs to happen before super, not all the widget templates have
# inputs that actually get updated, as such required and additional
# checks will also fail.
if self.wizard_persist_is_valid and self._get_result() is True:
return None

super(ReCaptchaField, self).validate(value)

try:
Expand Down Expand Up @@ -116,3 +170,6 @@ def validate(self, value):
self.error_messages["captcha_invalid"],
code="captcha_invalid"
)

if self.wizard_persist_is_valid:
self._set_result()
8 changes: 8 additions & 0 deletions captcha/tests/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from django import forms

from captcha import fields


class TestWizardRecaptchaForm(forms.Form):
charfield = forms.CharField()
captcha = fields.ReCaptchaField(wizard_persist_is_valid=True)
1 change: 1 addition & 0 deletions captcha/tests/requirements/py35.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
mock
16 changes: 9 additions & 7 deletions captcha/tests/settings/coveralls_settings.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': 'test.sqlite',
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": "test.sqlite",
}
}

INSTALLED_APPS = [
'captcha',
"django.contrib.admin",
"captcha",
"captcha.tests"
]

RECAPTCHA_PRIVATE_KEY = 'privkey'
RECAPTCHA_PUBLIC_KEY = 'pubkey'
RECAPTCHA_PRIVATE_KEY = "privkey"
RECAPTCHA_PUBLIC_KEY = "pubkey"

SECRET_KEY = 'SECRET_KEY'
SECRET_KEY = "SECRET_KEY"
108 changes: 108 additions & 0 deletions captcha/tests/settings/django.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import os

# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "!giky)2zz^n&z0=ump8!#)2d1xy#8r(!%_q9gnfaavc6^tqa$q"

ALLOWED_HOSTS = []

RECAPTCHA_PRIVATE_KEY = "privkey"
RECAPTCHA_PUBLIC_KEY = "pubkey"


# Application definition

INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"captcha",
"captcha.tests",
]

MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]

ROOT_URLCONF = "captcha.tests.urls"

TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]


# Database
# https://docs.djangoproject.com/en/2.1/ref/settings/#databases

DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": os.path.join(BASE_DIR, "db.sqlite3"),
}
}


# Password validation
# https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators

AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]


# Internationalization
# https://docs.djangoproject.com/en/2.1/topics/i18n/

LANGUAGE_CODE = "en-us"

TIME_ZONE = "UTC"

USE_I18N = True

USE_L10N = True

USE_TZ = True


# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.1/howto/static-files/

STATIC_URL = "/static/"
5 changes: 5 additions & 0 deletions captcha/tests/templates/form.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<form method="post" class="Form" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<input type="submit" value="Submit" class="Button" />
</form>
6 changes: 4 additions & 2 deletions captcha/tests/test_client.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import uuid

try:
from unittest.mock import patch, PropertyMock, MagicMock
except ImportError:
# Python 2.7 does not have the mock module included, Python 3.5 has some
# features missing. We only install mock for those two versions.
from mock import patch, PropertyMock, MagicMock
except ImportError:
from unittest.mock import patch, PropertyMock, MagicMock

from django import forms
from django.conf import settings
Expand Down
43 changes: 41 additions & 2 deletions captcha/tests/test_fields.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import hashlib
import os
import uuid
import warnings

try:
from unittest.mock import patch, PropertyMock, MagicMock
except ImportError:
# Python 2.7 does not have the mock module included, Python 3.5 has some
# features missing. We only install mock for those two versions.
from mock import patch, PropertyMock, MagicMock
except ImportError:
from unittest.mock import patch, PropertyMock, MagicMock

from django import forms
from django.core.exceptions import ImproperlyConfigured
#from django.core.urlresolvers import reverse
from django.urls import reverse
from django.test import TestCase, override_settings

from captcha import fields, widgets, constants
Expand All @@ -28,6 +33,7 @@ def test_client_success_response(self, mocked_submit):
form_params = {"g-recaptcha-response": "PASSED"}
form = DefaultForm(form_params)
self.assertTrue(form.is_valid())
mocked_submit.assert_called()

@patch("captcha.fields.client.submit")
def test_client_failure_response(self, mocked_submit):
Expand Down Expand Up @@ -392,3 +398,36 @@ class VThreeDomainForm(forms.Form):
form_params = {"captcha": "PASSED"}
form = VThreeDomainForm(form_params)
self.assertFalse(form.is_valid())


class TestFieldsWithRequests(TestCase):

@patch("captcha.fields.client.submit")
def test_client_wizard_response(self, mocked_submit):
mocked_submit.return_value = RecaptchaResponse(is_valid=True)
data = {
"charfield": "field",
"g-recaptcha-response": "PASSED",
}
response = self.client.post(
reverse("form"),
data=data,
)
mocked_submit.assert_called()
key = hashlib.sha256(
("%s-captcha-cached-result" % reverse("form")).encode("utf-8")
).hexdigest()
# TODO: test session value as well, should be True
self.assertIn(key, self.client.session.keys())
data = {
"charfield": "field",
"g-recaptcha-response": "PASSED",
}
response = self.client.post(
reverse("form"),
data=data,
)

# Submit should only be called if the validation was not halted due to
# it having been validated already.
mocked_submit.assert_called_once()
33 changes: 33 additions & 0 deletions captcha/tests/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
try:
from django.urls import re_path
except ImportError:
from django.conf.urls import url as re_path

from django.contrib import admin
from django.views.generic.edit import FormView

from captcha.tests.forms import TestWizardRecaptchaForm

#django < 2
#urlpatterns = [
# path("admin/", admin.site.urls),
# path(r"^form/$",
# FormView.as_view(
# form_class=TestWizardRecaptchaForm,
# template_name="test_form.html",
# success_url="/admin"
# ),
# name="form"
# )
#]
urlpatterns = [
re_path(r"admin/", admin.site.urls),
re_path(r"form/$",
FormView.as_view(
form_class=TestWizardRecaptchaForm,
template_name="test_form.html",
success_url="/admin"
),
name="form"
)
]
Empty file added captcha/tests/views.py
Empty file.
6 changes: 4 additions & 2 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ deps =
django21: Django<2.2
django22: Django<3.0
py27: -rcaptcha/tests/requirements/py27.txt
py35: -rcaptcha/tests/requirements/py35.txt
py357: -rcaptcha/tests/requirements/py35.txt
pycodestyle: pycodestyle
commands =
django{111,2,21,22}: coverage run manage.py test
pycodestyle: pycodestyle captcha/
django{111,2,21,22}: coverage run manage.py test --settings="captcha.tests.settings.django"
pycodestyle: pycodestyle captcha/ --exclude=tests

0 comments on commit 5d924fe

Please sign in to comment.