Repository: pypi/warehouse

Issue:
Encourage generation of 2FA recovery codes
In order to cut down on 2FA reset requests, PyPI should encourage/require users to generate recovery codes.

Some ideas:

* Require recovery codes to be generated before upload can succeed if 2FA is enabled
* Require recovery codes to be generated before the user can log in
* Require recovery codes to be generated as part of the 2FA setup
  * Additionally, require the user to burn one of the codes immediately after generation to ensure the codes have actually been downloaded/stored.
Encourage generation of 2FA recovery codes
In order to cut down on 2FA reset requests, PyPI should encourage/require users to generate recovery codes.

Some ideas:

* Require recovery codes to be generated before upload can succeed if 2FA is enabled
* Require recovery codes to be generated before the user can log in
* Require recovery codes to be generated as part of the 2FA setup
  * Additionally, require the user to burn one of the codes immediately after generation to ensure the codes have actually been downloaded/stored.


Discussion:
> Additionally, require the user to burn one of the codes immediately after generation to ensure the codes have actually been downloaded/stored.

I think this is the only way to make sure users actually did write them somewhere.

If we could also ask sometimes at login time whether they still have the codes (e.g. add a checkbox once every ~2 months asking them to check that they can still access their recovery code, without requesting that they enter one, just to make sure they're aware that it's a thing they might need, and highlighting that if they loose their 2FA app without recovery codes, they will be permanently locked out without any recovery procedure)
> Additionally, require the user to burn one of the codes immediately after generation to ensure the codes have actually been downloaded/stored.

I think this is the only way to make sure users actually did write them somewhere.

If we could also ask sometimes at login time whether they still have the codes (e.g. add a checkbox once every ~2 months asking them to check that they can still access their recovery code, without requesting that they enter one, just to make sure they're aware that it's a thing they might need, and highlighting that if they loose their 2FA app without recovery codes, they will be permanently locked out without any recovery procedure)

Resolving patch:
diff --git a/warehouse/accounts/views.py b/warehouse/accounts/views.py
--- a/warehouse/accounts/views.py
+++ b/warehouse/accounts/views.py
@@ -58,6 +58,7 @@
     send_email_verification_email,
     send_password_change_email,
     send_password_reset_email,
+    send_recovery_code_reminder_email,
 )
 from warehouse.packaging.models import (
     JournalEntry,
@@ -282,6 +283,10 @@ def two_factor_and_totp_validate(request, _form_class=TOTPAuthenticationForm):
                 .hexdigest()
                 .lower(),
             )
+
+            if not two_factor_state.get("has_recovery_codes", False):
+                send_recovery_code_reminder_email(request, request.user)
+
             return resp
         else:
             form.totp_value.data = ""
@@ -372,6 +377,10 @@ def webauthn_authentication_validate(request):
             .hexdigest()
             .lower(),
         )
+
+        if not request.user.has_recovery_codes:
+            send_recovery_code_reminder_email(request, request.user)
+
         return {
             "success": request._("Successful WebAuthn assertion"),
             "redirect_to": redirect_to,
diff --git a/warehouse/email/__init__.py b/warehouse/email/__init__.py
--- a/warehouse/email/__init__.py
+++ b/warehouse/email/__init__.py
@@ -421,6 +421,11 @@ def send_recovery_code_used_email(request, user):
     return {"username": user.username}
 
 
+@_email("recovery-code-reminder")
+def send_recovery_code_reminder_email(request, user):
+    return {"username": user.username}
+
+
 def includeme(config):
     email_sending_class = config.maybe_dotted(config.registry.settings["mail.backend"])
     config.register_service_factory(email_sending_class.create_service, IEmailSender)


Test patch:
diff --git a/tests/unit/accounts/test_views.py b/tests/unit/accounts/test_views.py
--- a/tests/unit/accounts/test_views.py
+++ b/tests/unit/accounts/test_views.py
@@ -544,7 +544,10 @@ def test_get_returns_recovery_code_status(self, pyramid_request, redirect_url):
         assert result == {"has_recovery_codes": True}
 
     @pytest.mark.parametrize("redirect_url", ["test_redirect_url", None])
-    def test_totp_auth(self, monkeypatch, pyramid_request, redirect_url):
+    @pytest.mark.parametrize("has_recovery_codes", [True, False])
+    def test_totp_auth(
+        self, monkeypatch, pyramid_request, redirect_url, has_recovery_codes
+    ):
         remember = pretend.call_recorder(lambda request, user_id: [("foo", "bar")])
         monkeypatch.setattr(views, "remember", remember)
 
@@ -558,17 +561,17 @@ def test_totp_auth(self, monkeypatch, pyramid_request, redirect_url):
             )
         )
 
+        user = pretend.stub(
+            last_login=(datetime.datetime.utcnow() - datetime.timedelta(days=1)),
+            has_recovery_codes=has_recovery_codes,
+        )
         user_service = pretend.stub(
             find_userid=pretend.call_recorder(lambda username: 1),
-            get_user=pretend.call_recorder(
-                lambda userid: pretend.stub(
-                    last_login=(datetime.datetime.utcnow() - datetime.timedelta(days=1))
-                )
-            ),
+            get_user=pretend.call_recorder(lambda userid: user),
             update_user=lambda *a, **k: None,
             has_totp=lambda userid: True,
             has_webauthn=lambda userid: False,
-            has_recovery_codes=lambda userid: False,
+            has_recovery_codes=lambda userid: has_recovery_codes,
             check_totp_value=lambda userid, totp_value: True,
             record_event=pretend.call_recorder(lambda *a, **kw: None),
         )
@@ -606,6 +609,11 @@ def test_totp_auth(self, monkeypatch, pyramid_request, redirect_url):
         pyramid_request.params = pretend.stub(
             get=pretend.call_recorder(lambda k: query_params.get(k))
         )
+        pyramid_request.user = user
+
+        send_email = pretend.call_recorder(lambda *a: None)
+        monkeypatch.setattr(views, "send_recovery_code_reminder_email", send_email)
+
         result = views.two_factor_and_totp_validate(
             pyramid_request, _form_class=form_class
         )
@@ -627,6 +635,9 @@ def test_totp_auth(self, monkeypatch, pyramid_request, redirect_url):
             )
         ]
         assert pyramid_request.session.record_auth_timestamp.calls == [pretend.call()]
+        assert send_email.calls == (
+            [] if has_recovery_codes else [pretend.call(pyramid_request, user)]
+        )
 
     def test_totp_auth_already_authed(self):
         request = pretend.stub(
@@ -735,6 +746,7 @@ def test_two_factor_token_invalid(self, pyramid_request):
 class TestWebAuthn:
     def test_webauthn_get_options_already_authenticated(self, pyramid_request):
         request = pretend.stub(authenticated_userid=pretend.stub(), _=lambda a: a)
+
         result = views.webauthn_authentication_options(request)
 
         assert result == {"fail": {"errors": ["Already authenticated"]}}
@@ -836,7 +848,8 @@ def test_webauthn_validate_invalid_form(self, monkeypatch):
 
         assert result == {"fail": {"errors": ["Fake validation failure"]}}
 
-    def test_webauthn_validate(self, monkeypatch, pyramid_request):
+    @pytest.mark.parametrize("has_recovery_codes", [True, False])
+    def test_webauthn_validate(self, monkeypatch, pyramid_request, has_recovery_codes):
         _get_two_factor_data = pretend.call_recorder(
             lambda r: {"redirect_to": "foobar", "userid": 1}
         )
@@ -845,7 +858,10 @@ def test_webauthn_validate(self, monkeypatch, pyramid_request):
         _login_user = pretend.call_recorder(lambda *a, **kw: pretend.stub())
         monkeypatch.setattr(views, "_login_user", _login_user)
 
-        user = pretend.stub(webauthn=pretend.stub(sign_count=pretend.stub()))
+        user = pretend.stub(
+            webauthn=pretend.stub(sign_count=pretend.stub()),
+            has_recovery_codes=has_recovery_codes,
+        )
 
         user_service = pretend.stub(
             get_user=pretend.call_recorder(lambda uid: user),
@@ -858,6 +874,7 @@ def test_webauthn_validate(self, monkeypatch, pyramid_request):
             clear_webauthn_challenge=pretend.call_recorder(lambda: pretend.stub()),
         )
         pyramid_request.find_service = lambda *a, **kw: user_service
+        pyramid_request.user = user
 
         form_obj = pretend.stub(
             validate=pretend.call_recorder(lambda: True),
@@ -869,6 +886,9 @@ def test_webauthn_validate(self, monkeypatch, pyramid_request):
         form_class = pretend.call_recorder(lambda *a, **kw: form_obj)
         monkeypatch.setattr(views, "WebAuthnAuthenticationForm", form_class)
 
+        send_email = pretend.call_recorder(lambda *a: None)
+        monkeypatch.setattr(views, "send_recovery_code_reminder_email", send_email)
+
         result = views.webauthn_authentication_validate(pyramid_request)
 
         assert _get_two_factor_data.calls == [pretend.call(pyramid_request)]
@@ -884,6 +904,9 @@ def test_webauthn_validate(self, monkeypatch, pyramid_request):
         assert pyramid_request.session.clear_webauthn_challenge.calls == [
             pretend.call()
         ]
+        assert send_email.calls == (
+            [] if has_recovery_codes else [pretend.call(pyramid_request, user)]
+        )
 
         assert result == {
             "success": "Successful WebAuthn assertion",
diff --git a/tests/unit/email/test_init.py b/tests/unit/email/test_init.py
--- a/tests/unit/email/test_init.py
+++ b/tests/unit/email/test_init.py
@@ -3467,6 +3467,7 @@ class TestRecoveryCodeEmails:
         [
             (email.send_recovery_codes_generated_email, "recovery-codes-generated"),
             (email.send_recovery_code_used_email, "recovery-code-used"),
+            (email.send_recovery_code_reminder_email, "recovery-code-reminder"),
         ],
     )
     def test_recovery_code_emails(