Repository: pypi/warehouse

Issue:
2FA validation should be rate limited, in addition to current window
**What's the problem this feature will solve?**

Right now, we use a signed parameter with a 5-minute window to ensure authority/integrity during 2FA authentication. As a result, any user who successfully enters a password but fails to enter a correct TOTP code (or WebAuthn attestation, soon) within 5 minutes will be kicked back to the login page and required to re-enter the password.

The above provides sufficient brute-force protection, but it'd be nice to additionally leverage Warehouse's existing rate-limiters to prevent an attacker from wasting resources by hammering TOTP verification within each 5 minute window.

**Describe the solution you'd like**

An additional user-scoped ratelimiter on `check_totp_value` (and eventually WebAuthn) with similar semantics to the user-scope ratelimiter on `check_password`.



Discussion:

Resolving patch:
diff --git a/warehouse/accounts/services.py b/warehouse/accounts/services.py
--- a/warehouse/accounts/services.py
+++ b/warehouse/accounts/services.py
@@ -257,6 +257,28 @@ def check_totp_value(self, user_id, totp_value, *, tags=None):
         tags = tags if tags is not None else []
         self._metrics.increment("warehouse.authentication.two_factor.start", tags=tags)
 
+        # The very first thing we want to do is check to see if we've hit our
+        # global rate limit or not, assuming that we've been configured with a
+        # global rate limiter anyways.
+        if not self.ratelimiters["global"].test():
+            logger.warning("Global failed login threshold reached.")
+            self._metrics.increment(
+                "warehouse.authentication.two_factor.ratelimited",
+                tags=tags + ["ratelimiter:global"],
+            )
+            raise TooManyFailedLogins(resets_in=self.ratelimiters["global"].resets_in())
+
+        # Now, check to make sure that we haven't hitten a rate limit on a
+        # per user basis.
+        if not self.ratelimiters["user"].test(user_id):
+            self._metrics.increment(
+                "warehouse.authentication.two_factor.ratelimited",
+                tags=tags + ["ratelimiter:user"],
+            )
+            raise TooManyFailedLogins(
+                resets_in=self.ratelimiters["user"].resets_in(user_id)
+            )
+
         totp_secret = self.get_totp_secret(user_id)
 
         if totp_secret is None:


Test patch:
diff --git a/tests/unit/accounts/test_services.py b/tests/unit/accounts/test_services.py
--- a/tests/unit/accounts/test_services.py
+++ b/tests/unit/accounts/test_services.py
@@ -366,6 +366,46 @@ def test_check_totp_value_no_secret(self, user_service):
         user = UserFactory.create()
         assert not user_service.check_totp_value(user.id, b"123456")
 
+    def test_check_totp_global_rate_limited(self, user_service, metrics):
+        resets = pretend.stub()
+        limiter = pretend.stub(test=lambda: False, resets_in=lambda: resets)
+        user_service.ratelimiters["global"] = limiter
+
+        with pytest.raises(TooManyFailedLogins) as excinfo:
+            user_service.check_totp_value(uuid.uuid4(), b"123456", tags=["foo"])
+
+        assert excinfo.value.resets_in is resets
+        assert metrics.increment.calls == [
+            pretend.call("warehouse.authentication.two_factor.start", tags=["foo"]),
+            pretend.call(
+                "warehouse.authentication.two_factor.ratelimited",
+                tags=["foo", "ratelimiter:global"],
+            ),
+        ]
+
+    def test_check_totp_value_user_rate_limited(self, user_service, metrics):
+        user = UserFactory.create()
+        resets = pretend.stub()
+        limiter = pretend.stub(
+            test=pretend.call_recorder(lambda uid: False),
+            resets_in=pretend.call_recorder(lambda uid: resets),
+        )
+        user_service.ratelimiters["user"] = limiter
+
+        with pytest.raises(TooManyFailedLogins) as excinfo:
+            user_service.check_totp_value(user.id, b"123456")
+
+        assert excinfo.value.resets_in is resets
+        assert limiter.test.calls == [pretend.call(user.id)]
+        assert limiter.resets_in.calls == [pretend.call(user.id)]
+        assert metrics.increment.calls == [
+            pretend.call("warehouse.authentication.two_factor.start", tags=[]),
+            pretend.call(
+                "warehouse.authentication.two_factor.ratelimited",
+                tags=["ratelimiter:user"],
+            ),
+        ]
+
 
 class TestTokenService:
     def test_verify_service(self):