Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/v2 wizard support #196

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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