Skip to content

Commit 63ca8ed

Browse files
authored
Enforce API Token requirement for 2FA users (#13830)
* Enforce API Token requirement for 2FA users * spelling and a lil tweak
1 parent 1043822 commit 63ca8ed

File tree

6 files changed

+124
-1
lines changed

6 files changed

+124
-1
lines changed
Loading
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
title: Enforcement of 2FA for upload.pypi.org begins today
3+
description: PyPI now requires all uploads from accounts with 2FA enabled
4+
to use an API token or Trusted Publisher configuration.
5+
author: Ee Durbin
6+
publish_date: 2023-06-01
7+
date: "2023-06-01 00:00"
8+
tags:
9+
- security
10+
- 2fa
11+
---
12+
13+
Beginning today, all uploads from user accounts with 2FA enabled
14+
will be required to use an [API Token](https://pypi.org/help/#apitoken)
15+
or [Trusted Publisher](https://docs.pypi.org/trusted-publishers/) configuration
16+
in place of their password.
17+
18+
This change has [been planned](https://github.com/pypi/warehouse/issues/7265)
19+
since 2FA was rolled out in 2019.
20+
In [February of 2022](https://github.com/pypi/warehouse/pull/10836)
21+
we began notifying users on upload that this change was coming.
22+
23+
If you have 2FA enabled and have been using only your password to upload,
24+
the following email is likely familiar to you:
25+
26+
<figure markdown>
27+
![Sample notice email](/assets/2023-06-01-2fa-notice-email.png)
28+
<figcaption>
29+
A sample notice email sent when users with 2FA enabled
30+
upload using only their password.
31+
</figcaption>
32+
</figure>
33+
34+
Initially, we intended for this notice to live for six months before
35+
we began enforcement.
36+
37+
However, some valid concerns were raised regarding
38+
the use of user-scoped API tokens for new project creation.
39+
40+
With the [introduction of Trusted Publishers](/posts/2023-04-20-introducing-trusted-publishers/)
41+
PyPI now provides a way for users to publish **new** projects without
42+
provisioning a user-scoped token, and to continue publishing without
43+
ever provisioning a long lived API token whatsoever.
44+
45+
Given this, and our [commitment to further rolling out 2FA across PyPI](/posts/2023-05-25-securing-pypi-with-2fa/),
46+
we are now enforcing this policy.
47+
48+
---
49+
50+
_Ee Durbin is the Director of Infrastructure at
51+
the Python Software Foundation.
52+
They have been contributing to keeping PyPI online, available, and
53+
secure since 2013._

docs/mkdocs-blog.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ theme:
2222
markdown_extensions:
2323
- footnotes
2424
- pymdownx.superfences
25+
- attr_list
26+
- md_in_html
2527
extra_css:
2628
- stylesheets/extra.css
2729
plugins:

tests/unit/accounts/test_core.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,11 @@
3434
TokenServiceFactory,
3535
database_login_factory,
3636
)
37-
from warehouse.errors import BasicAuthBreachedPassword, BasicAuthFailedPassword
37+
from warehouse.errors import (
38+
BasicAuthBreachedPassword,
39+
BasicAuthFailedPassword,
40+
BasicAuthTwoFactorEnabled,
41+
)
3842
from warehouse.events.tags import EventTag
3943
from warehouse.oidc.models import OIDCPublisher
4044
from warehouse.rate_limiting import IRateLimiter, RateLimit
@@ -76,6 +80,7 @@ def test_with_invalid_password(self, pyramid_request, pyramid_services):
7680
lambda userid, password, tags=None: False
7781
),
7882
is_disabled=pretend.call_recorder(lambda user_id: (False, None)),
83+
has_two_factor=pretend.call_recorder(lambda uid: False),
7984
)
8085
pyramid_services.register_service(service, IUserService, None)
8186
pyramid_services.register_service(
@@ -212,6 +217,7 @@ def test_with_valid_password(self, monkeypatch, pyramid_request, pyramid_service
212217
),
213218
update_user=pretend.call_recorder(lambda userid, last_login: None),
214219
is_disabled=pretend.call_recorder(lambda user_id: (False, None)),
220+
has_two_factor=pretend.call_recorder(lambda uid: False),
215221
)
216222
breach_service = pretend.stub(
217223
check_password=pretend.call_recorder(lambda pw, tags=None: False)
@@ -232,6 +238,7 @@ def test_with_valid_password(self, monkeypatch, pyramid_request, pyramid_service
232238
assert service.find_userid.calls == [pretend.call("myuser")]
233239
assert service.get_user.calls == [pretend.call(2)]
234240
assert service.is_disabled.calls == [pretend.call(2)]
241+
assert service.has_two_factor.calls == [pretend.call(2)]
235242
assert service.check_password.calls == [
236243
pretend.call(
237244
2,
@@ -252,6 +259,52 @@ def test_with_valid_password(self, monkeypatch, pyramid_request, pyramid_service
252259
)
253260
]
254261

262+
def test_via_basic_auth_2fa_enforced(
263+
self, monkeypatch, pyramid_request, pyramid_services
264+
):
265+
send_email = pretend.call_recorder(lambda *a, **kw: None)
266+
267+
user = pretend.stub(id=2, username="multiFactor")
268+
service = pretend.stub(
269+
get_user=pretend.call_recorder(lambda user_id: user),
270+
find_userid=pretend.call_recorder(lambda username: 2),
271+
check_password=pretend.call_recorder(
272+
lambda userid, password, tags=None: True
273+
),
274+
is_disabled=pretend.call_recorder(lambda user_id: (False, None)),
275+
disable_password=pretend.call_recorder(
276+
lambda user_id, request, reason=None: None
277+
),
278+
has_two_factor=pretend.call_recorder(lambda uid: True),
279+
)
280+
breach_service = pretend.stub(
281+
check_password=pretend.call_recorder(lambda pw, tags=None: False),
282+
)
283+
284+
pyramid_services.register_service(service, IUserService, None)
285+
pyramid_services.register_service(
286+
breach_service, IPasswordBreachedService, None
287+
)
288+
289+
pyramid_request.matched_route = pretend.stub(name="forklift.legacy.file_upload")
290+
291+
with pytest.raises(BasicAuthTwoFactorEnabled) as excinfo:
292+
_basic_auth_check("multiFactor", "mypass", pyramid_request)
293+
294+
assert excinfo.value.status == (
295+
"401 User multiFactor has two factor auth enabled, "
296+
"an API Token or Trusted Publisher must be used "
297+
"to upload in place of password."
298+
)
299+
assert service.find_userid.calls == [pretend.call("multiFactor")]
300+
assert service.get_user.calls == [pretend.call(2)]
301+
assert service.is_disabled.calls == [pretend.call(2)]
302+
assert service.has_two_factor.calls == [pretend.call(2)]
303+
assert service.check_password.calls == []
304+
assert breach_service.check_password.calls == []
305+
assert service.disable_password.calls == []
306+
assert send_email.calls == []
307+
255308
def test_via_basic_auth_compromised(
256309
self, monkeypatch, pyramid_request, pyramid_services
257310
):
@@ -271,6 +324,7 @@ def test_via_basic_auth_compromised(
271324
disable_password=pretend.call_recorder(
272325
lambda user_id, request, reason=None: None
273326
),
327+
has_two_factor=pretend.call_recorder(lambda uid: False),
274328
)
275329
breach_service = pretend.stub(
276330
check_password=pretend.call_recorder(lambda pw, tags=None: True),

warehouse/accounts/security_policy.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
BasicAuthAccountFrozen,
3030
BasicAuthBreachedPassword,
3131
BasicAuthFailedPassword,
32+
BasicAuthTwoFactorEnabled,
3233
WarehouseDenied,
3334
)
3435
from warehouse.events.tags import EventTag
@@ -75,6 +76,15 @@ def _basic_auth_check(username, password, request):
7576
raise _format_exc_status(BasicAuthAccountFrozen(), "Account is frozen.")
7677
else:
7778
raise _format_exc_status(HTTPUnauthorized(), "Account is disabled.")
79+
elif login_service.has_two_factor(user.id):
80+
raise _format_exc_status(
81+
BasicAuthTwoFactorEnabled(),
82+
(
83+
f"User {user.username} has two factor auth enabled, "
84+
"an API Token or Trusted Publisher must be used to upload "
85+
"in place of password."
86+
),
87+
)
7888
elif login_service.check_password(
7989
user.id,
8090
password,

warehouse/errors.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ class BasicAuthAccountFrozen(HTTPUnauthorized):
2626
pass
2727

2828

29+
class BasicAuthTwoFactorEnabled(HTTPUnauthorized):
30+
pass
31+
32+
2933
class WarehouseDenied(Denied):
3034
def __new__(cls, s, *args, reason=None, **kwargs):
3135
inner = super().__new__(cls, s, *args, **kwargs)

0 commit comments

Comments
 (0)