Repository: pypi/warehouse

Issue:
Send confirmation when email address changed
Currently on PyPI, if a user changes their contact email address, there is no notification to the old email address, meaning account compromise can go unnoticed.

This means that an attacker can silently prevent other important security notifications (eg #997) from being sent to the real owner.



Discussion:
Thanks for another great suggestion, @edmorley, and sorry for the slow response!

This would be a very useful security feature, and now that #2898 is written and merged, it's far easier to add this feature.

Today in [our development meeting](https://wiki.python.org/psf/PackagingWG/2018-02-20-Warehouse) we discussed where this should go on [our development roadmap](https://wiki.python.org/psf/WarehouseRoadmap).

Since this feature isn't something that legacy PyPI has, I've moved the issue to a future milestone. But that doesn't have to stop a volunteer from working on it now; if someone wants to write up a pull request for it, go ahead!

Thanks and sorry again for the wait.
I think we should send an email whenever an

- email is added
- email is deleted
- email has been changed to primary email
@brainwane I'm looking into this and will soon put up a PR.

Resolving patch:
diff --git a/warehouse/email.py b/warehouse/email.py
--- a/warehouse/email.py
+++ b/warehouse/email.py
@@ -126,3 +126,23 @@ def send_account_deletion_email(request, user):
     request.task(send_email).delay(body, [user.email], subject)
 
     return fields
+
+
+def send_primary_email_change_email(request, user, email):
+    fields = {
+        'username': user.username,
+        'old_email': email,
+        'new_email': user.email,
+    }
+
+    subject = render(
+        'email/primary-email-change.subject.txt', fields, request=request
+    )
+
+    body = render(
+        'email/primary-email-change.body.txt', fields, request=request
+    )
+
+    request.task(send_email).delay(body, [email], subject)
+
+    return fields
diff --git a/warehouse/manage/views.py b/warehouse/manage/views.py
--- a/warehouse/manage/views.py
+++ b/warehouse/manage/views.py
@@ -23,7 +23,7 @@
 from warehouse.accounts.views import logout
 from warehouse.email import (
     send_account_deletion_email, send_email_verification_email,
-    send_password_change_email,
+    send_password_change_email, send_primary_email_change_email
 )
 from warehouse.manage.forms import (
     AddEmailForm, ChangePasswordForm, CreateRoleForm, ChangeRoleForm,
@@ -163,6 +163,7 @@ def delete_email(self):
         request_param=["primary_email_id"],
     )
     def change_primary_email(self):
+        previous_primary_email = self.request.user.email
         try:
             new_primary_email = self.request.db.query(Email).filter(
                 Email.user_id == self.request.user.id,
@@ -187,6 +188,9 @@ def change_primary_email(self):
             queue='success',
         )
 
+        send_primary_email_change_email(
+            self.request, self.request.user, previous_primary_email
+        )
         return self.default_response
 
     @view_config(


Test patch:
diff --git a/tests/unit/manage/test_views.py b/tests/unit/manage/test_views.py
--- a/tests/unit/manage/test_views.py
+++ b/tests/unit/manage/test_views.py
@@ -379,8 +379,10 @@ def test_change_primary_email(self, monkeypatch, db_request):
         user = UserFactory()
         old_primary = EmailFactory(primary=True, user=user)
         new_primary = EmailFactory(primary=False, verified=True, user=user)
+        email = EmailFactory(user=user)
 
         db_request.user = user
+
         db_request.find_service = lambda *a, **kw: pretend.stub()
         db_request.POST = {'primary_email_id': new_primary.id}
         db_request.session.flash = pretend.call_recorder(lambda *a, **kw: None)
@@ -389,7 +391,14 @@ def test_change_primary_email(self, monkeypatch, db_request):
         )
         view = views.ManageAccountViews(db_request)
 
+        send_email = pretend.call_recorder(lambda *a: None)
+        monkeypatch.setattr(
+            views, 'send_primary_email_change_email', send_email
+        )
         assert view.change_primary_email() == view.default_response
+        assert send_email.calls == [
+            pretend.call(db_request, db_request.user, email.email)
+        ]
         assert db_request.session.flash.calls == [
             pretend.call(
                 f'Email address {new_primary.email} set as primary.',
diff --git a/tests/unit/test_email.py b/tests/unit/test_email.py
--- a/tests/unit/test_email.py
+++ b/tests/unit/test_email.py
@@ -305,3 +305,50 @@ def test_account_deletion_email(
         assert send_email.delay.calls == [
             pretend.call('Email Body', [stub_user.email], 'Email Subject'),
         ]
+
+
+class TestPrimaryEmailChangeEmail:
+
+    def test_primary_email_change_email(
+            self, pyramid_request, pyramid_config, monkeypatch):
+
+        stub_user = pretend.stub(
+            email='new_email',
+            username='username',
+        )
+        subject_renderer = pyramid_config.testing_add_renderer(
+            'email/primary-email-change.subject.txt'
+        )
+        subject_renderer.string_response = 'Email Subject'
+        body_renderer = pyramid_config.testing_add_renderer(
+            'email/primary-email-change.body.txt'
+        )
+        body_renderer.string_response = 'Email Body'
+
+        send_email = pretend.stub(
+            delay=pretend.call_recorder(lambda *args, **kwargs: None)
+        )
+        pyramid_request.task = pretend.call_recorder(
+            lambda *args, **kwargs: send_email
+        )
+        monkeypatch.setattr(email, 'send_email', send_email)
+
+        result = email.send_primary_email_change_email(
+            pyramid_request,
+            stub_user,
+            "old_email"
+        )
+
+        assert result == {
+            'username': stub_user.username,
+            'old_email': "old_email",
+            'new_email': stub_user.email
+        }
+        subject_renderer.assert_()
+        body_renderer.assert_(username=stub_user.username)
+        assert pyramid_request.task.calls == [
+            pretend.call(send_email),
+        ]
+        assert send_email.delay.calls == [
+            pretend.call('Email Body', ['old_email'], 'Email Subject'),
+        ]