Repository: pypi/warehouse

Issue:
Send password reset to the specified email instead of Primary
Currently the password reset page allows specifying either a username or an email address to reset your password for. However, if you supply an email address that isn't the primary email address on the account, it will successfully find the account, but will send the password reset to the primary address anyways. This negates a lot of the benefit of having secondary emails, since if you lose access to the first email, you still end up getting locked out of your account (at least until a PyPI administrator can intervene). Additionally, this is somewhat surprising behavior, people are going to generally expect that the email is coming to the address they provided.

We should consider sending the password reset to the provided email address instead of the primary address. Of course if someone is reseting by username, we would still send to the primary address in that case.

@pypa/warehouse-committers thoughts?


Discussion:
I think this makes sense - in fact, that's what I thought was already happening...
https://github.com/pypa/warehouse/blob/master/warehouse/templates/manage/account.html#L192 

Resolving patch:
diff --git a/warehouse/accounts/views.py b/warehouse/accounts/views.py
--- a/warehouse/accounts/views.py
+++ b/warehouse/accounts/views.py
@@ -14,6 +14,7 @@
 import hashlib
 import uuid
 
+from first import first
 from pyramid.httpexceptions import (
     HTTPMovedPermanently,
     HTTPSeeOther,
@@ -272,10 +273,14 @@ def request_password_reset(request, _form_class=RequestPasswordResetForm):
     form = _form_class(request.POST, user_service=user_service)
     if request.method == "POST" and form.validate():
         user = user_service.get_user_by_username(form.username_or_email.data)
+        email = None
         if user is None:
             user = user_service.get_user_by_email(form.username_or_email.data)
+            email = first(
+                user.emails, key=lambda e: e.email == form.username_or_email.data
+            )
 
-        send_password_reset_email(request, user)
+        send_password_reset_email(request, (user, email))
 
         token_service = request.find_service(ITokenService, name="password")
         n_hours = token_service.max_age // 60 // 60
diff --git a/warehouse/email/__init__.py b/warehouse/email/__init__.py
--- a/warehouse/email/__init__.py
+++ b/warehouse/email/__init__.py
@@ -122,7 +122,8 @@ def wrapper(request, user_or_users, **kwargs):
 
 
 @_email("password-reset", allow_unverified=True)
-def send_password_reset_email(request, user):
+def send_password_reset_email(request, user_and_email):
+    user, _ = user_and_email
     token_service = request.find_service(ITokenService, name="password")
     token = token_service.dumps(
         {


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
@@ -506,14 +506,16 @@ def test_request_password_reset(
             pretend.call(pyramid_request.POST, user_service=user_service)
         ]
         assert send_password_reset_email.calls == [
-            pretend.call(pyramid_request, stub_user)
+            pretend.call(pyramid_request, (stub_user, None))
         ]
 
     def test_request_password_reset_with_email(
         self, monkeypatch, pyramid_request, pyramid_config, user_service, token_service
     ):
 
-        stub_user = pretend.stub(email=pretend.stub())
+        stub_user = pretend.stub(
+            email="foo@example.com", emails=[pretend.stub(email="foo@example.com")]
+        )
         pyramid_request.method = "POST"
         token_service.dumps = pretend.call_recorder(lambda a: "TOK")
         user_service.get_user_by_username = pretend.call_recorder(lambda a: None)
@@ -553,18 +555,24 @@ def test_request_password_reset_with_email(
             pretend.call(pyramid_request.POST, user_service=user_service)
         ]
         assert send_password_reset_email.calls == [
-            pretend.call(pyramid_request, stub_user)
+            pretend.call(pyramid_request, (stub_user, stub_user.emails[0]))
         ]
 
-    def test_request_password_reset_with_wrong_credentials(
+    def test_request_password_reset_with_non_primary_email(
         self, monkeypatch, pyramid_request, pyramid_config, user_service, token_service
     ):
 
-        stub_user = pretend.stub(username=pretend.stub())
+        stub_user = pretend.stub(
+            email="foo@example.com",
+            emails=[
+                pretend.stub(email="foo@example.com"),
+                pretend.stub(email="other@example.com"),
+            ],
+        )
         pyramid_request.method = "POST"
         token_service.dumps = pretend.call_recorder(lambda a: "TOK")
         user_service.get_user_by_username = pretend.call_recorder(lambda a: None)
-        user_service.get_user_by_email = pretend.call_recorder(lambda a: None)
+        user_service.get_user_by_email = pretend.call_recorder(lambda a: stub_user)
         pyramid_request.find_service = pretend.call_recorder(
             lambda interface, **kw: {
                 IUserService: user_service,
@@ -572,7 +580,7 @@ def test_request_password_reset_with_wrong_credentials(
             }[interface]
         )
         form_obj = pretend.stub(
-            username_or_email=pretend.stub(data=stub_user.username),
+            username_or_email=pretend.stub(data="other@example.com"),
             validate=pretend.call_recorder(lambda: True),
         )
         form_class = pretend.call_recorder(lambda d, user_service: form_obj)
@@ -588,10 +596,10 @@ def test_request_password_reset_with_wrong_credentials(
 
         assert result == {"n_hours": n_hours}
         assert user_service.get_user_by_username.calls == [
-            pretend.call(stub_user.username)
+            pretend.call("other@example.com")
         ]
         assert user_service.get_user_by_email.calls == [
-            pretend.call(stub_user.username)
+            pretend.call("other@example.com")
         ]
         assert pyramid_request.find_service.calls == [
             pretend.call(IUserService, context=None),
@@ -601,7 +609,9 @@ def test_request_password_reset_with_wrong_credentials(
         assert form_class.calls == [
             pretend.call(pyramid_request.POST, user_service=user_service)
         ]
-        assert send_password_reset_email.calls == [pretend.call(pyramid_request, None)]
+        assert send_password_reset_email.calls == [
+            pretend.call(pyramid_request, (stub_user, stub_user.emails[1]))
+        ]
 
     def test_redirect_authenticated_user(self):
         pyramid_request = pretend.stub(authenticated_userid=1)
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
@@ -235,9 +235,23 @@ def retry(exc):
 
 
 class TestSendPasswordResetEmail:
-    @pytest.mark.parametrize("verified", [True, False])
+    @pytest.mark.parametrize(
+        ("verified", "email_addr"),
+        [
+            (True, None),
+            (False, None),
+            (True, "other@example.com"),
+            (False, "other@example.com"),
+        ],
+    )
     def test_send_password_reset_email(
-        self, verified, pyramid_request, pyramid_config, token_service, monkeypatch
+        self,
+        verified,
+        email_addr,
+        pyramid_request,
+        pyramid_config,
+        token_service,
+        monkeypatch,
     ):
 
         stub_user = pretend.stub(
@@ -249,6 +263,10 @@ def test_send_password_reset_email(
             last_login="last_login",
             password_date="password_date",
         )
+        if email_addr is None:
+            stub_email = None
+        else:
+            stub_email = pretend.stub(email=email_addr, verified=verified)
         pyramid_request.method = "POST"
         token_service.dumps = pretend.call_recorder(lambda a: "TOKEN")
         pyramid_request.find_service = pretend.call_recorder(
@@ -274,7 +292,9 @@ def test_send_password_reset_email(
         pyramid_request.task = pretend.call_recorder(lambda *args, **kwargs: send_email)
         monkeypatch.setattr(email, "send_email", send_email)
 
-        result = email.send_password_reset_email(pyramid_request, stub_user)
+        result = email.send_password_reset_email(
+            pyramid_request, (stub_user, stub_email)
+        )
 
         assert result == {
             "token": "TOKEN",
@@ -300,7 +320,9 @@ def test_send_password_reset_email(
         assert pyramid_request.task.calls == [pretend.call(send_email)]
         assert send_email.delay.calls == [
             pretend.call(
-                "name_value <" + stub_user.email + ">",
+                "name_value <"
+                + (stub_user.email if email_addr is None else email_addr)
+                + ">",
                 attr.asdict(
                     EmailMessage(
                         subject="Email Subject",