Skip to content

Commit 7244a67

Browse files
didomdfcoding
authored andcommitted
Send notification email on basic auth with 2FA (pypi#10836)
* Slight refactoring and adding comments * Send notification email on basic auth with 2FA * Update translations
1 parent 3b49fef commit 7244a67

File tree

8 files changed

+308
-51
lines changed

8 files changed

+308
-51
lines changed

tests/unit/accounts/test_core.py

Lines changed: 78 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def test_invalid_route(self, pyramid_request, pyramid_services):
4040
pretend.stub(), IPasswordBreachedService, None
4141
)
4242
pyramid_request.matched_route = pretend.stub(name="route_name")
43-
assert accounts._basic_auth_login("myuser", "mypass", pyramid_request) is None
43+
assert accounts._basic_auth_check("myuser", "mypass", pyramid_request) is None
4444
assert service.find_userid.calls == []
4545

4646
def test_with_no_user(self, pyramid_request, pyramid_services):
@@ -50,7 +50,7 @@ def test_with_no_user(self, pyramid_request, pyramid_services):
5050
pretend.stub(), IPasswordBreachedService, None
5151
)
5252
pyramid_request.matched_route = pretend.stub(name="forklift.legacy.file_upload")
53-
assert accounts._basic_auth_login("myuser", "mypass", pyramid_request) is None
53+
assert accounts._basic_auth_check("myuser", "mypass", pyramid_request) is None
5454
assert service.find_userid.calls == [pretend.call("myuser")]
5555

5656
def test_with_invalid_password(self, pyramid_request, pyramid_services):
@@ -75,7 +75,7 @@ def test_with_invalid_password(self, pyramid_request, pyramid_services):
7575

7676
with pytest.raises(BasicAuthFailedPassword) as excinfo:
7777
assert (
78-
accounts._basic_auth_login("myuser", "mypass", pyramid_request) is None
78+
accounts._basic_auth_check("myuser", "mypass", pyramid_request) is None
7979
)
8080

8181
assert excinfo.value.status == (
@@ -122,7 +122,7 @@ def test_with_disabled_user_no_reason(self, pyramid_request, pyramid_services):
122122

123123
with pytest.raises(BasicAuthFailedPassword) as excinfo:
124124
assert (
125-
accounts._basic_auth_login("myuser", "mypass", pyramid_request) is None
125+
accounts._basic_auth_check("myuser", "mypass", pyramid_request) is None
126126
)
127127

128128
assert excinfo.value.status == (
@@ -169,7 +169,7 @@ def test_with_disabled_user_compromised_pw(self, pyramid_request, pyramid_servic
169169

170170
with pytest.raises(BasicAuthBreachedPassword) as excinfo:
171171
assert (
172-
accounts._basic_auth_login("myuser", "mypass", pyramid_request) is None
172+
accounts._basic_auth_check("myuser", "mypass", pyramid_request) is None
173173
)
174174

175175
assert excinfo.value.status == "401 Bad Password!"
@@ -183,7 +183,7 @@ def test_with_valid_password(self, monkeypatch, pyramid_request, pyramid_service
183183
authenticate = pretend.call_recorder(lambda userid, request: principals)
184184
monkeypatch.setattr(accounts, "_authenticate", authenticate)
185185

186-
user = pretend.stub(id=2)
186+
user = pretend.stub(id=2, has_two_factor=False)
187187
service = pretend.stub(
188188
get_user=pretend.call_recorder(lambda user_id: user),
189189
find_userid=pretend.call_recorder(lambda username: 2),
@@ -208,7 +208,7 @@ def test_with_valid_password(self, monkeypatch, pyramid_request, pyramid_service
208208

209209
with freezegun.freeze_time(now):
210210
assert (
211-
accounts._basic_auth_login("myuser", "mypass", pyramid_request)
211+
accounts._basic_auth_check("myuser", "mypass", pyramid_request)
212212
is principals
213213
)
214214

@@ -259,7 +259,7 @@ def test_via_basic_auth_compromised(
259259
pyramid_request.matched_route = pretend.stub(name="forklift.legacy.file_upload")
260260

261261
with pytest.raises(BasicAuthBreachedPassword) as excinfo:
262-
accounts._basic_auth_login("myuser", "mypass", pyramid_request)
262+
accounts._basic_auth_check("myuser", "mypass", pyramid_request)
263263

264264
assert excinfo.value.status == "401 Bad Password!"
265265
assert service.find_userid.calls == [pretend.call("myuser")]
@@ -280,6 +280,64 @@ def test_via_basic_auth_compromised(
280280
]
281281
assert send_email.calls == [pretend.call(pyramid_request, user)]
282282

283+
def test_via_basic_auth_2fa_enabled(
284+
self, monkeypatch, pyramid_request, pyramid_services
285+
):
286+
principals = pretend.stub()
287+
authenticate = pretend.call_recorder(lambda userid, request: principals)
288+
monkeypatch.setattr(accounts, "_authenticate", authenticate)
289+
send_email = pretend.call_recorder(lambda *a, **kw: None)
290+
monkeypatch.setattr(
291+
accounts, "send_basic_auth_with_two_factor_email", send_email
292+
)
293+
294+
user = pretend.stub(id=2, has_two_factor=True)
295+
service = pretend.stub(
296+
get_user=pretend.call_recorder(lambda user_id: user),
297+
find_userid=pretend.call_recorder(lambda username: 2),
298+
check_password=pretend.call_recorder(
299+
lambda userid, password, tags=None: True
300+
),
301+
is_disabled=pretend.call_recorder(lambda user_id: (False, None)),
302+
disable_password=pretend.call_recorder(lambda user_id, reason=None: None),
303+
update_user=pretend.call_recorder(lambda userid, last_login: None),
304+
)
305+
breach_service = pretend.stub(
306+
check_password=pretend.call_recorder(lambda pw, tags=None: False)
307+
)
308+
309+
pyramid_services.register_service(service, IUserService, None)
310+
pyramid_services.register_service(
311+
breach_service, IPasswordBreachedService, None
312+
)
313+
314+
pyramid_request.matched_route = pretend.stub(name="forklift.legacy.file_upload")
315+
316+
now = datetime.datetime.utcnow()
317+
318+
with freezegun.freeze_time(now):
319+
assert (
320+
accounts._basic_auth_check("myuser", "mypass", pyramid_request)
321+
is principals
322+
)
323+
324+
assert service.find_userid.calls == [pretend.call("myuser")]
325+
assert service.get_user.calls == [pretend.call(2)]
326+
assert service.is_disabled.calls == [pretend.call(2)]
327+
assert service.check_password.calls == [
328+
pretend.call(
329+
2,
330+
"mypass",
331+
tags=["mechanism:basic_auth", "method:auth", "auth_method:basic"],
332+
)
333+
]
334+
assert breach_service.check_password.calls == [
335+
pretend.call("mypass", tags=["method:auth", "auth_method:basic"])
336+
]
337+
assert send_email.calls == [pretend.call(pyramid_request, user)]
338+
assert service.update_user.calls == [pretend.call(2, last_login=now)]
339+
assert authenticate.calls == [pretend.call(2, pyramid_request)]
340+
283341

284342
class TestAuthenticate:
285343
@pytest.mark.parametrize(
@@ -372,6 +430,17 @@ def test_route_matched_name_ok(self, monkeypatch):
372430
assert authenticate_obj.calls == [pretend.call(1, request)]
373431

374432

433+
class TestMacaroonAuthenticate:
434+
def test_macaroon_authenticate(self, monkeypatch):
435+
authenticate_obj = pretend.call_recorder(lambda *a, **kw: True)
436+
monkeypatch.setattr(accounts, "_authenticate", authenticate_obj)
437+
request = pretend.stub(
438+
matched_route=pretend.stub(name="includes.current-user-indicator")
439+
)
440+
assert accounts._macaroon_authenticate(1, request) is True
441+
assert authenticate_obj.calls == [pretend.call(1, request)]
442+
443+
375444
class TestUser:
376445
def test_with_user(self):
377446
user = pretend.stub()
@@ -466,7 +535,7 @@ def test_includeme(monkeypatch):
466535
]
467536
assert config.set_authentication_policy.calls == [pretend.call(authn_obj)]
468537
assert config.set_authorization_policy.calls == [pretend.call(authz_obj)]
469-
assert basic_authn_cls.calls == [pretend.call(check=accounts._basic_auth_login)]
538+
assert basic_authn_cls.calls == [pretend.call(check=accounts._basic_auth_check)]
470539
assert session_authn_cls.calls == [
471540
pretend.call(callback=accounts._session_authenticate)
472541
]

tests/unit/email/test_init.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -942,6 +942,77 @@ def test_password_compromised_email(
942942
]
943943

944944

945+
class TestBasicAuthWith2FAEmail:
946+
@pytest.mark.parametrize("verified", [True, False])
947+
def test_basic_auth_with_2fa_email(
948+
self, pyramid_request, pyramid_config, monkeypatch, verified
949+
):
950+
stub_user = pretend.stub(
951+
id="id",
952+
username="username",
953+
name="",
954+
955+
primary_email=pretend.stub(email="[email protected]", verified=verified),
956+
)
957+
subject_renderer = pyramid_config.testing_add_renderer(
958+
"email/basic-auth-with-2fa/subject.txt"
959+
)
960+
subject_renderer.string_response = "Email Subject"
961+
body_renderer = pyramid_config.testing_add_renderer(
962+
"email/basic-auth-with-2fa/body.txt"
963+
)
964+
body_renderer.string_response = "Email Body"
965+
html_renderer = pyramid_config.testing_add_renderer(
966+
"email/basic-auth-with-2fa/body.html"
967+
)
968+
html_renderer.string_response = "Email HTML Body"
969+
970+
send_email = pretend.stub(
971+
delay=pretend.call_recorder(lambda *args, **kwargs: None)
972+
)
973+
pyramid_request.task = pretend.call_recorder(lambda *args, **kwargs: send_email)
974+
monkeypatch.setattr(email, "send_email", send_email)
975+
976+
pyramid_request.db = pretend.stub(
977+
query=lambda a: pretend.stub(
978+
filter=lambda *a: pretend.stub(
979+
one=lambda: pretend.stub(user_id=stub_user.id)
980+
)
981+
),
982+
)
983+
pyramid_request.user = stub_user
984+
pyramid_request.registry.settings = {"mail.sender": "[email protected]"}
985+
986+
result = email.send_basic_auth_with_two_factor_email(pyramid_request, stub_user)
987+
988+
assert result == {}
989+
assert pyramid_request.task.calls == [pretend.call(send_email)]
990+
assert send_email.delay.calls == [
991+
pretend.call(
992+
f"{stub_user.username} <{stub_user.email}>",
993+
{
994+
"subject": "Email Subject",
995+
"body_text": "Email Body",
996+
"body_html": (
997+
"<html>\n<head></head>\n"
998+
"<body><p>Email HTML Body</p></body>\n</html>\n"
999+
),
1000+
},
1001+
{
1002+
"tag": "account:email:sent",
1003+
"user_id": stub_user.id,
1004+
"ip_address": pyramid_request.remote_addr,
1005+
"additional": {
1006+
"from_": "[email protected]",
1007+
"to": stub_user.email,
1008+
"subject": "Email Subject",
1009+
"redact_ip": False,
1010+
},
1011+
},
1012+
)
1013+
]
1014+
1015+
9451016
class TestAccountDeletionEmail:
9461017
def test_account_deletion_email(self, pyramid_request, pyramid_config, monkeypatch):
9471018

warehouse/accounts/__init__.py

Lines changed: 44 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,10 @@
3232
TokenServiceFactory,
3333
database_login_factory,
3434
)
35-
from warehouse.email import send_password_compromised_email_hibp
35+
from warehouse.email import (
36+
send_basic_auth_with_two_factor_email,
37+
send_password_compromised_email_hibp,
38+
)
3639
from warehouse.errors import BasicAuthBreachedPassword, BasicAuthFailedPassword
3740
from warehouse.macaroons.auth_policy import (
3841
MacaroonAuthenticationPolicy,
@@ -51,7 +54,32 @@ def _format_exc_status(exc, message):
5154
return exc
5255

5356

54-
def _basic_auth_login(username, password, request):
57+
def _authenticate(userid, request):
58+
"""Apply the necessary principals to the authenticated user"""
59+
login_service = request.find_service(IUserService, context=None)
60+
user = login_service.get_user(userid)
61+
62+
if user is None:
63+
return
64+
65+
principals = []
66+
67+
if user.is_superuser:
68+
principals.append("group:admins")
69+
if user.is_moderator or user.is_superuser:
70+
principals.append("group:moderators")
71+
if user.is_psf_staff or user.is_superuser:
72+
principals.append("group:psf_staff")
73+
74+
# user must have base admin access if any admin permission
75+
if principals:
76+
principals.append("group:with_admin_dashboard_access")
77+
78+
return principals
79+
80+
81+
def _basic_auth_check(username, password, request):
82+
# Basic authentication can only be used for uploading
5583
if request.matched_route.name not in ["forklift.legacy.file_upload"]:
5684
return
5785

@@ -89,11 +117,13 @@ def _basic_auth_login(username, password, request):
89117
raise _format_exc_status(
90118
BasicAuthBreachedPassword(), breach_service.failure_message_plain
91119
)
92-
else:
93-
login_service.update_user(
94-
user.id, last_login=datetime.datetime.utcnow()
95-
)
96-
return _authenticate(user.id, request)
120+
121+
if user.has_two_factor:
122+
send_basic_auth_with_two_factor_email(request, user)
123+
# Eventually, raise here to disable basic auth with 2FA enabled
124+
125+
login_service.update_user(user.id, last_login=datetime.datetime.utcnow())
126+
return _authenticate(user.id, request)
97127
else:
98128
user.record_event(
99129
tag="account:login:failure",
@@ -109,36 +139,18 @@ def _basic_auth_login(username, password, request):
109139
)
110140

111141

112-
def _authenticate(userid, request):
113-
login_service = request.find_service(IUserService, context=None)
114-
user = login_service.get_user(userid)
115-
116-
if user is None:
117-
return
118-
119-
principals = []
120-
121-
if user.is_superuser:
122-
principals.append("group:admins")
123-
if user.is_moderator or user.is_superuser:
124-
principals.append("group:moderators")
125-
if user.is_psf_staff or user.is_superuser:
126-
principals.append("group:psf_staff")
127-
128-
# user must have base admin access if any admin permission
129-
if principals:
130-
principals.append("group:with_admin_dashboard_access")
131-
132-
return principals
133-
134-
135142
def _session_authenticate(userid, request):
143+
# Session authentication cannot be used for uploading
136144
if request.matched_route.name in ["forklift.legacy.file_upload"]:
137145
return
138146

139147
return _authenticate(userid, request)
140148

141149

150+
def _macaroon_authenticate(userid, request):
151+
return _authenticate(userid, request)
152+
153+
142154
def _user(request):
143155
userid = request.authenticated_userid
144156

@@ -179,8 +191,8 @@ def includeme(config):
179191
MultiAuthenticationPolicy(
180192
[
181193
SessionAuthenticationPolicy(callback=_session_authenticate),
182-
BasicAuthAuthenticationPolicy(check=_basic_auth_login),
183-
MacaroonAuthenticationPolicy(callback=_authenticate),
194+
BasicAuthAuthenticationPolicy(check=_basic_auth_check),
195+
MacaroonAuthenticationPolicy(callback=_macaroon_authenticate),
184196
]
185197
)
186198
)

warehouse/email/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,11 @@ def send_token_compromised_email_leak(request, user, *, public_url, origin):
212212
return {"username": user.username, "public_url": public_url, "origin": origin}
213213

214214

215+
@_email("basic-auth-with-2fa", allow_unverified=True)
216+
def send_basic_auth_with_two_factor_email(request, user):
217+
return {}
218+
219+
215220
@_email("account-deleted")
216221
def send_account_deletion_email(request, user):
217222
return {"username": user.username}

0 commit comments

Comments
 (0)