diff --git a/benefits/core/context_processors.py b/benefits/core/context_processors.py index d9120a861..9c39beaf3 100644 --- a/benefits/core/context_processors.py +++ b/benefits/core/context_processors.py @@ -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, - } - } diff --git a/benefits/core/middleware.py b/benefits/core/middleware.py index 2e24728f2..42e2d0331 100644 --- a/benefits/core/middleware.py +++ b/benefits/core/middleware.py @@ -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__) @@ -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 diff --git a/benefits/core/recaptcha.py b/benefits/core/recaptcha.py index dd3a9a036..e6f19592c 100644 --- a/benefits/core/recaptcha.py +++ b/benefits/core/recaptcha.py @@ -6,7 +6,7 @@ from django.conf import settings -_POST_DATA = "g-recaptcha-response" +DATA_FIELD = "g-recaptcha-response" def has_error(form) -> bool: @@ -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"]) diff --git a/benefits/core/templates/core/base.html b/benefits/core/templates/core/base.html index ac0446706..a40889acb 100644 --- a/benefits/core/templates/core/base.html +++ b/benefits/core/templates/core/base.html @@ -129,20 +129,22 @@

{{ page.headline }}

+ + {% if request.recaptcha %}{% endif %} diff --git a/benefits/core/templates/core/includes/form.html b/benefits/core/templates/core/includes/form.html index f1cfa935e..20cad6c39 100644 --- a/benefits/core/templates/core/includes/form.html +++ b/benefits/core/templates/core/includes/form.html @@ -3,7 +3,7 @@ {% url form.action_url as form_action %} -
+ {% csrf_token %}
@@ -35,42 +35,60 @@
{% endif %}
- {% if recaptcha.enabled %} - - {% else %} - - {% endif %} +
{% endif %} - {% if recaptcha.enabled %} - + + + {% 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 %} + + {% endif %} -
- {% endif %} diff --git a/benefits/eligibility/forms.py b/benefits/eligibility/forms.py index 09f191f13..d220ec98a 100644 --- a/benefits/eligibility/forms.py +++ b/benefits/eligibility/forms.py @@ -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) @@ -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") diff --git a/benefits/eligibility/views.py b/benefits/eligibility/views.py index 11bc18074..86e00ed14 100644 --- a/benefits/eligibility/views.py +++ b/benefits/eligibility/views.py @@ -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 @@ -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" @@ -29,6 +30,7 @@ @decorator_from_middleware(AgencySessionRequired) +@decorator_from_middleware(RecaptchaEnabled) def index(request): """View handler for the eligibility verifier selection form.""" @@ -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: @@ -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.""" @@ -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) @@ -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( @@ -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) diff --git a/benefits/enrollment/forms.py b/benefits/enrollment/forms.py index edd135e30..cf619c785 100644 --- a/benefits/enrollment/forms.py +++ b/benefits/enrollment/forms.py @@ -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 @@ -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): diff --git a/benefits/settings.py b/benefits/settings.py index 15e942383..c4b86d355 100644 --- a/benefits/settings.py +++ b/benefits/settings.py @@ -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: @@ -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)) diff --git a/tests/pytest/eligibility/test_views.py b/tests/pytest/eligibility/test_views.py index f8b425513..3a1013739 100644 --- a/tests/pytest/eligibility/test_views.py +++ b/tests/pytest/eligibility/test_views.py @@ -11,6 +11,7 @@ ROUTE_LOGIN, ROUTE_CONFIRM, ROUTE_ENROLLMENT, + ROUTE_UNVERIFIED, TEMPLATE_INDEX, TEMPLATE_CONFIRM, TEMPLATE_UNVERIFIED, @@ -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 @@ -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 @@ -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