diff --git a/tests/unit/email/test_init.py b/tests/unit/email/test_init.py index 8f6c95fa4eaa..cbe230b597a4 100644 --- a/tests/unit/email/test_init.py +++ b/tests/unit/email/test_init.py @@ -1090,3 +1090,72 @@ def test_added_as_collaborator_email_unverified( assert pyramid_request.task.calls == [] assert send_email.delay.calls == [] + + +class TestTwoFactorEmail: + @pytest.mark.parametrize( + ("action", "method", "pretty_method"), + [ + ("added", "totp", "TOTP"), + ("removed", "totp", "TOTP"), + ("added", "webauthn", "WebAuthn"), + ("removed", "webauthn", "WebAuthn"), + ], + ) + def test_two_factor_email( + self, + pyramid_request, + pyramid_config, + monkeypatch, + action, + method, + pretty_method, + ): + stub_user = pretend.stub( + username="username", + name="", + email="email@example.com", + primary_email=pretend.stub(email="email@example.com", verified=True), + ) + subject_renderer = pyramid_config.testing_add_renderer( + f"email/two-factor-{action}/subject.txt" + ) + subject_renderer.string_response = "Email Subject" + body_renderer = pyramid_config.testing_add_renderer( + f"email/two-factor-{action}/body.txt" + ) + body_renderer.string_response = "Email Body" + html_renderer = pyramid_config.testing_add_renderer( + f"email/two-factor-{action}/body.html" + ) + html_renderer.string_response = "Email HTML 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) + + send_method = getattr(email, f"send_two_factor_{action}_email") + result = send_method(pyramid_request, stub_user, method=method) + + assert result == {"method": pretty_method, "username": stub_user.username} + subject_renderer.assert_() + body_renderer.assert_(method=pretty_method, username=stub_user.username) + html_renderer.assert_(method=pretty_method, username=stub_user.username) + assert pyramid_request.task.calls == [pretend.call(send_email)] + assert send_email.delay.calls == [ + pretend.call( + f"{stub_user.username} <{stub_user.email}>", + attr.asdict( + EmailMessage( + subject="Email Subject", + body_text="Email Body", + body_html=( + "\n
\n" + "Email HTML Body
\n\n" + ), + ) + ), + ) + ] diff --git a/tests/unit/manage/test_views.py b/tests/unit/manage/test_views.py index 80c1b5392b13..a07d70e1d017 100644 --- a/tests/unit/manage/test_views.py +++ b/tests/unit/manage/test_views.py @@ -970,6 +970,9 @@ def test_validate_totp_provision(self, monkeypatch): provision_totp_cls = pretend.call_recorder(lambda *a, **kw: provision_totp_obj) monkeypatch.setattr(views, "ProvisionTOTPForm", provision_totp_cls) + send_email = pretend.call_recorder(lambda *a, **kw: None) + monkeypatch.setattr(views, "send_two_factor_added_email", send_email) + view = views.ProvisionTOTPViews(request) result = view.validate_totp_provision() @@ -991,6 +994,9 @@ def test_validate_totp_provision(self, monkeypatch): additional={"method": "totp"}, ) ] + assert send_email.calls == [ + pretend.call(request, request.user, method="totp"), + ] def test_validate_totp_provision_already_provisioned(self, monkeypatch): user_service = pretend.stub( @@ -1118,6 +1124,9 @@ def test_delete_totp(self, monkeypatch, db_request): delete_totp_cls = pretend.call_recorder(lambda *a, **kw: delete_totp_obj) monkeypatch.setattr(views, "DeleteTOTPForm", delete_totp_cls) + send_email = pretend.call_recorder(lambda *a, **kw: None) + monkeypatch.setattr(views, "send_two_factor_removed_email", send_email) + view = views.ProvisionTOTPViews(request) result = view.delete_totp() @@ -1141,6 +1150,9 @@ def test_delete_totp(self, monkeypatch, db_request): additional={"method": "totp"}, ) ] + assert send_email.calls == [ + pretend.call(request, request.user, method="totp"), + ] def test_delete_totp_bad_password(self, monkeypatch, db_request): user_service = pretend.stub( @@ -1304,6 +1316,9 @@ def test_validate_webauthn_provision(self, monkeypatch): ) monkeypatch.setattr(views, "ProvisionWebAuthnForm", provision_webauthn_cls) + send_email = pretend.call_recorder(lambda *a, **kw: None) + monkeypatch.setattr(views, "send_two_factor_added_email", send_email) + view = views.ProvisionWebAuthnViews(request) result = view.validate_webauthn_provision() @@ -1333,6 +1348,9 @@ def test_validate_webauthn_provision(self, monkeypatch): }, ) ] + assert send_email.calls == [ + pretend.call(request, request.user, method="webauthn"), + ] def test_validate_webauthn_provision_invalid_form(self, monkeypatch): user_service = pretend.stub( @@ -1401,6 +1419,9 @@ def test_delete_webauthn(self, monkeypatch): ) monkeypatch.setattr(views, "DeleteWebAuthnForm", delete_webauthn_cls) + send_email = pretend.call_recorder(lambda *a, **kw: None) + monkeypatch.setattr(views, "send_two_factor_removed_email", send_email) + view = views.ProvisionWebAuthnViews(request) result = view.delete_webauthn() @@ -1421,6 +1442,9 @@ def test_delete_webauthn(self, monkeypatch): }, ) ] + assert send_email.calls == [ + pretend.call(request, request.user, method="webauthn"), + ] def test_delete_webauthn_not_provisioned(self): request = pretend.stub( diff --git a/warehouse/email/__init__.py b/warehouse/email/__init__.py index ccb8d7413394..8b6b170498b7 100644 --- a/warehouse/email/__init__.py +++ b/warehouse/email/__init__.py @@ -201,6 +201,18 @@ def send_added_as_collaborator_email(request, user, *, submitter, project_name, return {"project": project_name, "submitter": submitter.username, "role": role} +@_email("two-factor-added") +def send_two_factor_added_email(request, user, method): + pretty_methods = {"totp": "TOTP", "webauthn": "WebAuthn"} + return {"method": pretty_methods[method], "username": user.username} + + +@_email("two-factor-removed") +def send_two_factor_removed_email(request, user, method): + pretty_methods = {"totp": "TOTP", "webauthn": "WebAuthn"} + return {"method": pretty_methods[method], "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) diff --git a/warehouse/manage/views.py b/warehouse/manage/views.py index c3ca7b868ff2..5c38151a5d98 100644 --- a/warehouse/manage/views.py +++ b/warehouse/manage/views.py @@ -38,6 +38,8 @@ send_email_verification_email, send_password_change_email, send_primary_email_change_email, + send_two_factor_added_email, + send_two_factor_removed_email, ) from warehouse.i18n import localize as _ from warehouse.macaroons.interfaces import IMacaroonService @@ -461,6 +463,7 @@ def validate_totp_provision(self): self.request.session.flash( "Authentication application successfully set up", queue="success" ) + send_two_factor_added_email(self.request, self.request.user, method="totp") return HTTPSeeOther(self.request.route_path("manage.account")) @@ -500,6 +503,9 @@ def delete_totp(self): "Remember to remove PyPI from your application.", queue="success", ) + send_two_factor_removed_email( + self.request, self.request.user, method="totp" + ) else: self.request.session.flash("Invalid credentials. Try again", queue="error") @@ -575,6 +581,10 @@ def validate_webauthn_provision(self): self.request.session.flash( "Security device successfully set up", queue="success" ) + send_two_factor_added_email( + self.request, self.request.user, method="webauthn" + ) + return {"success": "Security device successfully set up"} errors = [ @@ -610,6 +620,9 @@ def delete_webauthn(self): additional={"method": "webauthn", "label": form.label.data}, ) self.request.session.flash("Security device removed", queue="success") + send_two_factor_removed_email( + self.request, self.request.user, method="webauthn" + ) else: self.request.session.flash("Invalid credentials", queue="error") diff --git a/warehouse/templates/email/two-factor-added/body.html b/warehouse/templates/email/two-factor-added/body.html new file mode 100644 index 000000000000..b2284d037a4b --- /dev/null +++ b/warehouse/templates/email/two-factor-added/body.html @@ -0,0 +1,21 @@ +{# + # Licensed under the Apache License, Version 2.0 (the "License"); + # you may not use this file except in compliance with the License. + # You may obtain a copy of the License at + # + # http://www.apache.org/licenses/LICENSE-2.0 + # + # Unless required by applicable law or agreed to in writing, software + # distributed under the License is distributed on an "AS IS" BASIS, + # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + # See the License for the specific language governing permissions and + # limitations under the License. +-#} +{% extends "email/_base/body.html" %} + + +{% block content %} +{% trans method=method, username=username %}Someone, perhaps you, has added a {{ method }} two-factor authentication method to your PyPI account {{ username }}.{% endtrans %}
+ +{% trans href='mailto:admin@pypi.org', email_address='admin@pypi.org' %}If you did not make this change, you can email {{ email_address }} to communicate with the PyPI administrators.{% endtrans %}
+{% endblock %} diff --git a/warehouse/templates/email/two-factor-added/body.txt b/warehouse/templates/email/two-factor-added/body.txt new file mode 100644 index 000000000000..3b6972a29aaf --- /dev/null +++ b/warehouse/templates/email/two-factor-added/body.txt @@ -0,0 +1,23 @@ +{# + # Licensed under the Apache License, Version 2.0 (the "License"); + # you may not use this file except in compliance with the License. + # You may obtain a copy of the License at + # + # http://www.apache.org/licenses/LICENSE-2.0 + # + # Unless required by applicable law or agreed to in writing, software + # distributed under the License is distributed on an "AS IS" BASIS, + # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + # See the License for the specific language governing permissions and + # limitations under the License. +-#} +{% extends "email/_base/body.txt" %} + +{% block content %} +{% trans method=method, username=username %}Someone, perhaps you, has added a {{ method }} two-factor authentication method to your PyPI account +'{{ username }}'.{% endtrans %} + +{% trans email_address='admin@pypi.org' %}If you did not make this change, you can email {{ email_address }} to +communicate with the PyPI administrators.{% endtrans %} +{% endblock %} + diff --git a/warehouse/templates/email/two-factor-added/subject.txt b/warehouse/templates/email/two-factor-added/subject.txt new file mode 100644 index 000000000000..1638bd6a3c73 --- /dev/null +++ b/warehouse/templates/email/two-factor-added/subject.txt @@ -0,0 +1,17 @@ +{# + # Licensed under the Apache License, Version 2.0 (the "License"); + # you may not use this file except in compliance with the License. + # You may obtain a copy of the License at + # + # http://www.apache.org/licenses/LICENSE-2.0 + # + # Unless required by applicable law or agreed to in writing, software + # distributed under the License is distributed on an "AS IS" BASIS, + # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + # See the License for the specific language governing permissions and + # limitations under the License. +-#} + +{% extends "email/_base/subject.txt" %} + +{% block subject %}{% trans %}Two-factor method added{% endtrans %}{% endblock %} diff --git a/warehouse/templates/email/two-factor-removed/body.html b/warehouse/templates/email/two-factor-removed/body.html new file mode 100644 index 000000000000..ed171f574c7e --- /dev/null +++ b/warehouse/templates/email/two-factor-removed/body.html @@ -0,0 +1,21 @@ +{# + # Licensed under the Apache License, Version 2.0 (the "License"); + # you may not use this file except in compliance with the License. + # You may obtain a copy of the License at + # + # http://www.apache.org/licenses/LICENSE-2.0 + # + # Unless required by applicable law or agreed to in writing, software + # distributed under the License is distributed on an "AS IS" BASIS, + # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + # See the License for the specific language governing permissions and + # limitations under the License. +-#} +{% extends "email/_base/body.html" %} + + +{% block content %} +{% trans method=method, username=username %}Someone, perhaps you, has removed a {{ method }} two-factor authentication method from your PyPI account {{ username }}.{% endtrans %}
+ +{% trans href='mailto:admin@pypi.org', email_address='admin@pypi.org' %}If you did not make this change, you can email {{ email_address }} to communicate with the PyPI administrators.{% endtrans %}
+{% endblock %} diff --git a/warehouse/templates/email/two-factor-removed/body.txt b/warehouse/templates/email/two-factor-removed/body.txt new file mode 100644 index 000000000000..cc17ca474acb --- /dev/null +++ b/warehouse/templates/email/two-factor-removed/body.txt @@ -0,0 +1,23 @@ +{# + # Licensed under the Apache License, Version 2.0 (the "License"); + # you may not use this file except in compliance with the License. + # You may obtain a copy of the License at + # + # http://www.apache.org/licenses/LICENSE-2.0 + # + # Unless required by applicable law or agreed to in writing, software + # distributed under the License is distributed on an "AS IS" BASIS, + # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + # See the License for the specific language governing permissions and + # limitations under the License. +-#} +{% extends "email/_base/body.txt" %} + +{% block content %} +{% trans method=method, username=username %}Someone, perhaps you, has removed a {{ method }} two-factor authentication method from your PyPI account +'{{ username }}'.{% endtrans %} + +{% trans email_address='admin@pypi.org' %}If you did not make this change, you can email {{ email_address }} to +communicate with the PyPI administrators.{% endtrans %} +{% endblock %} + diff --git a/warehouse/templates/email/two-factor-removed/subject.txt b/warehouse/templates/email/two-factor-removed/subject.txt new file mode 100644 index 000000000000..501b346e56e1 --- /dev/null +++ b/warehouse/templates/email/two-factor-removed/subject.txt @@ -0,0 +1,17 @@ +{# + # Licensed under the Apache License, Version 2.0 (the "License"); + # you may not use this file except in compliance with the License. + # You may obtain a copy of the License at + # + # http://www.apache.org/licenses/LICENSE-2.0 + # + # Unless required by applicable law or agreed to in writing, software + # distributed under the License is distributed on an "AS IS" BASIS, + # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + # See the License for the specific language governing permissions and + # limitations under the License. +-#} + +{% extends "email/_base/subject.txt" %} + +{% block subject %}{% trans %}Two-factor method removed{% endtrans %}{% endblock %}