Skip to content

Commit

Permalink
Fix: reCAPTCHA + HTML5 form validation (#1071)
Browse files Browse the repository at this point in the history
  • Loading branch information
thekaveman authored Oct 24, 2022
2 parents 506cd80 + e783ce1 commit fe247eb
Show file tree
Hide file tree
Showing 10 changed files with 118 additions and 70 deletions.
11 changes: 0 additions & 11 deletions benefits/core/context_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,3 @@ def authentication(request):
def debug(request):
"""Context processor adds debug information to request context."""
return {"debug": session.context_dict(request)}


def recaptcha(request):
"""Context processor adds recaptcha information to request context."""
return {
"recaptcha": {
"api_url": settings.RECAPTCHA_API_URL,
"enabled": settings.RECAPTCHA_ENABLED,
"site_key": settings.RECAPTCHA_SITE_KEY,
}
}
15 changes: 14 additions & 1 deletion benefits/core/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from django.utils.deprecation import MiddlewareMixin
from django.views import i18n

from . import analytics, session, viewmodels
from . import analytics, recaptcha, session, viewmodels


logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -174,3 +174,16 @@ def process_exception(self, request, exception):
self.azure_logger.exception(msg, exc_info=exception)

return None


class RecaptchaEnabled(MiddlewareMixin):
"""Middleware configures the request with required reCAPTCHA settings."""

def process_request(self, request):
if settings.RECAPTCHA_ENABLED:
request.recaptcha = {
"data_field": recaptcha.DATA_FIELD,
"script_api": settings.RECAPTCHA_API_KEY_URL,
"site_key": settings.RECAPTCHA_SITE_KEY,
}
return None
6 changes: 3 additions & 3 deletions benefits/core/recaptcha.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from django.conf import settings


_POST_DATA = "g-recaptcha-response"
DATA_FIELD = "g-recaptcha-response"


def has_error(form) -> bool:
Expand All @@ -22,10 +22,10 @@ def verify(form_data: dict) -> bool:
if not settings.RECAPTCHA_ENABLED:
return True

if not form_data or _POST_DATA not in form_data:
if not form_data or DATA_FIELD not in form_data:
return False

payload = dict(secret=settings.RECAPTCHA_SECRET_KEY, response=form_data[_POST_DATA])
payload = dict(secret=settings.RECAPTCHA_SECRET_KEY, response=form_data[DATA_FIELD])
response = requests.post(settings.RECAPTCHA_VERIFY_URL, payload).json()

return bool(response["success"])
28 changes: 15 additions & 13 deletions benefits/core/templates/core/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -129,20 +129,22 @@ <h1>{{ page.headline }}</h1>
<script src="https://california.azureedge.net/cdt/statetemplate/6.0.7/js/cagov.core.min.js"></script>

<script>
$(function() {
document.cookie = "testcookie"
if (document.cookie.indexOf("testcookie") < 0) {
$(".nocookies").removeClass("d-none");
}
else {
document.cookie = "testcookie; expires=Thu, 01-Jan-1970 00:00:01 GMT";
$(".nocookies").addClass("d-none");
}
$(function() {
document.cookie = "testcookie"
if (document.cookie.indexOf("testcookie") < 0) {
$(".nocookies").removeClass("d-none");
}
else {
document.cookie = "testcookie; expires=Thu, 01-Jan-1970 00:00:01 GMT";
$(".nocookies").addClass("d-none");
}

$("a[href^='https'], a[href^='tel'], [href*='#']").on("click", function(e) {
amplitude.getInstance().logEvent('clicked link', {'href': e.target.href, 'path': window.location.pathname});
});
});
$("a[href^='https'], a[href^='tel'], [href*='#']").on("click", function(e) {
amplitude.getInstance().logEvent('clicked link', {'href': e.target.href, 'path': window.location.pathname});
});
});
</script>

{% if request.recaptcha %}<script src="{{ request.recaptcha.script_api }}"></script>{% endif %}
</body>
</html>
78 changes: 48 additions & 30 deletions benefits/core/templates/core/includes/form.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

{% url form.action_url as form_action %}

<form action="{{ form_action }}" method="{{ form.method | default:"post" | upper }}" role="form">
<form id="{{ form.id }}" action="{{ form_action }}" method="{{ form.method | default:"post" | upper }}" role="form">
{% csrf_token %}

<div class="row form-container">
Expand Down Expand Up @@ -35,42 +35,60 @@
</div>
{% endif %}
<div class="col-lg-3 offset-2 offset-sm-2 offset-lg-0 col-sm-8 col-8">
{% if recaptcha.enabled %}
<button class="btn btn-lg btn-primary g-recaptcha"
data-sitekey="{{ recaptcha.site_key }}"
data-callback="onSubmit"
data-action="submit">
{{ form.submit_value }}
</button>
{% else %}
<button class="btn btn-lg btn-primary" data-action="submit">{{ form.submit_value }}</button>
{% endif %}
<button class="btn btn-lg btn-primary" form="{{ form.id }}" data-action="submit" type="submit">
{{ form.submit_value }}
</button>
</div>
</div>
{% endif %}

{% if recaptcha.enabled %}
<script src="{{ recaptcha.api_url }}"></script>
<script>
$(function(){
$("#{{ form.id }}").on("submit", function(e) {
if ("{{ form.submitting_value }}" !== "") {
$("button[type=submit]", this)
.attr("role", "status")
.attr("disabled", "true")
.text("{{ form.submitting_value }}")
.addClass("disabled");
}
});
});
</script>

{% if request.recaptcha %}
{% comment %}
Adapted from https://stackoverflow.com/a/63290578/453168
{% endcomment %}

{% comment %}
hidden input field will later send g-recaptcha token back to server
{% endcomment %}
<input type="hidden" name="{{ request.recaptcha.data_field }}" value="">

<script>
function onSubmit(token) {
$("form[action='{{form_action}}']").submit();
}
function recaptchaSubmit($event) {
// Checks the validity of the form. Return if invalid; HTML5 validation errors should display
if (!$event.target.form.checkValidity()) {
return;
}
// form is client-side valid; taking over the remainder of processing
$event.preventDefault();

grecaptcha.ready(function() {
grecaptcha.execute("{{ request.recaptcha.site_key }}", { action: "submit" }).then(function(token) {
// Add the token to hidden form element
$event.target.form.elements["{{ request.recaptcha.data_field }}"].value = token;
// submit the form to server
$event.target.form.submit();
});
});
};

// bind the above handler to button click
$("button[type=submit]", "#{{ form.id }}").on("click", recaptchaSubmit);
</script>
{% endif %}

<script>
$(function(){
$("form[action='{{form_action}}']").on("submit", function(e) {
if ("{{ form.submitting_value }}" !== "") {
$("button[data-action='submit']", this)
.attr("role", "status")
.attr("disabled", "true")
.text("{{ form.submitting_value }}")
.addClass("disabled");
}
});
});
</script>
</form>

{% endif %}
6 changes: 6 additions & 0 deletions benefits/eligibility/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class EligibilityVerifierSelectionForm(forms.Form):
"""Form to capture eligibility verifier selection."""

action_url = "eligibility:index"
id = "form-verifier-selection"
method = "POST"

verifier = forms.ChoiceField(label="", widget=widgets.VerifierRadioSelect)
Expand All @@ -32,11 +33,16 @@ def __init__(self, agency: models.TransitAgency, *args, **kwargs):
v.id: _(v.selection_label_description) for v in verifiers if v.selection_label_description
}

def clean(self):
if not recaptcha.verify(self.data):
raise forms.ValidationError("reCAPTCHA failed")


class EligibilityVerificationForm(forms.Form):
"""Form to collect eligibility verification details."""

action_url = "eligibility:confirm"
id = "form-eligibility-verification"
method = "POST"

submit_value = _("eligibility.forms.confirm.submit")
Expand Down
13 changes: 10 additions & 3 deletions benefits/eligibility/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from django.utils.translation import pgettext, gettext as _

from benefits.core import recaptcha, session, viewmodels
from benefits.core.middleware import AgencySessionRequired, LoginRequired, RateLimit, VerifierSessionRequired
from benefits.core.middleware import AgencySessionRequired, LoginRequired, RateLimit, RecaptchaEnabled, VerifierSessionRequired
from benefits.core.models import EligibilityVerifier
from benefits.core.views import ROUTE_HELP
from . import analytics, forms, verify
Expand All @@ -20,6 +20,7 @@
ROUTE_START = "eligibility:start"
ROUTE_LOGIN = "oauth:login"
ROUTE_CONFIRM = "eligibility:confirm"
ROUTE_UNVERIFIED = "eligibility:unverified"
ROUTE_ENROLLMENT = "enrollment:index"

TEMPLATE_INDEX = "eligibility/index.html"
Expand All @@ -29,6 +30,7 @@


@decorator_from_middleware(AgencySessionRequired)
@decorator_from_middleware(RecaptchaEnabled)
def index(request):
"""View handler for the eligibility verifier selection form."""

Expand Down Expand Up @@ -65,6 +67,8 @@ def index(request):
response = redirect(eligibility_start)
else:
# form was not valid, allow for correction/resubmission
if recaptcha.has_error(form):
messages.error(request, "Recaptcha failed. Please try again.")
page.forms = [form]
response = TemplateResponse(request, TEMPLATE_INDEX, ctx)
else:
Expand Down Expand Up @@ -138,6 +142,7 @@ def start(request):
@decorator_from_middleware(AgencySessionRequired)
@decorator_from_middleware(LoginRequired)
@decorator_from_middleware(RateLimit)
@decorator_from_middleware(RecaptchaEnabled)
@decorator_from_middleware(VerifierSessionRequired)
def confirm(request):
"""View handler for the eligibility verification form."""
Expand All @@ -147,6 +152,8 @@ def confirm(request):
eligibility = session.eligibility(request)
return verified(request, [eligibility.name])

unverified_view = reverse(ROUTE_UNVERIFIED)

agency = session.agency(request)
verifier = session.verifier(request)
types_to_verify = verify.typenames_to_verify(agency, verifier)
Expand All @@ -159,7 +166,7 @@ def confirm(request):
if verified_types:
return verified(request, verified_types)
else:
return unverified(request)
return redirect(unverified_view)

# GET/POST for Eligibility API verification
page = viewmodels.Page(
Expand Down Expand Up @@ -200,7 +207,7 @@ def confirm(request):
return TemplateResponse(request, TEMPLATE_CONFIRM, ctx)
# no types were verified
elif len(verified_types) == 0:
return unverified(request)
return redirect(unverified_view)
# type(s) were verified
else:
return verified(request, verified_types)
Expand Down
2 changes: 2 additions & 0 deletions benefits/enrollment/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class CardTokenizeSuccessForm(forms.Form):
"""Form to bring client card token back to server."""

action_url = "enrollment:index"
id = "form-card-tokenize-success"
method = "POST"

# hidden input with no label
Expand All @@ -17,6 +18,7 @@ class CardTokenizeSuccessForm(forms.Form):
class CardTokenizeFailForm(forms.Form):
"""Form to indicate card tokenization failure to server."""

id = "form-card-tokenize-fail"
method = "POST"

def __init__(self, action_url, *args, **kwargs):
Expand Down
2 changes: 1 addition & 1 deletion benefits/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,6 @@ def _filter_empty(ls):
"django.contrib.messages.context_processors.messages",
"benefits.core.context_processors.analytics",
"benefits.core.context_processors.authentication",
"benefits.core.context_processors.recaptcha",
]

if DEBUG:
Expand Down Expand Up @@ -250,6 +249,7 @@ def _filter_empty(ls):

RECAPTCHA_API_URL = os.environ.get("DJANGO_RECAPTCHA_API_URL", "https://www.google.com/recaptcha/api.js")
RECAPTCHA_SITE_KEY = os.environ.get("DJANGO_RECAPTCHA_SITE_KEY")
RECAPTCHA_API_KEY_URL = f"{RECAPTCHA_API_URL}?render={RECAPTCHA_SITE_KEY}"
RECAPTCHA_SECRET_KEY = os.environ.get("DJANGO_RECAPTCHA_SECRET_KEY")
RECAPTCHA_VERIFY_URL = os.environ.get("DJANGO_RECAPTCHA_VERIFY_URL", "https://www.google.com/recaptcha/api/siteverify")
RECAPTCHA_ENABLED = all((RECAPTCHA_API_URL, RECAPTCHA_SITE_KEY, RECAPTCHA_SECRET_KEY, RECAPTCHA_VERIFY_URL))
Expand Down
27 changes: 19 additions & 8 deletions tests/pytest/eligibility/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
ROUTE_LOGIN,
ROUTE_CONFIRM,
ROUTE_ENROLLMENT,
ROUTE_UNVERIFIED,
TEMPLATE_INDEX,
TEMPLATE_CONFIRM,
TEMPLATE_UNVERIFIED,
Expand Down Expand Up @@ -222,15 +223,14 @@ def test_confirm_get_oauth_verified(mocker, client, model_EligibilityType, mocke
@pytest.mark.usefixtures(
"mocked_session_agency", "mocked_session_verifier_oauth", "mocked_session_oauth_token", "mocked_session_update"
)
def test_confirm_get_oauth_unverified(mocker, client, mocked_analytics_module):
def test_confirm_get_oauth_unverified(mocker, client):
mocker.patch("benefits.eligibility.verify.eligibility_from_oauth", return_value=[])

path = reverse(ROUTE_CONFIRM)
response = client.get(path)

mocked_analytics_module.returned_fail.assert_called_once()
assert response.status_code == 200
assert response.template_name == TEMPLATE_UNVERIFIED
assert response.status_code == 302
assert response.url == reverse(ROUTE_UNVERIFIED)


@pytest.mark.django_db
Expand Down Expand Up @@ -273,15 +273,14 @@ def test_confirm_post_valid_form_eligibility_error(mocker, client, form_data, mo

@pytest.mark.django_db
@pytest.mark.usefixtures("mocked_eligibility_auth_request")
def test_confirm_post_valid_form_eligibility_unverified(mocker, client, form_data, mocked_analytics_module):
def test_confirm_post_valid_form_eligibility_unverified(mocker, client, form_data):
mocker.patch("benefits.eligibility.verify.eligibility_from_api", return_value=[])

path = reverse(ROUTE_CONFIRM)
response = client.post(path, form_data)

mocked_analytics_module.returned_fail.assert_called_once()
assert response.status_code == 200
assert response.template_name == TEMPLATE_UNVERIFIED
assert response.status_code == 302
assert response.url == reverse(ROUTE_UNVERIFIED)


@pytest.mark.django_db
Expand All @@ -299,3 +298,15 @@ def test_confirm_post_valid_form_eligibility_verified(
mocked_analytics_module.returned_success.assert_called_once()
assert response.status_code == 302
assert response.url == reverse(ROUTE_ENROLLMENT)


@pytest.mark.django_db
@pytest.mark.usefixtures("mocked_eligibility_request_session")
def test_unverified(client, mocked_analytics_module):
path = reverse(ROUTE_UNVERIFIED)

response = client.get(path)

mocked_analytics_module.returned_fail.assert_called_once()
assert response.status_code == 200
assert response.template_name == TEMPLATE_UNVERIFIED

0 comments on commit fe247eb

Please sign in to comment.