Repository: pypi/warehouse

Issue:
Improvements to 'Login using recovery codes' input field
We've gotten a report that users are confused by the 'Login using recovery codes' input field, sometimes attempting to paste all their recovery codes into the field at once.

We should add some simple client-side and server-side verification of this field, to ensure that the contents are a) the length we expect for a recovery code b) a valid set of characters.

Additionally, we should ensure that the field does not autocomplete or retain previously entered data, which it does now.



Discussion:

Resolving patch:
diff --git a/warehouse/accounts/forms.py b/warehouse/accounts/forms.py
--- a/warehouse/accounts/forms.py
+++ b/warehouse/accounts/forms.py
@@ -30,6 +30,7 @@
     TooManyFailedLogins,
 )
 from warehouse.accounts.models import DisableReason
+from warehouse.accounts.services import RECOVERY_CODE_BYTES
 from warehouse.email import (
     send_password_compromised_email_hibp,
     send_recovery_code_used_email,
@@ -79,7 +80,16 @@ class WebAuthnCredentialMixin:
 class RecoveryCodeValueMixin:
 
     recovery_code_value = wtforms.StringField(
-        validators=[wtforms.validators.DataRequired()]
+        validators=[
+            wtforms.validators.DataRequired(),
+            wtforms.validators.Regexp(
+                rf"^ *([0-9a-f] *){{{2*RECOVERY_CODE_BYTES}}}$",
+                message=_(
+                    "Recovery Codes must be ${recovery_code_length} characters.",
+                    mapping={"recovery_code_length": 2 * RECOVERY_CODE_BYTES},
+                ),
+            ),
+        ]
     )
 
 
@@ -409,7 +419,7 @@ class RecoveryCodeAuthenticationForm(
     RecoveryCodeValueMixin, _TwoFactorAuthenticationForm
 ):
     def validate_recovery_code_value(self, field):
-        recovery_code_value = field.data.encode("utf-8")
+        recovery_code_value = field.data.encode("utf-8").strip()
 
         try:
             self.user_service.check_recovery_code(self.user_id, recovery_code_value)
diff --git a/warehouse/accounts/services.py b/warehouse/accounts/services.py
--- a/warehouse/accounts/services.py
+++ b/warehouse/accounts/services.py
@@ -60,6 +60,7 @@
 
 PASSWORD_FIELD = "password"
 RECOVERY_CODE_COUNT = 8
+RECOVERY_CODE_BYTES = 8
 
 
 @implementer(IUserService)
@@ -574,7 +575,9 @@ def generate_recovery_codes(self, user_id):
         if user.has_recovery_codes:
             self.db.query(RecoveryCode).filter_by(user=user).delete()
 
-        recovery_codes = [secrets.token_hex(8) for _ in range(RECOVERY_CODE_COUNT)]
+        recovery_codes = [
+            secrets.token_hex(RECOVERY_CODE_BYTES) for _ in range(RECOVERY_CODE_COUNT)
+        ]
         for recovery_code in recovery_codes:
             self.db.add(RecoveryCode(user=user, code=self.hasher.hash(recovery_code)))
 


Test patch:
diff --git a/tests/unit/accounts/test_forms.py b/tests/unit/accounts/test_forms.py
--- a/tests/unit/accounts/test_forms.py
+++ b/tests/unit/accounts/test_forms.py
@@ -832,7 +832,7 @@ def test_invalid_recovery_code(
         )
         form = forms.RecoveryCodeAuthenticationForm(
             request=request,
-            data={"recovery_code_value": "invalid"},
+            data={"recovery_code_value": "deadbeef00001111"},
             user_id=1,
             user_service=user_service,
         )
@@ -852,7 +852,7 @@ def test_valid_recovery_code(self, monkeypatch):
         user = pretend.stub(id=pretend.stub(), username="foobar")
         form = forms.RecoveryCodeAuthenticationForm(
             request=request,
-            data={"recovery_code_value": "valid"},
+            data={"recovery_code_value": "deadbeef00001111"},
             user_id=pretend.stub(),
             user_service=pretend.stub(
                 check_recovery_code=pretend.call_recorder(lambda *a, **kw: True),
@@ -868,3 +868,37 @@ def test_valid_recovery_code(self, monkeypatch):
 
         assert form.validate()
         assert send_recovery_code_used_email.calls == [pretend.call(request, user)]
+
+    @pytest.mark.parametrize(
+        "input_string, validates",
+        [
+            (" deadbeef00001111 ", True),
+            ("deadbeef00001111 ", True),
+            (" deadbeef00001111", True),
+            ("deadbeef00001111", True),
+            ("wu-tang", False),
+            ("deadbeef00001111 deadbeef11110000", False),
+        ],
+    )
+    def test_recovery_code_string_validation(
+        self, monkeypatch, input_string, validates
+    ):
+        request = pretend.stub(remote_addr="127.0.0.1")
+        user = pretend.stub(id=pretend.stub(), username="foobar")
+        form = forms.RecoveryCodeAuthenticationForm(
+            request=request,
+            data={"recovery_code_value": input_string},
+            user_id=pretend.stub(),
+            user_service=pretend.stub(
+                check_recovery_code=pretend.call_recorder(lambda *a, **kw: True),
+                get_user=lambda _: user,
+            ),
+        )
+        send_recovery_code_used_email = pretend.call_recorder(
+            lambda request, user: None
+        )
+        monkeypatch.setattr(
+            forms, "send_recovery_code_used_email", send_recovery_code_used_email
+        )
+
+        assert form.validate() == validates