Skip to content

Commit 148adc0

Browse files
rascalkingewdurbin
andauthored
Send email when a 2fa method is added or removed (#6930)
* initial working 2fa emails * only look for the request when we know we have work * just send the 2fa emails directly from the view also add tests and make the linter happy * s/2fa/two_factor/ Co-authored-by: Ernest W. Durbin III <[email protected]>
1 parent 08cc5f3 commit 148adc0

File tree

10 files changed

+240
-0
lines changed

10 files changed

+240
-0
lines changed

tests/unit/email/test_init.py

+69
Original file line numberDiff line numberDiff line change
@@ -1090,3 +1090,72 @@ def test_added_as_collaborator_email_unverified(
10901090

10911091
assert pyramid_request.task.calls == []
10921092
assert send_email.delay.calls == []
1093+
1094+
1095+
class TestTwoFactorEmail:
1096+
@pytest.mark.parametrize(
1097+
("action", "method", "pretty_method"),
1098+
[
1099+
("added", "totp", "TOTP"),
1100+
("removed", "totp", "TOTP"),
1101+
("added", "webauthn", "WebAuthn"),
1102+
("removed", "webauthn", "WebAuthn"),
1103+
],
1104+
)
1105+
def test_two_factor_email(
1106+
self,
1107+
pyramid_request,
1108+
pyramid_config,
1109+
monkeypatch,
1110+
action,
1111+
method,
1112+
pretty_method,
1113+
):
1114+
stub_user = pretend.stub(
1115+
username="username",
1116+
name="",
1117+
1118+
primary_email=pretend.stub(email="[email protected]", verified=True),
1119+
)
1120+
subject_renderer = pyramid_config.testing_add_renderer(
1121+
f"email/two-factor-{action}/subject.txt"
1122+
)
1123+
subject_renderer.string_response = "Email Subject"
1124+
body_renderer = pyramid_config.testing_add_renderer(
1125+
f"email/two-factor-{action}/body.txt"
1126+
)
1127+
body_renderer.string_response = "Email Body"
1128+
html_renderer = pyramid_config.testing_add_renderer(
1129+
f"email/two-factor-{action}/body.html"
1130+
)
1131+
html_renderer.string_response = "Email HTML Body"
1132+
1133+
send_email = pretend.stub(
1134+
delay=pretend.call_recorder(lambda *args, **kwargs: None)
1135+
)
1136+
pyramid_request.task = pretend.call_recorder(lambda *args, **kwargs: send_email)
1137+
monkeypatch.setattr(email, "send_email", send_email)
1138+
1139+
send_method = getattr(email, f"send_two_factor_{action}_email")
1140+
result = send_method(pyramid_request, stub_user, method=method)
1141+
1142+
assert result == {"method": pretty_method, "username": stub_user.username}
1143+
subject_renderer.assert_()
1144+
body_renderer.assert_(method=pretty_method, username=stub_user.username)
1145+
html_renderer.assert_(method=pretty_method, username=stub_user.username)
1146+
assert pyramid_request.task.calls == [pretend.call(send_email)]
1147+
assert send_email.delay.calls == [
1148+
pretend.call(
1149+
f"{stub_user.username} <{stub_user.email}>",
1150+
attr.asdict(
1151+
EmailMessage(
1152+
subject="Email Subject",
1153+
body_text="Email Body",
1154+
body_html=(
1155+
"<html>\n<head></head>\n"
1156+
"<body><p>Email HTML Body</p></body>\n</html>\n"
1157+
),
1158+
)
1159+
),
1160+
)
1161+
]

tests/unit/manage/test_views.py

+24
Original file line numberDiff line numberDiff line change
@@ -970,6 +970,9 @@ def test_validate_totp_provision(self, monkeypatch):
970970
provision_totp_cls = pretend.call_recorder(lambda *a, **kw: provision_totp_obj)
971971
monkeypatch.setattr(views, "ProvisionTOTPForm", provision_totp_cls)
972972

973+
send_email = pretend.call_recorder(lambda *a, **kw: None)
974+
monkeypatch.setattr(views, "send_two_factor_added_email", send_email)
975+
973976
view = views.ProvisionTOTPViews(request)
974977
result = view.validate_totp_provision()
975978

@@ -991,6 +994,9 @@ def test_validate_totp_provision(self, monkeypatch):
991994
additional={"method": "totp"},
992995
)
993996
]
997+
assert send_email.calls == [
998+
pretend.call(request, request.user, method="totp"),
999+
]
9941000

9951001
def test_validate_totp_provision_already_provisioned(self, monkeypatch):
9961002
user_service = pretend.stub(
@@ -1118,6 +1124,9 @@ def test_delete_totp(self, monkeypatch, db_request):
11181124
delete_totp_cls = pretend.call_recorder(lambda *a, **kw: delete_totp_obj)
11191125
monkeypatch.setattr(views, "DeleteTOTPForm", delete_totp_cls)
11201126

1127+
send_email = pretend.call_recorder(lambda *a, **kw: None)
1128+
monkeypatch.setattr(views, "send_two_factor_removed_email", send_email)
1129+
11211130
view = views.ProvisionTOTPViews(request)
11221131
result = view.delete_totp()
11231132

@@ -1141,6 +1150,9 @@ def test_delete_totp(self, monkeypatch, db_request):
11411150
additional={"method": "totp"},
11421151
)
11431152
]
1153+
assert send_email.calls == [
1154+
pretend.call(request, request.user, method="totp"),
1155+
]
11441156

11451157
def test_delete_totp_bad_password(self, monkeypatch, db_request):
11461158
user_service = pretend.stub(
@@ -1304,6 +1316,9 @@ def test_validate_webauthn_provision(self, monkeypatch):
13041316
)
13051317
monkeypatch.setattr(views, "ProvisionWebAuthnForm", provision_webauthn_cls)
13061318

1319+
send_email = pretend.call_recorder(lambda *a, **kw: None)
1320+
monkeypatch.setattr(views, "send_two_factor_added_email", send_email)
1321+
13071322
view = views.ProvisionWebAuthnViews(request)
13081323
result = view.validate_webauthn_provision()
13091324

@@ -1333,6 +1348,9 @@ def test_validate_webauthn_provision(self, monkeypatch):
13331348
},
13341349
)
13351350
]
1351+
assert send_email.calls == [
1352+
pretend.call(request, request.user, method="webauthn"),
1353+
]
13361354

13371355
def test_validate_webauthn_provision_invalid_form(self, monkeypatch):
13381356
user_service = pretend.stub(
@@ -1401,6 +1419,9 @@ def test_delete_webauthn(self, monkeypatch):
14011419
)
14021420
monkeypatch.setattr(views, "DeleteWebAuthnForm", delete_webauthn_cls)
14031421

1422+
send_email = pretend.call_recorder(lambda *a, **kw: None)
1423+
monkeypatch.setattr(views, "send_two_factor_removed_email", send_email)
1424+
14041425
view = views.ProvisionWebAuthnViews(request)
14051426
result = view.delete_webauthn()
14061427

@@ -1421,6 +1442,9 @@ def test_delete_webauthn(self, monkeypatch):
14211442
},
14221443
)
14231444
]
1445+
assert send_email.calls == [
1446+
pretend.call(request, request.user, method="webauthn"),
1447+
]
14241448

14251449
def test_delete_webauthn_not_provisioned(self):
14261450
request = pretend.stub(

warehouse/email/__init__.py

+12
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,18 @@ def send_added_as_collaborator_email(request, user, *, submitter, project_name,
201201
return {"project": project_name, "submitter": submitter.username, "role": role}
202202

203203

204+
@_email("two-factor-added")
205+
def send_two_factor_added_email(request, user, method):
206+
pretty_methods = {"totp": "TOTP", "webauthn": "WebAuthn"}
207+
return {"method": pretty_methods[method], "username": user.username}
208+
209+
210+
@_email("two-factor-removed")
211+
def send_two_factor_removed_email(request, user, method):
212+
pretty_methods = {"totp": "TOTP", "webauthn": "WebAuthn"}
213+
return {"method": pretty_methods[method], "username": user.username}
214+
215+
204216
def includeme(config):
205217
email_sending_class = config.maybe_dotted(config.registry.settings["mail.backend"])
206218
config.register_service_factory(email_sending_class.create_service, IEmailSender)

warehouse/manage/views.py

+13
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
send_email_verification_email,
3939
send_password_change_email,
4040
send_primary_email_change_email,
41+
send_two_factor_added_email,
42+
send_two_factor_removed_email,
4143
)
4244
from warehouse.i18n import localize as _
4345
from warehouse.macaroons.interfaces import IMacaroonService
@@ -461,6 +463,7 @@ def validate_totp_provision(self):
461463
self.request.session.flash(
462464
"Authentication application successfully set up", queue="success"
463465
)
466+
send_two_factor_added_email(self.request, self.request.user, method="totp")
464467

465468
return HTTPSeeOther(self.request.route_path("manage.account"))
466469

@@ -500,6 +503,9 @@ def delete_totp(self):
500503
"Remember to remove PyPI from your application.",
501504
queue="success",
502505
)
506+
send_two_factor_removed_email(
507+
self.request, self.request.user, method="totp"
508+
)
503509
else:
504510
self.request.session.flash("Invalid credentials. Try again", queue="error")
505511

@@ -575,6 +581,10 @@ def validate_webauthn_provision(self):
575581
self.request.session.flash(
576582
"Security device successfully set up", queue="success"
577583
)
584+
send_two_factor_added_email(
585+
self.request, self.request.user, method="webauthn"
586+
)
587+
578588
return {"success": "Security device successfully set up"}
579589

580590
errors = [
@@ -610,6 +620,9 @@ def delete_webauthn(self):
610620
additional={"method": "webauthn", "label": form.label.data},
611621
)
612622
self.request.session.flash("Security device removed", queue="success")
623+
send_two_factor_removed_email(
624+
self.request, self.request.user, method="webauthn"
625+
)
613626
else:
614627
self.request.session.flash("Invalid credentials", queue="error")
615628

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{#
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
-#}
14+
{% extends "email/_base/body.html" %}
15+
16+
17+
{% block content %}
18+
<p>{% trans method=method, username=username %}Someone, perhaps you, has added a {{ method }} two-factor authentication method to your PyPI account <strong>{{ username }}</strong>.{% endtrans %}</p>
19+
20+
<p>{% trans href='mailto:[email protected]', email_address='[email protected]' %}If you did not make this change, you can email <a href="{{ href }}">{{ email_address }}</a> to communicate with the PyPI administrators.{% endtrans %}</p>
21+
{% endblock %}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{#
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
-#}
14+
{% extends "email/_base/body.txt" %}
15+
16+
{% block content %}
17+
{% trans method=method, username=username %}Someone, perhaps you, has added a {{ method }} two-factor authentication method to your PyPI account
18+
'{{ username }}'.{% endtrans %}
19+
20+
{% trans email_address='[email protected]' %}If you did not make this change, you can email {{ email_address }} to
21+
communicate with the PyPI administrators.{% endtrans %}
22+
{% endblock %}
23+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{#
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
-#}
14+
15+
{% extends "email/_base/subject.txt" %}
16+
17+
{% block subject %}{% trans %}Two-factor method added{% endtrans %}{% endblock %}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{#
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
-#}
14+
{% extends "email/_base/body.html" %}
15+
16+
17+
{% block content %}
18+
<p>{% trans method=method, username=username %}Someone, perhaps you, has removed a {{ method }} two-factor authentication method from your PyPI account <strong>{{ username }}</strong>.{% endtrans %}</p>
19+
20+
<p>{% trans href='mailto:[email protected]', email_address='[email protected]' %}If you did not make this change, you can email <a href="{{ href }}">{{ email_address }}</a> to communicate with the PyPI administrators.{% endtrans %}</p>
21+
{% endblock %}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{#
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
-#}
14+
{% extends "email/_base/body.txt" %}
15+
16+
{% block content %}
17+
{% trans method=method, username=username %}Someone, perhaps you, has removed a {{ method }} two-factor authentication method from your PyPI account
18+
'{{ username }}'.{% endtrans %}
19+
20+
{% trans email_address='[email protected]' %}If you did not make this change, you can email {{ email_address }} to
21+
communicate with the PyPI administrators.{% endtrans %}
22+
{% endblock %}
23+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{#
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
-#}
14+
15+
{% extends "email/_base/subject.txt" %}
16+
17+
{% block subject %}{% trans %}Two-factor method removed{% endtrans %}{% endblock %}

0 commit comments

Comments
 (0)