From 30910326375937cf3def9f1b3baec1b5f211d96b Mon Sep 17 00:00:00 2001 From: Dustin Ingram <di@users.noreply.github.com> Date: Fri, 17 Mar 2023 21:48:14 +0000 Subject: [PATCH 1/4] Add recaptcha to registration form --- dev/environment | 4 + tests/unit/accounts/test_forms.py | 82 +++++- tests/unit/test_config.py | 1 + tests/unit/test_recaptcha.py | 265 ++++++++++++++++++ warehouse/accounts/forms.py | 17 +- warehouse/accounts/views.py | 11 +- warehouse/config.py | 5 + warehouse/recaptcha.py | 155 ++++++++++ warehouse/templates/accounts/register.html | 7 + .../templates/includes/input-recaptcha.html | 31 ++ 10 files changed, 574 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_recaptcha.py create mode 100644 warehouse/recaptcha.py create mode 100644 warehouse/templates/includes/input-recaptcha.html diff --git a/dev/environment b/dev/environment index d562953550fa..90362c7b78fb 100644 --- a/dev/environment +++ b/dev/environment @@ -62,3 +62,7 @@ TWOFACTORMANDATE_AVAILABLE=true TWOFACTORMANDATE_ENABLED=true OIDC_ENABLED=true OIDC_AUDIENCE=pypi + +# Default to the reCAPTCHA testing keys from https://developers.google.com/recaptcha/docs/faq +RECAPTCHA_SITE_KEY=6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI +RECAPTCHA_SECRET_KEY=6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe diff --git a/tests/unit/accounts/test_forms.py b/tests/unit/accounts/test_forms.py index cd4fdce1af32..583a09425c5d 100644 --- a/tests/unit/accounts/test_forms.py +++ b/tests/unit/accounts/test_forms.py @@ -16,6 +16,7 @@ import pytest import wtforms +from warehouse import recaptcha from warehouse.accounts import forms from warehouse.accounts.interfaces import ( BurnedRecoveryCode, @@ -344,12 +345,18 @@ def test_validate_password_notok_ip_banned(self, db_session): class TestRegistrationForm: def test_create(self): user_service = pretend.stub() + recaptcha_service = pretend.stub(enabled=True) breach_service = pretend.stub() form = forms.RegistrationForm( - data={}, user_service=user_service, breach_service=breach_service + data={}, + user_service=user_service, + recaptcha_service=recaptcha_service, + breach_service=breach_service, ) + assert form.user_service is user_service + assert form.recaptcha_service is recaptcha_service def test_password_confirm_required_error(self): form = forms.RegistrationForm( @@ -357,6 +364,7 @@ def test_password_confirm_required_error(self): user_service=pretend.stub( find_userid_by_email=pretend.call_recorder(lambda _: pretend.stub()) ), + recaptcha_service=pretend.stub(enabled=True), breach_service=pretend.stub(check_password=lambda pw: False), ) @@ -370,6 +378,7 @@ def test_passwords_mismatch_error(self, pyramid_config): form = forms.RegistrationForm( data={"new_password": "password", "password_confirm": "mismatch"}, user_service=user_service, + recaptcha_service=pretend.stub(enabled=True), breach_service=pretend.stub(check_password=lambda pw, tags=None: False), ) @@ -389,6 +398,7 @@ def test_passwords_match_success(self): "password_confirm": "MyStr0ng!shPassword", }, user_service=user_service, + recaptcha_service=pretend.stub(enabled=True), breach_service=pretend.stub(check_password=lambda pw, tags=None: False), ) @@ -402,6 +412,7 @@ def test_email_required_error(self): user_service=pretend.stub( find_userid_by_email=pretend.call_recorder(lambda _: pretend.stub()) ), + recaptcha_service=pretend.stub(enabled=True), breach_service=pretend.stub(check_password=lambda pw, tags=None: False), ) @@ -415,6 +426,7 @@ def test_invalid_email_error(self, pyramid_config, email): user_service=pretend.stub( find_userid_by_email=pretend.call_recorder(lambda _: None) ), + recaptcha_service=pretend.stub(enabled=True), breach_service=pretend.stub(check_password=lambda pw, tags=None: False), ) @@ -429,6 +441,7 @@ def test_exotic_email_success(self): user_service=pretend.stub( find_userid_by_email=pretend.call_recorder(lambda _: None) ), + recaptcha_service=pretend.stub(enabled=True), breach_service=pretend.stub(check_password=lambda pw, tags=None: False), ) @@ -441,6 +454,7 @@ def test_email_exists_error(self, pyramid_config): user_service=pretend.stub( find_userid_by_email=pretend.call_recorder(lambda _: pretend.stub()) ), + recaptcha_service=pretend.stub(enabled=True), breach_service=pretend.stub(check_password=lambda pw, tags=None: False), ) @@ -457,6 +471,7 @@ def test_prohibited_email_error(self, pyramid_config): user_service=pretend.stub( find_userid_by_email=pretend.call_recorder(lambda _: None) ), + recaptcha_service=pretend.stub(enabled=True), breach_service=pretend.stub(check_password=lambda pw, tags=None: False), ) @@ -467,6 +482,47 @@ def test_prohibited_email_error(self, pyramid_config): "different email." ) + def test_recaptcha_disabled(self): + form = forms.RegistrationForm( + data={"g_recpatcha_response": ""}, + user_service=pretend.stub(), + recaptcha_service=pretend.stub( + enabled=False, + verify_response=pretend.call_recorder(lambda _: None), + ), + breach_service=pretend.stub(check_password=lambda pw, tags=None: False), + ) + assert not form.validate() + # there shouldn't be any errors for the recaptcha field if it's + # disabled + assert not form.g_recaptcha_response.errors + + def test_recaptcha_required_error(self): + form = forms.RegistrationForm( + data={"g_recaptcha_response": ""}, + user_service=pretend.stub(), + recaptcha_service=pretend.stub( + enabled=True, + verify_response=pretend.call_recorder(lambda _: None), + ), + breach_service=pretend.stub(check_password=lambda pw, tags=None: False), + ) + assert not form.validate() + assert form.g_recaptcha_response.errors.pop() == "Recaptcha error." + + def test_recaptcha_error(self): + form = forms.RegistrationForm( + data={"g_recaptcha_response": "asd"}, + user_service=pretend.stub(), + recaptcha_service=pretend.stub( + verify_response=pretend.raiser(recaptcha.RecaptchaError), + enabled=True, + ), + breach_service=pretend.stub(check_password=lambda pw, tags=None: False), + ) + assert not form.validate() + assert form.g_recaptcha_response.errors.pop() == "Recaptcha error." + def test_username_exists(self, pyramid_config): form = forms.RegistrationForm( data={"username": "foo"}, @@ -474,6 +530,10 @@ def test_username_exists(self, pyramid_config): find_userid=pretend.call_recorder(lambda name: 1), username_is_prohibited=lambda a: False, ), + recaptcha_service=pretend.stub( + enabled=False, + verify_response=pretend.call_recorder(lambda _: None), + ), breach_service=pretend.stub(check_password=lambda pw, tags=None: False), ) assert not form.validate() @@ -489,6 +549,10 @@ def test_username_prohibted(self, pyramid_config): user_service=pretend.stub( username_is_prohibited=lambda a: True, ), + recaptcha_service=pretend.stub( + enabled=False, + verify_response=pretend.call_recorder(lambda _: None), + ), breach_service=pretend.stub(check_password=lambda pw, tags=None: False), ) assert not form.validate() @@ -506,6 +570,10 @@ def test_username_is_valid(self, username, pyramid_config): find_userid=pretend.call_recorder(lambda _: None), username_is_prohibited=lambda a: False, ), + recaptcha_service=pretend.stub( + enabled=False, + verify_response=pretend.call_recorder(lambda _: None), + ), breach_service=pretend.stub(check_password=lambda pw, tags=None: False), ) assert not form.validate() @@ -527,6 +595,10 @@ def test_password_strength(self): form = forms.RegistrationForm( data={"new_password": pwd, "password_confirm": pwd}, user_service=pretend.stub(), + recaptcha_service=pretend.stub( + enabled=False, + verify_response=pretend.call_recorder(lambda _: None), + ), breach_service=pretend.stub(check_password=lambda pw, tags=None: False), ) form.validate() @@ -538,6 +610,10 @@ def test_password_breached(self): user_service=pretend.stub( find_userid=pretend.call_recorder(lambda _: None) ), + recaptcha_service=pretend.stub( + enabled=False, + verify_response=pretend.call_recorder(lambda _: None), + ), breach_service=pretend.stub( check_password=lambda pw, tags=None: True, failure_message=( @@ -558,6 +634,10 @@ def test_name_too_long(self, pyramid_config): user_service=pretend.stub( find_userid=pretend.call_recorder(lambda _: None) ), + recaptcha_service=pretend.stub( + enabled=False, + verify_response=pretend.call_recorder(lambda _: None), + ), breach_service=pretend.stub(check_password=lambda pw, tags=None: True), ) assert not form.validate() diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 5dd018ce7943..0888d0c537f6 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -370,6 +370,7 @@ def __init__(self): pretend.call(".sentry"), pretend.call(".csp"), pretend.call(".referrer_policy"), + pretend.call(".recaptcha"), pretend.call(".http"), ] + [pretend.call(x) for x in [configurator_settings.get("warehouse.theme")] if x] diff --git a/tests/unit/test_recaptcha.py b/tests/unit/test_recaptcha.py new file mode 100644 index 000000000000..f787175d6469 --- /dev/null +++ b/tests/unit/test_recaptcha.py @@ -0,0 +1,265 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import socket +import urllib.parse + +import pretend +import pytest +import requests +import responses + +from warehouse import recaptcha + +_SETTINGS = { + "recaptcha.site_key": "site_key_value", + "recaptcha.secret_key": "secret_key_value", +} +_REQUEST = pretend.stub( + # returning a real requests.Session object because responses is responsible + # for mocking that out + http=requests.Session(), + registry=pretend.stub( + settings=_SETTINGS, + ), +) + + +class TestVerifyResponse: + @responses.activate + def test_verify_service_disabled(self): + responses.add( + responses.POST, + recaptcha.VERIFY_URL, + body="", + ) + serv = recaptcha.Service(pretend.stub(registry=pretend.stub(settings={}))) + assert serv.verify_response("") is None + assert not responses.calls + + @responses.activate + def test_verify_service_disabled_with_none(self): + responses.add( + responses.POST, + recaptcha.VERIFY_URL, + body="", + ) + serv = recaptcha.Service( + pretend.stub( + registry=pretend.stub( + settings={ + "recaptcha.site_key": None, + "recaptcha.secret_key": None, + }, + ), + ), + ) + assert serv.verify_response("") is None + assert not responses.calls + + @responses.activate + def test_remote_ip_payload(self): + responses.add( + responses.POST, + recaptcha.VERIFY_URL, + json={"success": True}, + ) + serv = recaptcha.Service(_REQUEST) + serv.verify_response("meaningless", remote_ip="ip") + + payload = dict(urllib.parse.parse_qsl(responses.calls[0].request.body)) + assert payload["remoteip"] == "ip" + + @responses.activate + def test_unexpected_data_error(self): + responses.add( + responses.POST, + recaptcha.VERIFY_URL, + body="something awful", + ) + serv = recaptcha.Service(_REQUEST) + + with pytest.raises(recaptcha.UnexpectedError) as err: + serv.verify_response("meaningless") + + expected = "Unexpected data in response body: something awful" + assert str(err.value) == expected + + @responses.activate + def test_missing_success_key_error(self): + responses.add( + responses.POST, + recaptcha.VERIFY_URL, + json={"foo": "bar"}, + ) + serv = recaptcha.Service(_REQUEST) + + with pytest.raises(recaptcha.UnexpectedError) as err: + serv.verify_response("meaningless") + + expected = "Missing 'success' key in response: {'foo': 'bar'}" + assert str(err.value) == expected + + @responses.activate + def test_missing_error_codes_key_error(self): + responses.add( + responses.POST, + recaptcha.VERIFY_URL, + json={"success": False}, + ) + serv = recaptcha.Service(_REQUEST) + + with pytest.raises(recaptcha.UnexpectedError) as err: + serv.verify_response("meaningless") + + expected = "Response missing 'error-codes' key: {'success': False}" + assert str(err.value) == expected + + @responses.activate + def test_error_map_error(self): + for key, exc_tp in recaptcha.ERROR_CODE_MAP.items(): + responses.add( + responses.POST, + recaptcha.VERIFY_URL, + json={ + "success": False, + "challenge_ts": 0, + "hostname": "hotname_value", + "error_codes": [key], + }, + ) + + serv = recaptcha.Service(_REQUEST) + with pytest.raises(exc_tp): + serv.verify_response("meaningless") + + responses.reset() + + @responses.activate + def test_error_map_unknown_error(self): + responses.add( + responses.POST, + recaptcha.VERIFY_URL, + json={ + "success": False, + "challenge_ts": 0, + "hostname": "hostname_value", + "error_codes": ["slartibartfast"], + }, + ) + + serv = recaptcha.Service(_REQUEST) + with pytest.raises(recaptcha.UnexpectedError) as err: + serv.verify_response("meaningless") + assert str(err) == "Unexpected error code: slartibartfast" + + @responses.activate + def test_challenge_response_missing_timestamp_success(self): + responses.add( + responses.POST, + recaptcha.VERIFY_URL, + json={ + "success": True, + "hostname": "hostname_value", + }, + ) + + serv = recaptcha.Service(_REQUEST) + res = serv.verify_response("meaningless") + + assert isinstance(res, recaptcha.ChallengeResponse) + assert res.challenge_ts is None + assert res.hostname == "hostname_value" + + @responses.activate + def test_challenge_response_missing_hostname_success(self): + responses.add( + responses.POST, + recaptcha.VERIFY_URL, + json={ + "success": True, + "challenge_ts": 0, + }, + ) + + serv = recaptcha.Service(_REQUEST) + res = serv.verify_response("meaningless") + + assert isinstance(res, recaptcha.ChallengeResponse) + assert res.hostname is None + assert res.challenge_ts == 0 + + @responses.activate + def test_challenge_response_success(self): + responses.add( + responses.POST, + recaptcha.VERIFY_URL, + json={ + "success": True, + "hostname": "hostname_value", + "challenge_ts": 0, + }, + ) + + serv = recaptcha.Service(_REQUEST) + res = serv.verify_response("meaningless") + + assert isinstance(res, recaptcha.ChallengeResponse) + assert res.hostname == "hostname_value" + assert res.challenge_ts == 0 + + @responses.activate + def test_unexpected_error(self): + serv = recaptcha.Service(_REQUEST) + serv.request.http.post = pretend.raiser(socket.error) + + with pytest.raises(recaptcha.UnexpectedError): + serv.verify_response("meaningless") + + +class TestCSPPolicy: + def test_csp_policy(self): + scheme = "https" + request = pretend.stub( + scheme=scheme, + registry=pretend.stub( + settings={ + "recaptcha.site_key": "foo", + "recaptcha.secret_key": "bar", + } + ), + ) + serv = recaptcha.Service(request) + assert serv.csp_policy == { + "script-src": [ + "{request.scheme}://www.google.com/recaptcha/", + "{request.scheme}://www.gstatic.com/recaptcha/", + ], + "frame-src": ["{request.scheme}://www.google.com/recaptcha/"], + "style-src": ["'unsafe-inline'"], + } + + +def test_service_factory(): + serv = recaptcha.service_factory(None, _REQUEST) + assert serv.request is _REQUEST + + +def test_includeme(): + config = pretend.stub( + register_service_factory=pretend.call_recorder(lambda fact, name: None), + ) + recaptcha.includeme(config) + + assert config.register_service_factory.calls == [ + pretend.call(recaptcha.service_factory, name="recaptcha"), + ] diff --git a/warehouse/accounts/forms.py b/warehouse/accounts/forms.py index f849fa981f26..c564b936c3df 100644 --- a/warehouse/accounts/forms.py +++ b/warehouse/accounts/forms.py @@ -22,7 +22,7 @@ import warehouse.utils.webauthn as webauthn -from warehouse import forms +from warehouse import forms, recaptcha from warehouse.accounts.interfaces import ( BurnedRecoveryCode, InvalidRecoveryCode, @@ -296,11 +296,24 @@ class RegistrationForm( ) ] ) + g_recaptcha_response = wtforms.StringField() - def __init__(self, *args, user_service, **kwargs): + def __init__(self, *args, recaptcha_service, user_service, **kwargs): super().__init__(*args, **kwargs) self.user_service = user_service self.user_id = None + self.recaptcha_service = recaptcha_service + + def validate_g_recaptcha_response(self, field): + # do required data validation here due to enabled flag being required + if self.recaptcha_service.enabled and not field.data: + raise wtforms.validators.ValidationError("Recaptcha error.") + try: + self.recaptcha_service.verify_response(field.data) + except recaptcha.RecaptchaError: + # TODO: log error + # don't want to provide the user with any detail + raise wtforms.validators.ValidationError("Recaptcha error.") class LoginForm(PasswordMixin, UsernameMixin, forms.Form): diff --git a/warehouse/accounts/views.py b/warehouse/accounts/views.py index d483d3fa4787..1deabe00d31e 100644 --- a/warehouse/accounts/views.py +++ b/warehouse/accounts/views.py @@ -548,9 +548,18 @@ def register(request, _form_class=RegistrationForm): user_service = request.find_service(IUserService, context=None) breach_service = request.find_service(IPasswordBreachedService, context=None) + recaptcha_service = request.find_service(name="recaptcha") + request.find_service(name="csp").merge(recaptcha_service.csp_policy) + + # the form contains an auto-generated field from recaptcha with + # hyphens in it. make it play nice with wtforms. + post_body = {key.replace("-", "_"): value for key, value in request.POST.items()} form = _form_class( - data=request.POST, user_service=user_service, breach_service=breach_service + data=request.POST, + user_service=user_service, + recaptcha_service=recaptcha_service, + breach_service=breach_service, ) if request.method == "POST" and form.validate(): diff --git a/warehouse/config.py b/warehouse/config.py index c721e64e9f14..bf775a04587c 100644 --- a/warehouse/config.py +++ b/warehouse/config.py @@ -188,6 +188,8 @@ def configure(settings=None): maybe_set(settings, "sentry.transport", "SENTRY_TRANSPORT") maybe_set(settings, "sessions.url", "REDIS_URL") maybe_set(settings, "ratelimit.url", "REDIS_URL") + maybe_set(settings, "recaptcha.site_key", "RECAPTCHA_SITE_KEY") + maybe_set(settings, "recaptcha.secret_key", "RECAPTCHA_SECRET_KEY") maybe_set(settings, "sessions.secret", "SESSION_SECRET") maybe_set(settings, "camo.url", "CAMO_URL") maybe_set(settings, "camo.key", "CAMO_KEY") @@ -671,6 +673,9 @@ def configure(settings=None): # Register Referrer-Policy service config.include(".referrer_policy") + # Register recaptcha service + config.include(".recaptcha") + config.add_settings({"http": {"verify": "/etc/ssl/certs/"}}) config.include(".http") diff --git a/warehouse/recaptcha.py b/warehouse/recaptcha.py new file mode 100644 index 000000000000..c8f87a0fdcec --- /dev/null +++ b/warehouse/recaptcha.py @@ -0,0 +1,155 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import collections +import http + +from urllib.parse import urlencode + +VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify" + + +class RecaptchaError(ValueError): + pass + + +class MissingInputSecretError(RecaptchaError): + pass + + +class InvalidInputSecretError(RecaptchaError): + pass + + +class MissingInputResponseError(RecaptchaError): + pass + + +class InvalidInputResponseError(RecaptchaError): + pass + + +class UnexpectedError(RecaptchaError): + pass + + +ERROR_CODE_MAP = { + "missing-input-secret": MissingInputSecretError, + "invalid-input-secret": InvalidInputSecretError, + "missing-input-response": MissingInputResponseError, + "invalid-input-response": InvalidInputResponseError, +} + +ChallengeResponse = collections.namedtuple( + "ChallengeResponse", ("challenge_ts", "hostname") +) + + +class Service: + def __init__(self, request): + self.request = request + + @property + def csp_policy(self): + # the use of request.scheme should ever only be for dev. problem is + # that we use "//" in the script tags, so the request scheme is used. + # because the csp has to match the script src scheme, it also has to + # be dynamic. + return { + "script-src": [ + "{request.scheme}://www.google.com/recaptcha/", + "{request.scheme}://www.gstatic.com/recaptcha/", + ], + "frame-src": [ + "{request.scheme}://www.google.com/recaptcha/", + ], + "style-src": [ + "'unsafe-inline'", + ], + } + + @property + def enabled(self): + settings = self.request.registry.settings + return bool( + settings.get("recaptcha.site_key") and settings.get("recaptcha.secret_key") + ) + + def verify_response(self, response, remote_ip=None): + if not self.enabled: + # TODO: debug logging + return + + settings = self.request.registry.settings + + payload = { + "secret": settings["recaptcha.secret_key"], + "response": response, + } + if remote_ip is not None: + payload["remoteip"] = remote_ip + + try: + # TODO: the timeout is hardcoded for now. it would be nice to do + # something a little more generalized in the future. + resp = self.request.http.post( + VERIFY_URL, + urlencode(payload), + headers={ + "Content-Type": "application/x-www-form-urlencoded; charset=utf-8" + }, + timeout=10, + ) + except Exception as err: + raise UnexpectedError(str(err)) + + try: + data = resp.json() + except ValueError: + raise UnexpectedError( + "Unexpected data in response body: %s" % str(resp.content, "utf-8") + ) + + if "success" not in data: + raise UnexpectedError("Missing 'success' key in response: %s" % data) + + if resp.status_code != http.HTTPStatus.OK or not data["success"]: + try: + error_codes = data["error_codes"] + except KeyError: + raise UnexpectedError("Response missing 'error-codes' key: %s" % data) + try: + exc_tp = ERROR_CODE_MAP[error_codes[0]] + except KeyError: + raise UnexpectedError("Unexpected error code: %s" % error_codes[0]) + raise exc_tp + + # challenge_ts = timestamp of the challenge load + # (ISO format yyyy-MM-dd'T'HH:mm:ssZZ) + # TODO: maybe run some validation against the hostname and timestamp? + # TODO: log if either field is empty.. it shouldn't cause a failure, + # but it likely means that google has changed their response structure + return ChallengeResponse( + data.get("challenge_ts"), + data.get("hostname"), + ) + + +def service_factory(handler, request): + return Service(request) + + +def includeme(config): + # yeah yeah, binding to a concrete implementation rather than an + # interface. in a perfect world, this will never be offloaded to another + # service. however, if it is, then we'll deal with the refactor then + config.register_service_factory(service_factory, name="recaptcha") diff --git a/warehouse/templates/accounts/register.html b/warehouse/templates/accounts/register.html index 206085ede115..ea5e80982461 100644 --- a/warehouse/templates/accounts/register.html +++ b/warehouse/templates/accounts/register.html @@ -18,6 +18,8 @@ {% trans %}Create an account{% endtrans %} {% endblock %} +{%- from "warehouse:templates/includes/input-recaptcha.html" import recaptcha_html, recaptcha_src %} + {% block content %} {% if testPyPI %} {% set title = "TestPyPI" %} @@ -159,6 +161,10 @@ <h1 class="page-title">{% trans title=title %}Create an account on {{ title }}{% </ul> </div> + <div class="form-group"> + {{ recaptcha_html(request, form) }} + </div> + <input type="submit" value="{% trans %}Create account{% endtrans %}" class="button button--primary" data-password-match-target="submit"> </form> </div> @@ -166,6 +172,7 @@ <h1 class="page-title">{% trans title=title %}Create an account on {{ title }}{% {% endblock %} {% block extra_js %} + {{ recaptcha_src(request) }} <script async src="{{ request.static_path('warehouse:static/dist/js/vendor/zxcvbn.js') }}"> </script> diff --git a/warehouse/templates/includes/input-recaptcha.html b/warehouse/templates/includes/input-recaptcha.html new file mode 100644 index 000000000000..ab7ce4115f21 --- /dev/null +++ b/warehouse/templates/includes/input-recaptcha.html @@ -0,0 +1,31 @@ +{# + # Licensed under the Apache License, Version 2.0 (the "License"); + # you may not use this file except in compliance with the License. + # You may obtain a copy of the License at + # + # http://www.apache.org/licenses/LICENSE-2.0 + # + # Unless required by applicable law or agreed to in writing, software + # distributed under the License is distributed on an "AS IS" BASIS, + # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + # See the License for the specific language governing permissions and + # limitations under the License. +-#} +{% macro recaptcha_html(request, form) -%} + {% if request.find_service(name="recaptcha").enabled %} + <div class="g-recaptcha" data-sitekey="{{ request.registry.settings['recaptcha.site_key'] }}"></div> + {% if form.g_recaptcha_response.errors %} + <ul class="form-errors"> + {% for error in form.g_recaptcha_response.errors %} + <li>{{ error }}</li> + {% endfor %} + </ul> + {% endif %} + {% endif %} +{%- endmacro %} + +{% macro recaptcha_src(request) -%} + {% if request.find_service(name="recaptcha").enabled %} + <script src="//www.google.com/recaptcha/api.js" async defer></script> + {% endif %} +{%- endmacro %} From 69b174e4c662d6a2a834c7ffa8ff8bdad9bd08a1 Mon Sep 17 00:00:00 2001 From: Dustin Ingram <di@users.noreply.github.com> Date: Fri, 17 Mar 2023 21:53:42 +0000 Subject: [PATCH 2/4] Actually use post_body --- warehouse/accounts/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/warehouse/accounts/views.py b/warehouse/accounts/views.py index 1deabe00d31e..aaf25b78fad4 100644 --- a/warehouse/accounts/views.py +++ b/warehouse/accounts/views.py @@ -556,7 +556,7 @@ def register(request, _form_class=RegistrationForm): post_body = {key.replace("-", "_"): value for key, value in request.POST.items()} form = _form_class( - data=request.POST, + data=post_body, user_service=user_service, recaptcha_service=recaptcha_service, breach_service=breach_service, From 2db92b07a41c2dbad552f575c5e1120d48fed2f2 Mon Sep 17 00:00:00 2001 From: Dustin Ingram <di@users.noreply.github.com> Date: Fri, 17 Mar 2023 22:10:55 +0000 Subject: [PATCH 3/4] Fix new test --- tests/unit/accounts/test_views.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/unit/accounts/test_views.py b/tests/unit/accounts/test_views.py index d6a2cceea68e..38a8833ae2c3 100644 --- a/tests/unit/accounts/test_views.py +++ b/tests/unit/accounts/test_views.py @@ -1347,8 +1347,10 @@ def test_register_redirect(self, db_request, monkeypatch): lambda *args: None ) db_request.session.record_password_timestamp = lambda ts: None - db_request.find_service = pretend.call_recorder( - lambda svc, name=None, context=None: { + + def _find_service(service=None, name=None, context=None): + key = service or name + return { IUserService: pretend.stub( username_is_prohibited=lambda a: False, find_userid=pretend.call_recorder(lambda _: None), @@ -1364,8 +1366,13 @@ def test_register_redirect(self, db_request, monkeypatch): check_password=lambda pw, tags=None: False, ), IRateLimiter: pretend.stub(hit=lambda user_id: None), - }.get(svc) - ) + "csp": pretend.stub(merge=lambda *a, **kw: {}), + "recaptcha": pretend.stub( + csp_policy={}, enabled=True, verify_response=lambda a: True + ), + }[key] + + db_request.find_service = pretend.call_recorder(_find_service) db_request.route_path = pretend.call_recorder(lambda name: "/") db_request.POST.update( { @@ -1374,6 +1381,7 @@ def test_register_redirect(self, db_request, monkeypatch): "password_confirm": "MyStr0ng!shP455w0rd", "email": "foo@bar.com", "full_name": "full_name", + "g_recaptcha_response": "captchavalue", } ) From 05b401cc8d56957d9819157fb0ef821277a4590e Mon Sep 17 00:00:00 2001 From: Dustin Ingram <di@users.noreply.github.com> Date: Fri, 17 Mar 2023 22:11:14 +0000 Subject: [PATCH 4/4] Update translations --- warehouse/locale/messages.pot | 120 +++++++++++++++++----------------- 1 file changed, 60 insertions(+), 60 deletions(-) diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index 0ab94ee13f36..6888b1f8bcaf 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -78,23 +78,23 @@ msgstr "" msgid "The name is too long. Choose a name with 100 characters or less." msgstr "" -#: warehouse/accounts/forms.py:367 +#: warehouse/accounts/forms.py:380 msgid "Invalid TOTP code." msgstr "" -#: warehouse/accounts/forms.py:384 +#: warehouse/accounts/forms.py:397 msgid "Invalid WebAuthn assertion: Bad payload" msgstr "" -#: warehouse/accounts/forms.py:442 +#: warehouse/accounts/forms.py:455 msgid "Invalid recovery code." msgstr "" -#: warehouse/accounts/forms.py:450 +#: warehouse/accounts/forms.py:463 msgid "Recovery code has been previously used." msgstr "" -#: warehouse/accounts/forms.py:469 +#: warehouse/accounts/forms.py:482 msgid "No user found with that username or email" msgstr "" @@ -135,146 +135,146 @@ msgid "" "#admin-intervention for details." msgstr "" -#: warehouse/accounts/views.py:661 +#: warehouse/accounts/views.py:670 msgid "Expired token: request a new password reset link" msgstr "" -#: warehouse/accounts/views.py:663 +#: warehouse/accounts/views.py:672 msgid "Invalid token: request a new password reset link" msgstr "" -#: warehouse/accounts/views.py:665 warehouse/accounts/views.py:764 -#: warehouse/accounts/views.py:863 warehouse/accounts/views.py:1032 +#: warehouse/accounts/views.py:674 warehouse/accounts/views.py:773 +#: warehouse/accounts/views.py:872 warehouse/accounts/views.py:1041 msgid "Invalid token: no token supplied" msgstr "" -#: warehouse/accounts/views.py:669 +#: warehouse/accounts/views.py:678 msgid "Invalid token: not a password reset token" msgstr "" -#: warehouse/accounts/views.py:674 +#: warehouse/accounts/views.py:683 msgid "Invalid token: user not found" msgstr "" -#: warehouse/accounts/views.py:685 +#: warehouse/accounts/views.py:694 msgid "Invalid token: user has logged in since this token was requested" msgstr "" -#: warehouse/accounts/views.py:703 +#: warehouse/accounts/views.py:712 msgid "" "Invalid token: password has already been changed since this token was " "requested" msgstr "" -#: warehouse/accounts/views.py:732 +#: warehouse/accounts/views.py:741 msgid "You have reset your password" msgstr "" -#: warehouse/accounts/views.py:760 +#: warehouse/accounts/views.py:769 msgid "Expired token: request a new email verification link" msgstr "" -#: warehouse/accounts/views.py:762 +#: warehouse/accounts/views.py:771 msgid "Invalid token: request a new email verification link" msgstr "" -#: warehouse/accounts/views.py:768 +#: warehouse/accounts/views.py:777 msgid "Invalid token: not an email verification token" msgstr "" -#: warehouse/accounts/views.py:777 +#: warehouse/accounts/views.py:786 msgid "Email not found" msgstr "" -#: warehouse/accounts/views.py:780 +#: warehouse/accounts/views.py:789 msgid "Email already verified" msgstr "" -#: warehouse/accounts/views.py:797 +#: warehouse/accounts/views.py:806 msgid "You can now set this email as your primary address" msgstr "" -#: warehouse/accounts/views.py:801 +#: warehouse/accounts/views.py:810 msgid "This is your primary address" msgstr "" -#: warehouse/accounts/views.py:806 +#: warehouse/accounts/views.py:815 msgid "Email address ${email_address} verified. ${confirm_message}." msgstr "" -#: warehouse/accounts/views.py:859 +#: warehouse/accounts/views.py:868 msgid "Expired token: request a new organization invitation" msgstr "" -#: warehouse/accounts/views.py:861 +#: warehouse/accounts/views.py:870 msgid "Invalid token: request a new organization invitation" msgstr "" -#: warehouse/accounts/views.py:867 +#: warehouse/accounts/views.py:876 msgid "Invalid token: not an organization invitation token" msgstr "" -#: warehouse/accounts/views.py:871 +#: warehouse/accounts/views.py:880 msgid "Organization invitation is not valid." msgstr "" -#: warehouse/accounts/views.py:880 +#: warehouse/accounts/views.py:889 msgid "Organization invitation no longer exists." msgstr "" -#: warehouse/accounts/views.py:931 +#: warehouse/accounts/views.py:940 msgid "Invitation for '${organization_name}' is declined." msgstr "" -#: warehouse/accounts/views.py:994 +#: warehouse/accounts/views.py:1003 msgid "You are now ${role} of the '${organization_name}' organization." msgstr "" -#: warehouse/accounts/views.py:1028 +#: warehouse/accounts/views.py:1037 msgid "Expired token: request a new project role invitation" msgstr "" -#: warehouse/accounts/views.py:1030 +#: warehouse/accounts/views.py:1039 msgid "Invalid token: request a new project role invitation" msgstr "" -#: warehouse/accounts/views.py:1036 +#: warehouse/accounts/views.py:1045 msgid "Invalid token: not a collaboration invitation token" msgstr "" -#: warehouse/accounts/views.py:1040 +#: warehouse/accounts/views.py:1049 msgid "Role invitation is not valid." msgstr "" -#: warehouse/accounts/views.py:1055 +#: warehouse/accounts/views.py:1064 msgid "Role invitation no longer exists." msgstr "" -#: warehouse/accounts/views.py:1086 +#: warehouse/accounts/views.py:1095 msgid "Invitation for '${project_name}' is declined." msgstr "" -#: warehouse/accounts/views.py:1153 +#: warehouse/accounts/views.py:1162 msgid "You are now ${role} of the '${project_name}' project." msgstr "" -#: warehouse/accounts/views.py:1409 +#: warehouse/accounts/views.py:1418 msgid "" "You must have a verified email in order to register a pending OpenID " "Connect publisher. See https://pypi.org/help#openid-connect for details." msgstr "" -#: warehouse/accounts/views.py:1422 +#: warehouse/accounts/views.py:1431 msgid "You can't register more than 3 pending OpenID Connect publishers at once." msgstr "" -#: warehouse/accounts/views.py:1438 warehouse/manage/views.py:3182 +#: warehouse/accounts/views.py:1447 warehouse/manage/views.py:3182 msgid "" "There have been too many attempted OpenID Connect registrations. Try " "again later." msgstr "" -#: warehouse/accounts/views.py:1466 +#: warehouse/accounts/views.py:1475 msgid "" "This OpenID Connect publisher has already been registered. Please contact" " PyPI's admins if this wasn't intentional." @@ -1083,8 +1083,8 @@ msgstr "" msgid "Error processing form" msgstr "" -#: warehouse/templates/accounts/register.html:135 -#: warehouse/templates/accounts/register.html:140 +#: warehouse/templates/accounts/register.html:137 +#: warehouse/templates/accounts/register.html:142 #: warehouse/templates/accounts/reset-password.html:60 #: warehouse/templates/accounts/reset-password.html:65 #: warehouse/templates/manage/account.html:405 @@ -1097,7 +1097,7 @@ msgid "Confirm password to continue" msgstr "" #: warehouse/templates/accounts/login.html:69 -#: warehouse/templates/accounts/register.html:110 +#: warehouse/templates/accounts/register.html:112 #: warehouse/templates/accounts/reset-password.html:38 #: warehouse/templates/manage/manage_base.html:381 #: warehouse/templates/re-auth.html:49 @@ -1107,11 +1107,11 @@ msgstr "" #: warehouse/templates/accounts/login.html:51 #: warehouse/templates/accounts/login.html:71 #: warehouse/templates/accounts/recovery-code.html:42 -#: warehouse/templates/accounts/register.html:47 -#: warehouse/templates/accounts/register.html:66 -#: warehouse/templates/accounts/register.html:91 -#: warehouse/templates/accounts/register.html:112 -#: warehouse/templates/accounts/register.html:137 +#: warehouse/templates/accounts/register.html:49 +#: warehouse/templates/accounts/register.html:68 +#: warehouse/templates/accounts/register.html:93 +#: warehouse/templates/accounts/register.html:114 +#: warehouse/templates/accounts/register.html:139 #: warehouse/templates/accounts/request-password-reset.html:41 #: warehouse/templates/accounts/reset-password.html:40 #: warehouse/templates/accounts/reset-password.html:62 @@ -1238,7 +1238,7 @@ msgstr "" #: warehouse/templates/accounts/login.html:49 #: warehouse/templates/accounts/profile.html:41 -#: warehouse/templates/accounts/register.html:89 +#: warehouse/templates/accounts/register.html:91 #: warehouse/templates/email/organization-member-added/body.html:30 #: warehouse/templates/email/organization-member-invited/body.html:30 #: warehouse/templates/email/organization-member-removed/body.html:30 @@ -1380,57 +1380,57 @@ msgstr "" msgid "Create an account" msgstr "" -#: warehouse/templates/accounts/register.html:30 +#: warehouse/templates/accounts/register.html:32 #, python-format msgid "Create an account on %(title)s" msgstr "" -#: warehouse/templates/accounts/register.html:45 +#: warehouse/templates/accounts/register.html:47 #: warehouse/templates/manage/account.html:139 #: warehouse/templates/manage/account.html:434 msgid "Name" msgstr "" -#: warehouse/templates/accounts/register.html:50 +#: warehouse/templates/accounts/register.html:52 msgid "Your name" msgstr "" -#: warehouse/templates/accounts/register.html:64 +#: warehouse/templates/accounts/register.html:66 #: warehouse/templates/manage/account.html:326 msgid "Email address" msgstr "" -#: warehouse/templates/accounts/register.html:69 +#: warehouse/templates/accounts/register.html:71 #: warehouse/templates/manage/account.html:347 msgid "Your email address" msgstr "" -#: warehouse/templates/accounts/register.html:83 +#: warehouse/templates/accounts/register.html:85 msgid "Confirm form" msgstr "" -#: warehouse/templates/accounts/register.html:94 +#: warehouse/templates/accounts/register.html:96 msgid "Select a username" msgstr "" -#: warehouse/templates/accounts/register.html:117 +#: warehouse/templates/accounts/register.html:119 #: warehouse/templates/accounts/reset-password.html:44 #: warehouse/templates/manage/account.html:374 msgid "Show passwords" msgstr "" -#: warehouse/templates/accounts/register.html:120 +#: warehouse/templates/accounts/register.html:122 msgid "Select a password" msgstr "" -#: warehouse/templates/accounts/register.html:157 +#: warehouse/templates/accounts/register.html:159 msgid "" "This password appears in a security breach or has been compromised and " "cannot be used. Please refer to the <a href=\"/help/#compromised-" "password\">FAQ</a> for more information." msgstr "" -#: warehouse/templates/accounts/register.html:162 +#: warehouse/templates/accounts/register.html:168 msgid "Create account" msgstr ""