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 ""